From be03b974444928715f7b8bceae36c0172bc3a979 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:14:14 +0200 Subject: [PATCH 01/14] move sshKey logic --- proxmox/config_qemu.go | 10 ----- proxmox/config_qemu_cloudinit.go | 42 +++++++++++++++++++++ proxmox/config_qemu_cloudinit_test.go | 44 ++++++++++++++++++++++ proxmox/config_qemu_test.go | 27 ------------- test/data/test_data_qemu/type_PublicKey.go | 42 +++++++++++++++++++++ 5 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 proxmox/config_qemu_cloudinit.go create mode 100644 proxmox/config_qemu_cloudinit_test.go create mode 100644 test/data/test_data_qemu/type_PublicKey.go diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 13180efd..35f40c65 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -1087,16 +1087,6 @@ func SshForwardUsernet(vmr *VmRef, client *Client) (sshPort string, err error) { return } -// URL encodes the ssh keys -func sshKeyUrlEncode(keys string) (encodedKeys string) { - encodedKeys = url.PathEscape(keys + "\n") - encodedKeys = strings.Replace(encodedKeys, "+", "%2B", -1) - encodedKeys = strings.Replace(encodedKeys, "@", "%40", -1) - encodedKeys = strings.Replace(encodedKeys, "=", "%3D", -1) - encodedKeys = strings.Replace(encodedKeys, ":", "%3A", -1) - return -} - // device_del net1 // netdev_del net1 func RemoveSshForwardUsernet(vmr *VmRef, client *Client) (err error) { diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go new file mode 100644 index 00000000..5c8e8e17 --- /dev/null +++ b/proxmox/config_qemu_cloudinit.go @@ -0,0 +1,42 @@ +package proxmox + +import ( + "crypto" + "net/url" + "regexp" + "strings" +) + +var regexMultipleSpaces = regexp.MustCompile(`\s+`) +var regexMultipleSpacesEncoded = regexp.MustCompile(`(%20)+`) +var regexMultipleNewlineEncoded = regexp.MustCompile(`(%0A)+`) + +// URL encodes the ssh keys +func sshKeyUrlDecode(encodedKeys string) (keys []crypto.PublicKey) { + encodedKeys = regexMultipleSpacesEncoded.ReplaceAllString(encodedKeys, "%20") + encodedKeys = strings.TrimSuffix(encodedKeys, "%0A") + encodedKeys = regexMultipleNewlineEncoded.ReplaceAllString(encodedKeys, "%0A") + encodedKeys = strings.ReplaceAll(encodedKeys, "%2B", "+") + encodedKeys = strings.ReplaceAll(encodedKeys, "%40", "@") + encodedKeys = strings.ReplaceAll(encodedKeys, "%3D", "=") + encodedKeys = strings.ReplaceAll(encodedKeys, "%3A", ":") + encodedKeys = strings.ReplaceAll(encodedKeys, "%20", " ") + encodedKeys = strings.ReplaceAll(encodedKeys, "%2F", "/") + for _, key := range strings.Split(encodedKeys, "%0A") { + keys = append(keys, key) + } + return +} + +// URL encodes the ssh keys +func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { + for _, key := range keys { + tmpKey := regexMultipleSpaces.ReplaceAllString(key.(string), " ") + tmpKey = url.PathEscape(tmpKey + "\n") + tmpKey = strings.ReplaceAll(tmpKey, "+", "%2B") + tmpKey = strings.ReplaceAll(tmpKey, "@", "%40") + tmpKey = strings.ReplaceAll(tmpKey, "=", "%3D") + encodedKeys += strings.ReplaceAll(tmpKey, ":", "%3A") + } + return +} diff --git a/proxmox/config_qemu_cloudinit_test.go b/proxmox/config_qemu_cloudinit_test.go new file mode 100644 index 00000000..cf99fb36 --- /dev/null +++ b/proxmox/config_qemu_cloudinit_test.go @@ -0,0 +1,44 @@ +package proxmox + +import ( + "crypto" + "testing" + + "github.com/Telmate/proxmox-api-go/test/data/test_data_qemu" + "github.com/stretchr/testify/require" +) + +func Test_sshKeyUrlDecode(t *testing.T) { + tests := []struct { + name string + input string + output []crypto.PublicKey + }{ + {name: "Decode", + input: test_data_qemu.PublicKey_Encoded_Input(), + output: test_data_qemu.PublicKey_Decoded_Output()}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, sshKeyUrlDecode(test.input)) + }) + } +} + +// Test the encoding logic to encode the ssh keys +func Test_sshKeyUrlEncode(t *testing.T) { + tests := []struct { + name string + input []crypto.PublicKey + output string + }{ + {name: "Encode", + input: test_data_qemu.PublicKey_Decoded_Input(), + output: test_data_qemu.PublicKey_Encoded_Output()}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, sshKeyUrlEncode(test.input)) + }) + } +} diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index d2de5ef8..9c8458ec 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -2,7 +2,6 @@ package proxmox import ( "errors" - "strings" "testing" "github.com/Telmate/proxmox-api-go/internal/util" @@ -7340,29 +7339,3 @@ func Test_ConfigQemu_Validate(t *testing.T) { } } } - -// Test the encoding logic to encode the ssh keys -func Test_sshKeyUrlEncode(t *testing.T) { - input := test_sshKeyUrlEncode_Input() - output := test_sshKeyUrlEncode_Output() - test := sshKeyUrlEncode(input) - require.Equal(t, output, test) -} - -func test_sshKeyUrlEncode_Input() string { - keys := []string{ - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm+d0nWX+yuT2Mfzi8NaKe5ATg0bwmrzZ1ikS/tGs7v/TyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d/WBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2/E76+vym+3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr/RT84uaMF4XLx1CUm+bMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K+QAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh test@VScode", - "ssh-dss AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z+a4ZO/0geqEDZJSideX7Iq8zYrzdXGXfR+8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb/zGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT+QmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9/hVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU/L5O3j75vq+cng77mezAGWfHfBpAL+whKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw+Nac17jmfnRBebYYoJltehCognAU+xAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb/Moos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM+GPRJeffCh4+Ahx84Gptf0m+EXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9/Hb7oQXHEUzQQY= test@VScode", - "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL/GqA+AAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU/8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd/l12tC3q7ujS7hlInkhxbOyhqNXZ+obseOaS0g5Toqpgr+mV1Rg== test@VScode", - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW cardno:18 228 342"} - return strings.Join(keys, "\n") -} - -func test_sshKeyUrlEncode_Output() string { - encodedKeys := []string{ - "ssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm%2Bd0nWX%2ByuT2Mfzi8NaKe5ATg0bwmrzZ1ikS%2FtGs7v%2FTyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d%2FWBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2%2FE76%2Bvym%2B3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr%2FRT84uaMF4XLx1CUm%2BbMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K%2BQAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh%20test%40VScode", - "ssh-dss%20AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z%2Ba4ZO%2F0geqEDZJSideX7Iq8zYrzdXGXfR%2B8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb%2FzGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT%2BQmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9%2FhVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU%2FL5O3j75vq%2Bcng77mezAGWfHfBpAL%2BwhKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw%2BNac17jmfnRBebYYoJltehCognAU%2BxAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb%2FMoos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM%2BGPRJeffCh4%2BAhx84Gptf0m%2BEXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9%2FHb7oQXHEUzQQY%3D%20test%40VScode", - "ecdsa-sha2-nistp521%20AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL%2FGqA%2BAAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU%2F8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd%2Fl12tC3q7ujS7hlInkhxbOyhqNXZ%2BobseOaS0g5Toqpgr%2BmV1Rg%3D%3D%20test%40VScode", - "ssh-ed25519%20AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW%20cardno%3A18%20228%20342"} - return strings.Join(encodedKeys, "%0A") + "%0A" -} diff --git a/test/data/test_data_qemu/type_PublicKey.go b/test/data/test_data_qemu/type_PublicKey.go new file mode 100644 index 00000000..ca8abe70 --- /dev/null +++ b/test/data/test_data_qemu/type_PublicKey.go @@ -0,0 +1,42 @@ +package test_data_qemu + +import ( + "crypto" + "strings" +) + +func PublicKey_Decoded_Output() []crypto.PublicKey { + return []crypto.PublicKey{ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm+d0nWX+yuT2Mfzi8NaKe5ATg0bwmrzZ1ikS/tGs7v/TyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d/WBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2/E76+vym+3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr/RT84uaMF4XLx1CUm+bMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K+QAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh test@VScode", + "ssh-dss AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z+a4ZO/0geqEDZJSideX7Iq8zYrzdXGXfR+8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb/zGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT+QmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9/hVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU/L5O3j75vq+cng77mezAGWfHfBpAL+whKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw+Nac17jmfnRBebYYoJltehCognAU+xAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb/Moos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM+GPRJeffCh4+Ahx84Gptf0m+EXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9/Hb7oQXHEUzQQY= test@VScode", + "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL/GqA+AAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU/8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd/l12tC3q7ujS7hlInkhxbOyhqNXZ+obseOaS0g5Toqpgr+mV1Rg== test@VScode", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW cardno:18 228 342"} +} + +func PublicKey_Decoded_Input() []crypto.PublicKey { + return []crypto.PublicKey{ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm+d0nWX+yuT2Mfzi8NaKe5ATg0bwmrzZ1ikS/tGs7v/TyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d/WBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2/E76+vym+3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr/RT84uaMF4XLx1CUm+bMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K+QAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh test@VScode", + "ssh-dss AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z+a4ZO/0geqEDZJSideX7Iq8zYrzdXGXfR+8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb/zGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT+QmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9/hVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU/L5O3j75vq+cng77mezAGWfHfBpAL+whKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw+Nac17jmfnRBebYYoJltehCognAU+xAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb/Moos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM+GPRJeffCh4+Ahx84Gptf0m+EXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9/Hb7oQXHEUzQQY= test@VScode", + "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL/GqA+AAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU/8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd/l12tC3q7ujS7hlInkhxbOyhqNXZ+obseOaS0g5Toqpgr+mV1Rg== test@VScode", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW cardno:18 228 342"} +} + +func PublicKey_Encoded_Output() string { + return strings.Join([]string{ + "ssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm%2Bd0nWX%2ByuT2Mfzi8NaKe5ATg0bwmrzZ1ikS%2FtGs7v%2FTyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d%2FWBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2%2FE76%2Bvym%2B3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr%2FRT84uaMF4XLx1CUm%2BbMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K%2BQAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh%20test%40VScode", + "ssh-dss%20AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z%2Ba4ZO%2F0geqEDZJSideX7Iq8zYrzdXGXfR%2B8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb%2FzGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT%2BQmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9%2FhVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU%2FL5O3j75vq%2Bcng77mezAGWfHfBpAL%2BwhKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw%2BNac17jmfnRBebYYoJltehCognAU%2BxAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb%2FMoos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM%2BGPRJeffCh4%2BAhx84Gptf0m%2BEXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9%2FHb7oQXHEUzQQY%3D%20test%40VScode", + "ecdsa-sha2-nistp521%20AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL%2FGqA%2BAAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU%2F8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd%2Fl12tC3q7ujS7hlInkhxbOyhqNXZ%2BobseOaS0g5Toqpgr%2BmV1Rg%3D%3D%20test%40VScode", + "ssh-ed25519%20AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW%20cardno%3A18%20228%20342"}, + "%0A") + "%0A" +} + +func PublicKey_Encoded_Input() string { + return strings.Join([]string{ + "ssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAABAQDT7lsC9gTAjL0FUPlHqnz71TzqDMdsdHhWu54M7NN4E9KNzKwzUy1h6ZuOMm%2Bd0nWX%2ByuT2Mfzi8NaKe5ATg0bwmrzZ1ikS%2FtGs7v%2FTyMSBOlmrS5v0g8rn40bphCqnNeNcfP9JR2zyq4UccpdIYA62t6Ky9d%2FWBbsAQRESwZVhpU9JGhwnVHFcNN5svlDwz9wzW1a2J2%2FE76%2Bvym%2B3Rt4W9s3MqQZdbHozo4N43puXq7PH1tTr%2FRT84uaMF4XLx1CUm%2BbMZLgtac8sHl1DJz4gC3MLasD6UXZzRz99K%2BQAHD6YsXHDwdWu6QAkqzS0DNDbm0E618wn4GEZAJJhehh%20test%40VScode", + "ssh-dss%20%20%20%20AAAAB3NzaC1kc3MAAACBAN6VwM2CMPrpz0CT8z4UP5we4Jt1MSDHumArdzTaxaqtAcV6Z%2Ba4ZO%2F0geqEDZJSideX7Iq8zYrzdXGXfR%2B8N5GHoz49mVFit101cKAvcwZhzVeXQ1Cc8Zyjk53qmjWiNonfsjxP9VorNjjb%2FzGnA3ZnazflfyzqwEr8fV7JtUwjAAAAFQDlk3FT%2BQmsKiiBjBuekwyFeVzwiwAAAIBeAlzP9hsVeEbPjEjkxi9%2FhVgNQE8xtuUMZUCq7NOu5RlGzPHStzh8ByMh0Jsly0GbVHUfM84ikSpU%2FL5O3j75vq%2Bcng77mezAGWfHfBpAL%2BwhKfXvYHy0mqb0M1krzbdRbQkt9TV4gNw%2BNac17jmfnRBebYYoJltehCognAU%2BxAAAAIEAmI1SEcjqSTHRnHeypg08ppcpRUGx0Mkcb%2FMoos2SVfSfWBXrNR7p6eRzVPN0gCXSLsiaE0DaRvM%2BGPRJeffCh4%2BAhx84Gptf0m%2BEXH47sPfsumk8XxItDZa4zYYJ2gAISBdLD06iMtmJWAzD59FXDaHedxom9%2FHb7oQXHEUzQQY%3D%20test%40VScode", + "ecdsa-sha2-nistp521%20AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9dgZNa82njYtBR2zhCQs1yHL%2FGqA%2BAAmz97bjj2t2EQwMepx3TT8RubZscqwt6yedPREJU%2F8x0XtoEWkQzjBkGgCc2ip8xGyy6j3Th9YtYj9gW1g7Rwmqwnz0ZOd%2Fl12tC3q7ujS7hlInkhxbOyhqNXZ%2BobseOaS0g5Toqpgr%2BmV1Rg%3D%3D%20%20%20%20test%40VScode", + "%0A", + "%0A", + "ssh-ed25519%20AAAAC3NzaC1lZDI1NTE5AAAAIEY5T2JQgiL5Z5Yuy4yXuUYglVJlpsokHFXR1hvnCVYW%20cardno%3A18%20228%20342"}, + "%0A") + "%0A" +} From 0274a911482b3b9b4deeb6c9ac62233394c93745 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:19:58 +0200 Subject: [PATCH 02/14] feat: add `CloudInit` type --- proxmox/config_qemu.go | 20 ++++++++---------- proxmox/config_qemu_cloudinit.go | 35 ++++++++++++++++++++++++++++++++ proxmox/config_qemu_test.go | 28 +++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 35f40c65..5a49f37a 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -8,7 +8,6 @@ import ( "log" "math/rand" "net" - "net/url" "regexp" "strconv" "strings" @@ -40,6 +39,7 @@ type ConfigQemu struct { CIcustom string `json:"cicustom,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CIpassword string `json:"cipassword,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CIuser string `json:"ciuser,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) + CloudInit *CloudInit `json:"cloudinit,omitempty"` Description string `json:"description,omitempty"` Disks *QemuStorages `json:"disks,omitempty"` EFIDisk QemuDevice `json:"efidisk,omitempty"` // TODO should be a struct @@ -79,7 +79,6 @@ type ConfigQemu struct { Scsihw string `json:"scsihw,omitempty"` // TODO should be custom type with enum Searchdomain string `json:"searchdomain,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) Smbios1 string `json:"smbios1,omitempty"` // TODO should be custom type with enum? - Sshkeys string `json:"sshkeys,omitempty"` // TODO should be an array of strings Startup string `json:"startup,omitempty"` // TODO should be a struct? TPM *TpmState `json:"tpm,omitempty"` Tablet *bool `json:"tablet,omitempty"` @@ -253,9 +252,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.QemuSockets != 0 { params["sockets"] = config.QemuSockets } - if config.Sshkeys != "" { - params["sshkeys"] = sshKeyUrlEncode(config.Sshkeys) - } if config.Startup != "" { params["startup"] = config.Startup } @@ -307,6 +303,10 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire } } + if config.CloudInit != nil { + itemsToDelete += config.CloudInit.mapToAPI(currentConfig.CloudInit, params) + } + // Create EFI disk config.CreateQemuEfiParams(params) @@ -336,7 +336,7 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire } if itemsToDelete != "" { - params["delete"] = itemsToDelete + params["delete"] = strings.TrimPrefix(itemsToDelete, ",") } return } @@ -351,7 +351,9 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi // description:Base image // cores:2 ostype:l26 - config := ConfigQemu{} + config := ConfigQemu{ + CloudInit: CloudInit{}.mapToSDK(params), + } if vmr != nil { config.Node = vmr.node @@ -461,9 +463,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["searchdomain"]; isSet { config.Searchdomain = params["searchdomain"].(string) } - if _, isSet := params["sshkeys"]; isSet { - config.Sshkeys, _ = url.PathUnescape(params["sshkeys"].(string)) - } if _, isSet := params["startup"]; isSet { config.Startup = params["startup"].(string) } @@ -939,7 +938,6 @@ func (config ConfigQemu) HasCloudInit() bool { config.CIpassword != "" || config.Searchdomain != "" || config.Nameserver != "" || - config.Sshkeys != "" || config.CIcustom != "" } diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index 5c8e8e17..c1478dca 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -40,3 +40,38 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { } return } + +type CloudInit struct { + PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` +} + +func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { + if current != nil { // Update + if config.PublicSSHkeys != nil { + if len(*config.PublicSSHkeys) > 0 { + params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) + } else { + delete += ",sshkeys" + } + } + } else { // Create + if config.PublicSSHkeys != nil && len(*config.PublicSSHkeys) > 0 { + params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) + } + } + return +} + +func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { + ci := CloudInit{} + var set bool + if v, isSet := params["sshkeys"]; isSet { + tmp := sshKeyUrlDecode(v.(string)) + ci.PublicSSHkeys = &tmp + set = true + } + if set { + return &ci + } + return nil +} diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 9c8458ec..914c3d35 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -1,6 +1,7 @@ package proxmox import ( + "crypto" "errors" "testing" @@ -62,6 +63,16 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create Agent.FsTrim`, config: &ConfigQemu{Agent: &QemuGuestAgent{FsTrim: util.Pointer(true)}}, output: map[string]interface{}{"agent": "0,fstrim_cloned_disks=1"}}, + // Create CloudInit no need for update as update and create behave the same. will be changed in the future + {name: `Create CloudInit=nil`, + config: &ConfigQemu{}, + output: map[string]interface{}{}}, + {name: `Create CloudInit PublicSSHkeys`, + config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, + output: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Output()}}, + {name: `Create CloudInit PublicSSHkeys empty`, + config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, + output: map[string]interface{}{}}, // Create Disks // Create Disks.Ide @@ -1399,6 +1410,18 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{Agent: &QemuGuestAgent{}}, currentConfig: ConfigQemu{Agent: &QemuGuestAgent{}}, output: map[string]interface{}{"agent": "0"}}, + // Update CloudInit + {name: `Update CloudInit=nil`, + config: &ConfigQemu{}, + output: map[string]interface{}{}}, + {name: `Update CloudInit PublicSSHkeys`, + config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, + output: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Output()}}, + {name: `Update CloudInit PublicSSHkeys empty`, + config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, + output: map[string]interface{}{"delete": "sshkeys"}}, // Update Disk // Update Disk.Ide {name: "Update Disk.Ide.Disk_X DELETE", @@ -3435,6 +3458,11 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { {name: `Agent Type`, input: map[string]interface{}{"agent": string("1,type=virtio")}, output: &ConfigQemu{Agent: &QemuGuestAgent{Enable: util.Pointer(true), Type: util.Pointer(QemuGuestAgentType_VirtIO)}}}, + // CloudInit + {name: `CloudInit PublicSSHkeys`, + input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, + output: &ConfigQemu{CloudInit: &CloudInit{ + PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, // Disks Ide CdRom {name: "Disks Ide CdRom none", input: map[string]interface{}{"ide1": "none,media=cdrom"}, From 17bd2b5099807f33d5e7ac649e6a6c5aff6cfb34 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:25:39 +0200 Subject: [PATCH 03/14] feat: add User to `CloudInit` --- proxmox/config_qemu.go | 10 +--------- proxmox/config_qemu_cloudinit.go | 19 +++++++++++++++++++ proxmox/config_qemu_test.go | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 5a49f37a..ff38b1e0 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -38,7 +38,6 @@ type ConfigQemu struct { BootDisk string `json:"bootdisk,omitempty"` // TODO discuss deprecation? Only returned as it's deprecated in the proxmox api CIcustom string `json:"cicustom,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CIpassword string `json:"cipassword,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) - CIuser string `json:"ciuser,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CloudInit *CloudInit `json:"cloudinit,omitempty"` Description string `json:"description,omitempty"` Disks *QemuStorages `json:"disks,omitempty"` @@ -198,9 +197,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.CIpassword != "" { params["cipassword"] = config.CIpassword } - if config.CIuser != "" { - params["ciuser"] = config.CIuser - } if config.QemuCores != 0 { params["cores"] = config.QemuCores } @@ -390,9 +386,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["cipassword"]; isSet { config.CIpassword = params["cipassword"].(string) } - if _, isSet := params["ciuser"]; isSet { - config.CIuser = params["ciuser"].(string) - } if _, isSet := params["description"]; isSet { config.Description = strings.TrimSpace(params["description"].(string)) } @@ -934,8 +927,7 @@ func (config ConfigQemu) HasCloudInit() bool { return true } } - return config.CIuser != "" || - config.CIpassword != "" || + return config.CIpassword != "" || config.Searchdomain != "" || config.Nameserver != "" || config.CIcustom != "" diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index c1478dca..904f79c7 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -43,10 +43,19 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { type CloudInit struct { PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` + Username *string `json:"username"` // TODO custom type } func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { if current != nil { // Update + if config.Username != nil { + tmp := *config.Username + if tmp != "" { + params["ciuser"] = *config.Username + } else { + delete += ",ciuser" + } + } if config.PublicSSHkeys != nil { if len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) @@ -55,6 +64,9 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface } } } else { // Create + if config.Username != nil && *config.Username != "" { + params["ciuser"] = *config.Username + } if config.PublicSSHkeys != nil && len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) } @@ -65,6 +77,13 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { ci := CloudInit{} var set bool + if v, isSet := params["ciuser"]; isSet { + tmp := v.(string) + if tmp != "" && tmp != " " { + ci.Username = &tmp + set = true + } + } if v, isSet := params["sshkeys"]; isSet { tmp := sshKeyUrlDecode(v.(string)) ci.PublicSSHkeys = &tmp diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 914c3d35..ee7bfe57 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -73,6 +73,12 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit PublicSSHkeys empty`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, output: map[string]interface{}{}}, + {name: `Create CloudInit Username`, + config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("root")}}, + output: map[string]interface{}{"ciuser": "root"}}, + {name: `Create CloudInit Username empty`, + config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("")}}, + output: map[string]interface{}{}}, // Create Disks // Create Disks.Ide @@ -1422,6 +1428,14 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, output: map[string]interface{}{"delete": "sshkeys"}}, + {name: `Update CloudInit Username`, + config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("root")}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("admin")}}, + output: map[string]interface{}{"ciuser": "root"}}, + {name: `Update CloudInit Username empty`, + config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("")}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("admin")}}, + output: map[string]interface{}{"delete": "ciuser"}}, // Update Disk // Update Disk.Ide {name: "Update Disk.Ide.Disk_X DELETE", @@ -3463,6 +3477,13 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, output: &ConfigQemu{CloudInit: &CloudInit{ PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, + {name: `CloudInit Username`, + input: map[string]interface{}{"ciuser": string("root")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Username: util.Pointer("root")}}}, + {name: `CloudInit Username empty`, + input: map[string]interface{}{"ciuser": string(" ")}, + output: &ConfigQemu{}}, // Disks Ide CdRom {name: "Disks Ide CdRom none", input: map[string]interface{}{"ide1": "none,media=cdrom"}, From c1f014ef69170e836d9f6b1710a4be6c90d11bc0 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:31:05 +0200 Subject: [PATCH 04/14] feat: add Password to `CloudInit` --- proxmox/config_qemu.go | 16 ++++------------ proxmox/config_qemu_cloudinit.go | 12 +++++++++++- proxmox/config_qemu_test.go | 11 +++++++++++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index ff38b1e0..cdb81713 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -34,10 +34,9 @@ type ConfigQemu struct { Args string `json:"args,omitempty"` Balloon int `json:"balloon,omitempty"` // TODO should probably be a bool Bios string `json:"bios,omitempty"` - Boot string `json:"boot,omitempty"` // TODO should be an array of custom enums - BootDisk string `json:"bootdisk,omitempty"` // TODO discuss deprecation? Only returned as it's deprecated in the proxmox api - CIcustom string `json:"cicustom,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) - CIpassword string `json:"cipassword,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) + Boot string `json:"boot,omitempty"` // TODO should be an array of custom enums + BootDisk string `json:"bootdisk,omitempty"` // TODO discuss deprecation? Only returned as it's deprecated in the proxmox api + CIcustom string `json:"cicustom,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CloudInit *CloudInit `json:"cloudinit,omitempty"` Description string `json:"description,omitempty"` Disks *QemuStorages `json:"disks,omitempty"` @@ -194,9 +193,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.CIcustom != "" { params["cicustom"] = config.CIcustom } - if config.CIpassword != "" { - params["cipassword"] = config.CIpassword - } if config.QemuCores != 0 { params["cores"] = config.QemuCores } @@ -383,9 +379,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["cicustom"]; isSet { config.CIcustom = params["cicustom"].(string) } - if _, isSet := params["cipassword"]; isSet { - config.CIpassword = params["cipassword"].(string) - } if _, isSet := params["description"]; isSet { config.Description = strings.TrimSpace(params["description"].(string)) } @@ -927,8 +920,7 @@ func (config ConfigQemu) HasCloudInit() bool { return true } } - return config.CIpassword != "" || - config.Searchdomain != "" || + return config.Searchdomain != "" || config.Nameserver != "" || config.CIcustom != "" } diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index 904f79c7..0419ea15 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -43,7 +43,8 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { type CloudInit struct { PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` - Username *string `json:"username"` // TODO custom type + UserPassword *string `json:"userpassword"` // TODO custom type + Username *string `json:"username"` // TODO custom type } func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { @@ -71,12 +72,21 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) } } + // Shared + if config.UserPassword != nil { + params["cipassword"] = *config.UserPassword + } return } func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { ci := CloudInit{} var set bool + if v, isSet := params["cipassword"]; isSet { + tmp := v.(string) + ci.UserPassword = &tmp + set = true + } if v, isSet := params["ciuser"]; isSet { tmp := v.(string) if tmp != "" && tmp != " " { diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index ee7bfe57..465b4735 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -73,6 +73,9 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit PublicSSHkeys empty`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, output: map[string]interface{}{}}, + {name: `Create CloudInit UserPassword`, + config: &ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Enter123!")}}, + output: map[string]interface{}{"cipassword": "Enter123!"}}, {name: `Create CloudInit Username`, config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("root")}}, output: map[string]interface{}{"ciuser": "root"}}, @@ -1428,6 +1431,10 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, output: map[string]interface{}{"delete": "sshkeys"}}, + {name: `Update CloudInit UserPassword`, + config: &ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Enter123!")}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Abc123!")}}, + output: map[string]interface{}{"cipassword": "Enter123!"}}, {name: `Update CloudInit Username`, config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("root")}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("admin")}}, @@ -3477,6 +3484,10 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, output: &ConfigQemu{CloudInit: &CloudInit{ PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, + {name: `CloudInit UserPassword`, + input: map[string]interface{}{"cipassword": string("Enter123!")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + UserPassword: util.Pointer("Enter123!")}}}, {name: `CloudInit Username`, input: map[string]interface{}{"ciuser": string("root")}, output: &ConfigQemu{CloudInit: &CloudInit{ From 907a67c589056819da042b6fc695f2756ee3fd3a Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:39:49 +0200 Subject: [PATCH 05/14] feat: add DNS settings to `CloudInit` --- proxmox/config_guest.go | 6 ++++ proxmox/config_qemu.go | 54 +++++++++++------------------ proxmox/config_qemu_cloudinit.go | 58 ++++++++++++++++++++++++++++++++ proxmox/config_qemu_test.go | 58 ++++++++++++++++++++++++++++++++ proxmox/util.go | 6 ++++ 5 files changed, 147 insertions(+), 35 deletions(-) diff --git a/proxmox/config_guest.go b/proxmox/config_guest.go index 78746c14..81602c8c 100644 --- a/proxmox/config_guest.go +++ b/proxmox/config_guest.go @@ -2,11 +2,17 @@ package proxmox import ( "errors" + "net/netip" "strconv" ) // All code LXC and Qemu have in common should be placed here. +type GuestDNS struct { + NameServers *[]netip.Addr `json:"nameservers"` + SearchDomain *string `json:"searchdomain"` // we are not validating this field, as validating domain names is a complex topic. +} + type GuestResource struct { CpuCores uint `json:"cpu_cores"` CpuUsage float64 `json:"cpu_usage"` diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index cdb81713..71d10752 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -45,15 +45,14 @@ type ConfigQemu struct { HaGroup string `json:"hagroup,omitempty"` HaState string `json:"hastate,omitempty"` // TODO should be custom type with enum Hookscript string `json:"hookscript,omitempty"` - Hotplug string `json:"hotplug,omitempty"` // TODO should be a struct - Ipconfig IpconfigMap `json:"ipconfig,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) - Iso *IsoFile `json:"iso,omitempty"` // Same as Disks.Ide.Disk_2.CdRom.Iso - LinkedVmId uint `json:"linked_id,omitempty"` // Only returned setting it has no effect - Machine string `json:"machine,omitempty"` // TODO should be custom type with enum - Memory int `json:"memory,omitempty"` // TODO should be uint - Name string `json:"name,omitempty"` // TODO should be custom type as there are character and length limitations - Nameserver string `json:"nameserver,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) - Node string `json:"node,omitempty"` // Only returned setting it has no effect, set node in the VmRef instead + Hotplug string `json:"hotplug,omitempty"` // TODO should be a struct + Ipconfig IpconfigMap `json:"ipconfig,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) + Iso *IsoFile `json:"iso,omitempty"` // Same as Disks.Ide.Disk_2.CdRom.Iso + LinkedVmId uint `json:"linked_id,omitempty"` // Only returned setting it has no effect + Machine string `json:"machine,omitempty"` // TODO should be custom type with enum + Memory int `json:"memory,omitempty"` // TODO should be uint + Name string `json:"name,omitempty"` // TODO should be custom type as there are character and length limitations + Node string `json:"node,omitempty"` // Only returned setting it has no effect, set node in the VmRef instead Onboot *bool `json:"onboot,omitempty"` Pool *PoolName `json:"pool,omitempty"` Protection *bool `json:"protection,omitempty"` @@ -67,17 +66,16 @@ type ConfigQemu struct { QemuOs string `json:"ostype,omitempty"` QemuPCIDevices QemuDevices `json:"hostpci,omitempty"` // TODO should be a struct QemuPxe bool `json:"pxe,omitempty"` - QemuSerials QemuDevices `json:"serial,omitempty"` // TODO should be a struct - QemuSockets int `json:"sockets,omitempty"` // TODO should be uint - QemuUnusedDisks QemuDevices `json:"unused,omitempty"` // TODO should be a struct - QemuUsbs QemuDevices `json:"usb,omitempty"` // TODO should be a struct - QemuVcpus int `json:"vcpus,omitempty"` // TODO should be uint - QemuVga QemuDevice `json:"vga,omitempty"` // TODO should be a struct - RNGDrive QemuDevice `json:"rng0,omitempty"` // TODO should be a struct - Scsihw string `json:"scsihw,omitempty"` // TODO should be custom type with enum - Searchdomain string `json:"searchdomain,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) - Smbios1 string `json:"smbios1,omitempty"` // TODO should be custom type with enum? - Startup string `json:"startup,omitempty"` // TODO should be a struct? + QemuSerials QemuDevices `json:"serial,omitempty"` // TODO should be a struct + QemuSockets int `json:"sockets,omitempty"` // TODO should be uint + QemuUnusedDisks QemuDevices `json:"unused,omitempty"` // TODO should be a struct + QemuUsbs QemuDevices `json:"usb,omitempty"` // TODO should be a struct + QemuVcpus int `json:"vcpus,omitempty"` // TODO should be uint + QemuVga QemuDevice `json:"vga,omitempty"` // TODO should be a struct + RNGDrive QemuDevice `json:"rng0,omitempty"` // TODO should be a struct + Scsihw string `json:"scsihw,omitempty"` // TODO should be custom type with enum + Smbios1 string `json:"smbios1,omitempty"` // TODO should be custom type with enum? + Startup string `json:"startup,omitempty"` // TODO should be a struct? TPM *TpmState `json:"tpm,omitempty"` Tablet *bool `json:"tablet,omitempty"` Tags *[]Tag `json:"tags,omitempty"` @@ -220,9 +218,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.Name != "" { params["name"] = config.Name } - if config.Nameserver != "" { - params["nameserver"] = config.Nameserver - } if config.QemuNuma != nil { params["numa"] = *config.QemuNuma } @@ -238,9 +233,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.Scsihw != "" { params["scsihw"] = config.Scsihw } - if config.Searchdomain != "" { - params["searchdomain"] = config.Searchdomain - } if config.QemuSockets != 0 { params["sockets"] = config.QemuSockets } @@ -407,9 +399,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["name"]; isSet { config.Name = params["name"].(string) } - if _, isSet := params["nameserver"]; isSet { - config.Nameserver = params["nameserver"].(string) - } if _, isSet := params["onboot"]; isSet { config.Onboot = util.Pointer(Itob(int(params["onboot"].(float64)))) } @@ -446,9 +435,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["scsihw"]; isSet { config.Scsihw = params["scsihw"].(string) } - if _, isSet := params["searchdomain"]; isSet { - config.Searchdomain = params["searchdomain"].(string) - } if _, isSet := params["startup"]; isSet { config.Startup = params["startup"].(string) } @@ -920,9 +906,7 @@ func (config ConfigQemu) HasCloudInit() bool { return true } } - return config.Searchdomain != "" || - config.Nameserver != "" || - config.CIcustom != "" + return config.CIcustom != "" } /* diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index 0419ea15..e1b182a1 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -2,6 +2,7 @@ package proxmox import ( "crypto" + "net/netip" "net/url" "regexp" "strings" @@ -42,6 +43,7 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { } type CloudInit struct { + DNS *GuestDNS `json:"dns"` PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` UserPassword *string `json:"userpassword"` // TODO custom type Username *string `json:"username"` // TODO custom type @@ -57,6 +59,26 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface delete += ",ciuser" } } + if config.DNS != nil { + if config.DNS.SearchDomain != nil { + if *config.DNS.SearchDomain != "" { + params["searchdomain"] = *config.DNS.SearchDomain + } else { + delete += ",searchdomain" + } + } + if config.DNS.NameServers != nil { + if len(*config.DNS.NameServers) > 0 { + var nameservers string + for _, ns := range *config.DNS.NameServers { + nameservers += " " + ns.String() + } + params["nameserver"] = nameservers[1:] + } else { + delete += ",nameserver" + } + } + } if config.PublicSSHkeys != nil { if len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) @@ -68,6 +90,18 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface if config.Username != nil && *config.Username != "" { params["ciuser"] = *config.Username } + if config.DNS != nil { + if config.DNS.SearchDomain != nil && *config.DNS.SearchDomain != "" { + params["searchdomain"] = *config.DNS.SearchDomain + } + if config.DNS.NameServers != nil && len(*config.DNS.NameServers) > 0 { + var nameservers string + for _, ns := range *config.DNS.NameServers { + nameservers += " " + ns.String() + } + params["nameserver"] = nameservers[1:] + } + } if config.PublicSSHkeys != nil && len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) } @@ -99,6 +133,30 @@ func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { ci.PublicSSHkeys = &tmp set = true } + var dnsSet bool + var nameservers []netip.Addr + if v, isSet := params["nameserver"]; isSet { + tmp := strings.Split(v.(string), " ") + nameservers = make([]netip.Addr, len(tmp)) + for i, e := range tmp { + nameservers[i], _ = netip.ParseAddr(e) + } + dnsSet = true + } + var domain string + if v, isSet := params["searchdomain"]; isSet { + if len(v.(string)) > 1 { + domain = v.(string) + dnsSet = true + } + } + if dnsSet { + ci.DNS = &GuestDNS{ + SearchDomain: &domain, + NameServers: &nameservers, + } + set = true + } if set { return &ci } diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 465b4735..c8240b9f 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -3,6 +3,7 @@ package proxmox import ( "crypto" "errors" + "net/netip" "testing" "github.com/Telmate/proxmox-api-go/internal/util" @@ -13,6 +14,10 @@ import ( ) func Test_ConfigQemu_mapToApiValues(t *testing.T) { + parseIP := func(rawIP string) (ip netip.Addr) { + ip, _ = netip.ParseAddr(rawIP) + return + } format_Raw := QemuDiskFormat_Raw float10 := QemuDiskBandwidthMBpsLimitConcurrent(10.3) float45 := QemuDiskBandwidthMBpsLimitConcurrent(45.23) @@ -67,6 +72,20 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit=nil`, config: &ConfigQemu{}, output: map[string]interface{}{}}, + {name: `Create CloudInit DNS NameServers`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{parseIP("9.9.9.9")}}}}, + output: map[string]interface{}{"nameserver": "9.9.9.9"}}, + {name: `Create CloudInit DNS NameServers empty`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{}}}}, + output: map[string]interface{}{}}, + {name: `Create CloudInit DNS SearchDomain`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.com")}}}, + output: map[string]interface{}{"searchdomain": "example.com"}}, + {name: `Create CloudInit DNS SearchDomain empty`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("")}}}, + output: map[string]interface{}{}}, {name: `Create CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, output: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Output()}}, @@ -1423,6 +1442,26 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Update CloudInit=nil`, config: &ConfigQemu{}, output: map[string]interface{}{}}, + {name: `Update CloudInit DNS NameServers`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{parseIP("9.9.9.9")}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{parseIP("8.8.8.8")}}}}, + output: map[string]interface{}{"nameserver": "9.9.9.9"}}, + {name: `Update CloudInit DNS NameServers empty`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ + NameServers: &[]netip.Addr{parseIP("8.8.8.8")}}}}, + output: map[string]interface{}{"delete": "nameserver"}}, + {name: `Update CloudInit DNS SearchDomain`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.com")}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.org")}}}, + output: map[string]interface{}{"searchdomain": "example.com"}}, + {name: `Update CloudInit DNS SearchDomain empty`, + config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("")}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.org")}}}, + output: map[string]interface{}{"delete": "searchdomain"}}, {name: `Update CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, @@ -3446,6 +3485,10 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { } func Test_ConfigQemu_mapToStruct(t *testing.T) { + parseIP := func(rawIP string) (ip netip.Addr) { + ip, _ = netip.ParseAddr(rawIP) + return + } uint1 := uint(1) uint2 := uint(2) uint31 := uint(31) @@ -3480,6 +3523,21 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { input: map[string]interface{}{"agent": string("1,type=virtio")}, output: &ConfigQemu{Agent: &QemuGuestAgent{Enable: util.Pointer(true), Type: util.Pointer(QemuGuestAgentType_VirtIO)}}}, // CloudInit + {name: `CloudInit DNS SearchDomain`, + input: map[string]interface{}{"searchdomain": string("example.com")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + DNS: &GuestDNS{ + SearchDomain: util.Pointer("example.com"), + NameServers: util.Pointer(uninitializedArray[netip.Addr]())}}}}, + {name: `CloudInit DNS SearchDomain empty`, + input: map[string]interface{}{"searchdomain": string(" ")}, + output: &ConfigQemu{}}, + {name: `CloudInit DNS NameServers`, + input: map[string]interface{}{"nameserver": string("1.1.1.1 8.8.8.8 9.9.9.9")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + DNS: &GuestDNS{ + SearchDomain: util.Pointer(""), + NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}}}}, {name: `CloudInit PublicSSHkeys`, input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, output: &ConfigQemu{CloudInit: &CloudInit{ diff --git a/proxmox/util.go b/proxmox/util.go index d5925ccc..93f1708d 100644 --- a/proxmox/util.go +++ b/proxmox/util.go @@ -263,3 +263,9 @@ func subtractArray[T comparable](A, B []T) (result []T) { } return } + +// To be used during testing +func uninitializedArray[T any]() []T { + var x []T + return x +} From 645a7fb6259f274995ff313d6b37915bd98f58d4 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:01:45 +0200 Subject: [PATCH 06/14] feat: add Custom to `CloudInit` --- proxmox/config_qemu.go | 14 +- proxmox/config_qemu_cloudinit.go | 192 ++++++++++++++++++ proxmox/config_qemu_cloudinit_test.go | 134 ++++++++++++ proxmox/config_qemu_test.go | 115 +++++++++++ .../type_CloudInitSnippetPath.go | 123 +++++++++++ 5 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 test/data/test_data_qemu/type_CloudInitSnippetPath.go diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 71d10752..cdb73e74 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -36,7 +36,6 @@ type ConfigQemu struct { Bios string `json:"bios,omitempty"` Boot string `json:"boot,omitempty"` // TODO should be an array of custom enums BootDisk string `json:"bootdisk,omitempty"` // TODO discuss deprecation? Only returned as it's deprecated in the proxmox api - CIcustom string `json:"cicustom,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) CloudInit *CloudInit `json:"cloudinit,omitempty"` Description string `json:"description,omitempty"` Disks *QemuStorages `json:"disks,omitempty"` @@ -188,9 +187,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire if config.Boot != "" { params["boot"] = config.Boot } - if config.CIcustom != "" { - params["cicustom"] = config.CIcustom - } if config.QemuCores != 0 { params["cores"] = config.QemuCores } @@ -368,9 +364,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi if _, isSet := params["bios"]; isSet { config.Bios = params["bios"].(string) } - if _, isSet := params["cicustom"]; isSet { - config.CIcustom = params["cicustom"].(string) - } if _, isSet := params["description"]; isSet { config.Description = strings.TrimSpace(params["description"].(string)) } @@ -868,6 +861,11 @@ func (config ConfigQemu) Validate(current *ConfigQemu) (err error) { return } } + if config.CloudInit != nil { + if err = config.CloudInit.Validate(); err != nil { + return + } + } if config.Disks != nil { err = config.Disks.Validate() if err != nil { @@ -906,7 +904,7 @@ func (config ConfigQemu) HasCloudInit() bool { return true } } - return config.CIcustom != "" + return false } /* diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index e1b182a1..fffa31d3 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -2,6 +2,7 @@ package proxmox import ( "crypto" + "errors" "net/netip" "net/url" "regexp" @@ -43,6 +44,7 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { } type CloudInit struct { + Custom *CloudInitCustom `json:"cicustom"` DNS *GuestDNS `json:"dns"` PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` UserPassword *string `json:"userpassword"` // TODO custom type @@ -51,6 +53,9 @@ type CloudInit struct { func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { if current != nil { // Update + if config.Custom != nil { + params["cicustom"] = config.Custom.mapToAPI(current.Custom) + } if config.Username != nil { tmp := *config.Username if tmp != "" { @@ -87,6 +92,9 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface } } } else { // Create + if config.Custom != nil { + params["cicustom"] = config.Custom.mapToAPI(nil) + } if config.Username != nil && *config.Username != "" { params["ciuser"] = *config.Username } @@ -116,6 +124,10 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { ci := CloudInit{} var set bool + if v, isSet := params["cicustom"]; isSet { + ci.Custom = CloudInitCustom{}.mapToSDK(v.(string)) + set = true + } if v, isSet := params["cipassword"]; isSet { tmp := v.(string) ci.UserPassword = &tmp @@ -162,3 +174,183 @@ func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { } return nil } + +func (ci CloudInit) Validate() error { + if ci.Custom != nil { + if err := ci.Custom.Validate(); err != nil { + return err + } + } + return nil +} + +type CloudInitCustom struct { + Meta *CloudInitSnippet `json:"meta"` + Network *CloudInitSnippet `json:"network"` + User *CloudInitSnippet `json:"user"` + Vendor *CloudInitSnippet `json:"vendor"` +} + +func (config CloudInitCustom) mapToAPI(current *CloudInitCustom) string { + var param string + if current != nil { // update + if config.Meta != nil { + param += config.Meta.mapToAPI("meta") + } else { + param += current.Meta.mapToAPI("meta") + } + if config.Network != nil { + param += config.Network.mapToAPI("network") + } else { + param += current.Network.mapToAPI("network") + } + if config.User != nil { + param += config.User.mapToAPI("user") + } else { + param += current.User.mapToAPI("user") + } + if config.Vendor != nil { + param += config.Vendor.mapToAPI("vendor") + } else { + param += current.Vendor.mapToAPI("vendor") + } + } else { // create + if config.Meta != nil { + param += config.Meta.mapToAPI("meta") + } + if config.Network != nil { + param += config.Network.mapToAPI("network") + } + if config.User != nil { + param += config.User.mapToAPI("user") + } + if config.Vendor != nil { + param += config.Vendor.mapToAPI("vendor") + } + } + if param != "" { + return param[1:] + } + return "" +} + +func (CloudInitCustom) mapToSDK(raw string) *CloudInitCustom { + var set bool + var config CloudInitCustom + params := splitStringOfSettings(raw) + if v, isSet := params["meta"]; isSet { + config.Meta = CloudInitSnippet{}.mapToSDK(v.(string)) + set = true + } + if v, isSet := params["network"]; isSet { + config.Network = CloudInitSnippet{}.mapToSDK(v.(string)) + set = true + } + if v, isSet := params["user"]; isSet { + config.User = CloudInitSnippet{}.mapToSDK(v.(string)) + set = true + } + if v, isSet := params["vendor"]; isSet { + config.Vendor = CloudInitSnippet{}.mapToSDK(v.(string)) + set = true + } + if set { + return &config + } + return nil +} + +func (ci CloudInitCustom) Validate() (err error) { + if ci.Meta != nil { + if err = ci.Meta.Validate(); err != nil { + return + } + } + if ci.Network != nil { + if err = ci.Network.Validate(); err != nil { + return + } + } + if ci.User != nil { + if err = ci.User.Validate(); err != nil { + return err + } + } + if ci.Vendor != nil { + err = ci.Vendor.Validate() + } + return +} + +func (ci CloudInitCustom) String() string { + return ci.mapToAPI(nil) +} + +// If either Storage or FilePath is empty, the snippet will be removed +type CloudInitSnippet struct { + Storage string `json:"storage"` // TODO custom type (storage) + FilePath CloudInitSnippetPath `json:"path"` +} + +func (ci CloudInitSnippet) mapToAPI(kind string) string { + tmp := ci.String() + if tmp != ":" { + return "," + kind + "=" + tmp + } + return "" +} + +func (CloudInitSnippet) mapToSDK(param string) *CloudInitSnippet { + file := strings.SplitN(param, ":", 2) + if len(file) == 2 { + return &CloudInitSnippet{ + Storage: file[0], + FilePath: CloudInitSnippetPath(file[1])} + } + return nil +} + +func (config CloudInitSnippet) String() string { + return config.Storage + ":" + string(config.FilePath) +} + +func (ci CloudInitSnippet) Validate() error { + if ci.FilePath != "" { + return ci.FilePath.Validate() + } + return nil +} + +type CloudInitSnippetPath string + +var ( + regexCloudInitSnippetPath_Charters = regexp.MustCompile(`^[a-zA-Z0-9- _\/.]+$`) + regexCloudInitSnippetPath_Path = regexp.MustCompile(`^[^,=/]+(\/[^,=/]+)*$`) +) + +const ( + CloudInitSnippetPath_Error_Empty = "cloudInitSnippetPath may not be empty" + CloudInitSnippetPath_Error_InvalidCharacters = "cloudInitSnippetPath may ony contain the following characters: [a-zA-Z0-9_ -/.]" + CloudInitSnippetPath_Error_InvalidPath = "cloudInitSnippetPath must be a valid unix path" + CloudInitSnippetPath_Error_MaxLength = "cloudInitSnippetPath may not be longer than 256 characters" + CloudInitSnippetPath_Error_Relative = "cloudInitSnippetPath must be an relative path" +) + +func (path CloudInitSnippetPath) Validate() error { + if path == "" { + return errors.New(CloudInitSnippetPath_Error_Empty) + } + if path[:1] == "/" { + return errors.New(CloudInitSnippetPath_Error_Relative) + } + if len(path) > 256 { + return errors.New(CloudInitSnippetPath_Error_MaxLength) + } + if !regexCloudInitSnippetPath_Charters.MatchString(string(path)) { + return errors.New(CloudInitSnippetPath_Error_InvalidCharacters) + } + if !regexCloudInitSnippetPath_Path.MatchString(string(path)) { + return errors.New(CloudInitSnippetPath_Error_InvalidPath) + } + return nil +} diff --git a/proxmox/config_qemu_cloudinit_test.go b/proxmox/config_qemu_cloudinit_test.go index cf99fb36..37dd6ce3 100644 --- a/proxmox/config_qemu_cloudinit_test.go +++ b/proxmox/config_qemu_cloudinit_test.go @@ -2,6 +2,7 @@ package proxmox import ( "crypto" + "errors" "testing" "github.com/Telmate/proxmox-api-go/test/data/test_data_qemu" @@ -42,3 +43,136 @@ func Test_sshKeyUrlEncode(t *testing.T) { }) } } + +func Test_CloudInit_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInit + output error + }{ + {name: `Valid CloudInit CloudInitCustom FilePath`, + input: CloudInit{Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}}}}, + {name: `Valid CloudInit CloudInitCustom FilePath empty`, + input: CloudInit{Custom: &CloudInitCustom{Network: &CloudInitSnippet{}}}}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidCharacters)`, + input: CloudInit{Custom: &CloudInitCustom{User: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Character_Illegal()[0])}}}, + output: errors.New(CloudInitSnippetPath_Error_InvalidCharacters)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidPath)`, + input: CloudInit{Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_InvalidPath())}}}, + output: errors.New(CloudInitSnippetPath_Error_InvalidPath)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_MaxLength)`, + input: CloudInit{Custom: &CloudInitCustom{Meta: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Illegal())}}}, + output: errors.New(CloudInitSnippetPath_Error_MaxLength)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_Relative)`, + input: CloudInit{Custom: &CloudInitCustom{Network: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}}, + output: errors.New(CloudInitSnippetPath_Error_Relative)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_CloudInitCustom_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInitCustom + output error + }{ + {name: `Valid CloudInitCustom FilePath`, + input: CloudInitCustom{Meta: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}}}, + {name: `Valid CloudInitCustom FilePath empty`, + input: CloudInitCustom{Network: &CloudInitSnippet{}}}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidCharacters`, + input: CloudInitCustom{User: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Character_Illegal()[0])}}, + output: errors.New(CloudInitSnippetPath_Error_InvalidCharacters)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidPath)`, + input: CloudInitCustom{Vendor: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_InvalidPath())}}, + output: errors.New(CloudInitSnippetPath_Error_InvalidPath)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_MaxLength)`, + input: CloudInitCustom{Meta: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Illegal())}}, + output: errors.New(CloudInitSnippetPath_Error_MaxLength)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_Relative)`, + input: CloudInitCustom{Network: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}, + output: errors.New(CloudInitSnippetPath_Error_Relative)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_CloudInitSnippet_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInitSnippet + output error + }{ + {name: `Valid FilePath`, + input: CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}}, + {name: `Valid FilePath empty`}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidCharacters)`, + input: CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Character_Illegal()[0])}, + output: errors.New(CloudInitSnippetPath_Error_InvalidCharacters)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidPath)`, + input: CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_InvalidPath())}, + output: errors.New(CloudInitSnippetPath_Error_InvalidPath)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_MaxLength)`, + input: CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Illegal())}, + output: errors.New(CloudInitSnippetPath_Error_MaxLength)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_Relative)`, + input: CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}, + output: errors.New(CloudInitSnippetPath_Error_Relative)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_CloudInitSnippetPath_Validate(t *testing.T) { + tests := []struct { + name string + input []string + output error + }{ + {name: `Valid`, + input: test_data_qemu.CloudInitSnippetPath_Legal()}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_Empty)`, + input: []string{test_data_qemu.CloudInitSnippetPath_Min_Illegal()}, + output: errors.New(CloudInitSnippetPath_Error_Empty)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidCharacters)`, + input: test_data_qemu.CloudInitSnippetPath_Character_Illegal(), + output: errors.New(CloudInitSnippetPath_Error_InvalidCharacters)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidPath)`, + input: []string{test_data_qemu.CloudInitSnippetPath_InvalidPath()}, + output: errors.New(CloudInitSnippetPath_Error_InvalidPath)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_MaxLength)`, + input: []string{test_data_qemu.CloudInitSnippetPath_Max_Illegal()}, + output: errors.New(CloudInitSnippetPath_Error_MaxLength)}, + {name: `Invalid errors.New(CloudInitSnippetPath_Error_Relative)`, + input: []string{test_data_qemu.CloudInitSnippetPath_Relative()}, + output: errors.New(CloudInitSnippetPath_Error_Relative)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, input := range test.input { + require.Equal(t, test.output, CloudInitSnippetPath(input).Validate()) + } + }) + } +} diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index c8240b9f..7b918d16 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -14,6 +14,21 @@ import ( ) func Test_ConfigQemu_mapToApiValues(t *testing.T) { + cloudInitCustom := func() *CloudInitCustom { + return &CloudInitCustom{ + Meta: &CloudInitSnippet{ + Storage: "local-zfs", + FilePath: "ci-meta.yml"}, + Network: &CloudInitSnippet{ + Storage: "local-lvm", + FilePath: "ci-network.yml"}, + User: &CloudInitSnippet{ + Storage: "folder", + FilePath: "ci-user.yml"}, + Vendor: &CloudInitSnippet{ + Storage: "local", + FilePath: "snippets/ci-custom.yml"}} + } parseIP := func(rawIP string) (ip netip.Addr) { ip, _ = netip.ParseAddr(rawIP) return @@ -72,6 +87,30 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit=nil`, config: &ConfigQemu{}, output: map[string]interface{}{}}, + {name: `Create CloudInit Custom Network`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Network: &CloudInitSnippet{ + Storage: "local", + FilePath: "ci-network.yml"}}}}, + output: map[string]interface{}{"cicustom": "network=local:ci-network.yml"}}, + {name: `Create CloudInit Custom User`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + User: &CloudInitSnippet{ + Storage: "file", + FilePath: "abcd.yml"}}}}, + output: map[string]interface{}{"cicustom": "user=file:abcd.yml"}}, + {name: `Create CloudInit Custom Vendor`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Vendor: &CloudInitSnippet{ + Storage: "local", + FilePath: "vendor-ci"}}}}, + output: map[string]interface{}{"cicustom": "vendor=local:vendor-ci"}}, + {name: `Create CloudInit Custom Meta`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{ + Storage: "local-zfs", + FilePath: "ci-meta.yml"}}}}, + output: map[string]interface{}{"cicustom": "meta=local-zfs:ci-meta.yml"}}, {name: `Create CloudInit DNS NameServers`, config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ NameServers: &[]netip.Addr{parseIP("9.9.9.9")}}}}, @@ -1442,6 +1481,42 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Update CloudInit=nil`, config: &ConfigQemu{}, output: map[string]interface{}{}}, + {name: `Update CloudInit Custom clear`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{}, + Network: &CloudInitSnippet{}, + User: &CloudInitSnippet{}, + Vendor: &CloudInitSnippet{}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Custom: cloudInitCustom()}}, + output: map[string]interface{}{"cicustom": ""}}, + {name: `Update CloudInit Custom Network`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Network: &CloudInitSnippet{ + Storage: "newStorage", + FilePath: "new.yml"}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Custom: cloudInitCustom()}}, + output: map[string]interface{}{"cicustom": "meta=local-zfs:ci-meta.yml,network=newStorage:new.yml,user=folder:ci-user.yml,vendor=local:snippets/ci-custom.yml"}}, + {name: `Update CloudInit Custom User`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + User: &CloudInitSnippet{ + Storage: "newStorage", + FilePath: "new.yml"}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Custom: cloudInitCustom()}}, + output: map[string]interface{}{"cicustom": "meta=local-zfs:ci-meta.yml,network=local-lvm:ci-network.yml,user=newStorage:new.yml,vendor=local:snippets/ci-custom.yml"}}, + {name: `Update CloudInit Custom Vendor`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Vendor: &CloudInitSnippet{ + Storage: "newStorage", + FilePath: "new.yml"}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Custom: cloudInitCustom()}}, + output: map[string]interface{}{"cicustom": "meta=local-zfs:ci-meta.yml,network=local-lvm:ci-network.yml,user=folder:ci-user.yml,vendor=newStorage:new.yml"}}, + {name: `Update CloudInit Custom Meta`, + config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{ + Storage: "newStorage", + FilePath: "new.yml"}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{Custom: cloudInitCustom()}}, + output: map[string]interface{}{"cicustom": "meta=newStorage:new.yml,network=local-lvm:ci-network.yml,user=folder:ci-user.yml,vendor=local:snippets/ci-custom.yml"}}, {name: `Update CloudInit DNS NameServers`, config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{ NameServers: &[]netip.Addr{parseIP("9.9.9.9")}}}}, @@ -3523,6 +3598,23 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { input: map[string]interface{}{"agent": string("1,type=virtio")}, output: &ConfigQemu{Agent: &QemuGuestAgent{Enable: util.Pointer(true), Type: util.Pointer(QemuGuestAgentType_VirtIO)}}}, // CloudInit + {name: `CloudInit Custom Meta`, + input: map[string]interface{}{"cicustom": string("meta=local-zfs:ci-meta.yml")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{Meta: &CloudInitSnippet{FilePath: "ci-meta.yml", Storage: "local-zfs"}}}}}, + {name: `CloudInit Custom Network`, + input: map[string]interface{}{"cicustom": string("network=local-lvm:ci-network.yml")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{Network: &CloudInitSnippet{FilePath: "ci-network.yml", Storage: "local-lvm"}}}}}, + {name: `CloudInit Custom User`, + input: map[string]interface{}{ + "cicustom": string("user=folder:ci-user.yml")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{User: &CloudInitSnippet{FilePath: "ci-user.yml", Storage: "folder"}}}}}, + {name: `CloudInit Custom Vendor`, + input: map[string]interface{}{"cicustom": string("vendor=local:snippets/ci-custom.yml")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{FilePath: "snippets/ci-custom.yml", Storage: "local"}}}}}, {name: `CloudInit DNS SearchDomain`, input: map[string]interface{}{"searchdomain": string("example.com")}, output: &ConfigQemu{CloudInit: &CloudInit{ @@ -6201,6 +6293,13 @@ func Test_ConfigQemu_Validate(t *testing.T) { // Valid Agent {name: "Valid Agent", input: ConfigQemu{Agent: &QemuGuestAgent{Type: util.Pointer(QemuGuestAgentType("isa"))}}}, + // Valid CloudInit + {name: `Valid CloudInit`, + input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}, + Network: &CloudInitSnippet{FilePath: ""}, + User: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}, + Vendor: &CloudInitSnippet{FilePath: ""}}}}}, // Valid Disks {name: "Valid Disks Empty 0", input: ConfigQemu{Disks: &QemuStorages{}}, @@ -6332,6 +6431,22 @@ func Test_ConfigQemu_Validate(t *testing.T) { {name: "Invalid Agent", input: ConfigQemu{Agent: &QemuGuestAgent{Type: util.Pointer(QemuGuestAgentType("test"))}}, err: errors.New(QemuGuestAgentType_Error_Invalid)}, + // Invalid CloudInit + {name: `Invalid CloudInit errors.New(CloudInitSnippetPath_Error_InvalidCharacters)`, + input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{Meta: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Character_Illegal()[0])}}}}, + err: errors.New(CloudInitSnippetPath_Error_InvalidCharacters)}, + {name: `Invalid CloudInit errors.New(CloudInitSnippetPath_Error_InvalidPath)`, + input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{Network: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_InvalidPath())}}}}, + err: errors.New(CloudInitSnippetPath_Error_InvalidPath)}, + {name: `Invalid CloudInit errors.New(CloudInitSnippetPath_Error_MaxLength)`, + input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{User: &CloudInitSnippet{ + FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Illegal())}}}}, + err: errors.New(CloudInitSnippetPath_Error_MaxLength)}, + {name: `Invalid CloudInit errors.New(CloudInitSnippetPath_Error_Relative)`, + input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}}}, + err: errors.New(CloudInitSnippetPath_Error_Relative)}, // Invalid Disks Mutually exclusive Ide {name: "Invalid Disks MutuallyExclusive Ide 0", input: ConfigQemu{Disks: &QemuStorages{Ide: &QemuIdeDisks{Disk_0: &QemuIdeStorage{ diff --git a/test/data/test_data_qemu/type_CloudInitSnippetPath.go b/test/data/test_data_qemu/type_CloudInitSnippetPath.go new file mode 100644 index 00000000..867535ee --- /dev/null +++ b/test/data/test_data_qemu/type_CloudInitSnippetPath.go @@ -0,0 +1,123 @@ +package test_data_qemu + +// illegal character +func CloudInitSnippetPath_Character_Illegal() []string { + return []string{ + "aBc123!4567890_-", + "Qwer@ty-1234_ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "x1y2#z3_4-5-6-7-8-9", + "HelloWo$rld_2023", + "Ab1_%cd2_ef3-gh4-ij5", + "a-_-^_-_-_-_-_-_-_-_-_-", + "snaps&hotName_2433242", + "A1_B2-*C3_D4-E5_F6", + "Xyz-123(_456_789-0", + "Test_Cas)e-123_456_789_0", + "a_1+", + "B-c_=2-D", + "E3_f4-G5_:H6-I7", + "JKL_MNO_PQ;R-STU_VWX_YZ0", + "aBgnhfjkfgd'ihfghudsfgio", + `Cdsdjfidshfu"isdghfsgffghdsufsdhfgdsfuah`, + "Ef-`gh", + "Ij-k~l-mn", + "Op-qr-st-u-vw-xy-z0-12-34-56-[78-90", + "Abcd_1234-EFGH_]5678-IJKL_9012", + "M-n-Op-qR-sT-uV{-wX-yZ", + "a_b-c_d_e-f_g_h_}i_j_k_l_m_n-o-p-q-r-s-t", + "Aa1_Bb2-C,c3_Dd4-Ee5_Ff6-Gg7_Hh8-Ii9", + "JjKkLl-MmNnOo.PpQ)qRrSsTtUuVvWwXxYyZz01", + "A->1", + "B-2<_C-3", + "D-4_?E-5-F-6", + "G-7-H/-8*-I-9", + `J-0_K-\1-L-2-M-3-N-4-O-5-P-6-Q-7-R-8-S-9`, + "T-0_U-1-|V-2-W-3-X-4-Y-5-Z-6-7-8-9-0", + "a2😀", + } +} + +// 256 valid characters +func CloudInitSnippetPath_Max_Legal() string { + return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/- _.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/- _.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/- _.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012" +} + +// 257 invalid characters +func CloudInitSnippetPath_Max_Illegal() string { + return CloudInitSnippetPath_Max_Legal() + "A" +} + +// 1 valid characters +func CloudInitSnippetPath_Min_Legal() string { + return CloudInitSnippetPath_Min_Illegal() + "a" +} + +// 2 invalid characters +func CloudInitSnippetPath_Min_Illegal() string { + return "" +} + +func CloudInitSnippetPath_Legal() []string { + return []string{ + "aBc1234567890_-", + "Qwerty-1234_ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "x1y2z3_4-5-6-7-8-9", + "HelloWorld_2023", + "Ab1_cd2_ef3-gh4-ij5", + "a-_-_-_-_-_-_-_-_-_-_-", + "snapshotName_2433242", + "A1_B2-C3_D4-E5_F6", + "Xyz-123_456_789-0", + "Test_Case-123_456_789_0", + "a_1", + "B-c_2-D", + "E3_f4-G5_H6-I7", + "JKL_MNO_PQR-STU_VWX_YZ0", + "aBgnhfjkfgdihfghudsfgio", + "Cdsdjfidshfuisdghfsgffghdsufsdhfgdsfuahs", + "Ef-gh", + "Ij-kl-mn", + "Op-qr-st-u-vw-xy-z0-12-34-56-78-90", + "Abcd_1234-EFGH_5678-IJKL_9012", + "M-n-Op-qR-sT-uV-wX-yZ", + "a_b-c_d_e-f_g_h_i_j_k_l_m_n-o-p-q-r-s-t-", + "Aa1_Bb2-Cc3_Dd4-Ee5_Ff6-Gg7_Hh8-Ii9", + "JjKkLl-MmNnOoPpQqRrSsTtUuVvWwXxYyZz01", + "A-1", + "B-2_C-3", + "D-4_E-5-F-6", + "G-7-H-8-I-9", + "J-0_K-1-L-2-M-3-N-4-O-5-P-6-Q-7-R-8-S-9", + "T-0_U-1-V-2-W-3-X-4-Y-5-Z-6-7-8-9-0", + "a2B", + "c4D", + "e6F-g8H-i0J", + "k2L-m4N-o6P-q8R-s0T", + "u2V-w4X-y6Z-01-23-45-67-89-0", + "Abc_1234-Def_5678-Ghi_9012-Jkl_3456-Mno_", + "Pqr_2345-Stu_6789-Vwx_0123-Yz0_4567", + "a-B", + "c-D_e-F", + "g-H_i-J-k-L", + "m-N-o-P_q-R-s-T-u-V-w-X-y-Z-0", + "A_1b2-C3d4_E5f6-G7h8_I9j0-K1l2-M3n4", + "O5p6-Q7r8-S9t0-U1v2-W3x4-Y5z6-01", + "A2b3-C4d5-E6f7-G8h9-I0j1-K2l3-M4n5-O6", + "P7q8-R9s0-T1u2-V3w4-X5y6-Z7-89-01-23-45-", + "Ab_12-cD_34-eF_56-gH_78-iJ_90-kL_12-mN_3", + "O5p6-Q7r8-S9t0-U1v2-W3x4-Y5z6-01-23-45", + "A7b8-C9d0-E1f2-G3h4-I5j6-K7l8-M9n0-O1p2-", + "S5t6-U7v8-W9x0-Y1z2-34-56-78-90-12-34-56", + "Ab1C_d2E-F3G_h4I-J5k6L-m7N-o8P-q9R-s0T-u", + CloudInitSnippetPath_Max_Legal(), + CloudInitSnippetPath_Min_Legal(), + } +} + +func CloudInitSnippetPath_InvalidPath() string { + return "yhfg8fiusfhis/fmdjfhsudf//dsad" +} + +func CloudInitSnippetPath_Relative() string { + return "/file" +} From 3d31414e4a55340260ca0b37f5eb1a76865466ad Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:26:25 +0200 Subject: [PATCH 07/14] feat: add NetworkInterfaces to `CloudInit` --- proxmox/config_qemu.go | 100 ++++++++++++-------------- proxmox/config_qemu_cloudinit.go | 48 +++++++++++-- proxmox/config_qemu_cloudinit_test.go | 3 + proxmox/config_qemu_test.go | 86 +++++++++++++++++++--- test/api/CloudInit/cloudinit_test.go | 7 +- 5 files changed, 170 insertions(+), 74 deletions(-) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index cdb73e74..20a4cb17 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -45,7 +45,6 @@ type ConfigQemu struct { HaState string `json:"hastate,omitempty"` // TODO should be custom type with enum Hookscript string `json:"hookscript,omitempty"` Hotplug string `json:"hotplug,omitempty"` // TODO should be a struct - Ipconfig IpconfigMap `json:"ipconfig,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) Iso *IsoFile `json:"iso,omitempty"` // Same as Disks.Ide.Disk_2.CdRom.Iso LinkedVmId uint `json:"linked_id,omitempty"` // Only returned setting it has no effect Machine string `json:"machine,omitempty"` // TODO should be custom type with enum @@ -113,9 +112,6 @@ func (config *ConfigQemu) defaults() { if config.Hotplug == "" { config.Hotplug = "network,disk,usb" } - if config.Ipconfig == nil { - config.Ipconfig = IpconfigMap{} - } if config.Protection == nil { config.Protection = util.Pointer(false) } @@ -310,11 +306,6 @@ func (config ConfigQemu) mapToApiValues(currentConfig ConfigQemu) (rebootRequire config.CreateQemuPCIsParams(params) - err = config.CreateIpconfigParams(params) - if err != nil { - log.Printf("[ERROR] %q", err) - } - if itemsToDelete != "" { params["delete"] = strings.TrimPrefix(itemsToDelete, ",") } @@ -442,24 +433,6 @@ func (ConfigQemu) mapToStruct(vmr *VmRef, params map[string]interface{}) (*Confi config.Smbios1 = params["smbios1"].(string) } - ipconfigNames := []string{} - - for k := range params { - if ipconfigName := rxIpconfigName.FindStringSubmatch(k); len(ipconfigName) > 0 { - ipconfigNames = append(ipconfigNames, ipconfigName[0]) - } - } - - if len(ipconfigNames) > 0 { - config.Ipconfig = IpconfigMap{} - for _, ipconfigName := range ipconfigNames { - ipConfStr := params[ipconfigName] - id := rxDeviceID.FindStringSubmatch(ipconfigName) - ipconfigID, _ := strconv.Atoi(id[0]) - config.Ipconfig[ipconfigID] = ipConfStr - } - } - linkedVmId := uint(0) config.Disks = QemuStorages{}.mapToStruct(params, &linkedVmId) if linkedVmId != 0 { @@ -897,16 +870,6 @@ func (config ConfigQemu) Validate(current *ConfigQemu) (err error) { return } -// HasCloudInit - are there cloud-init options? -func (config ConfigQemu) HasCloudInit() bool { - for _, config := range config.Ipconfig { - if config != nil && config != "" { - return true - } - } - return false -} - /* CloneVm Example: Request @@ -1282,23 +1245,6 @@ func (c ConfigQemu) CreateQemuNetworksParams(params map[string]interface{}) { } } -// Create parameters for each Cloud-Init ipconfig entry. -func (c ConfigQemu) CreateIpconfigParams(params map[string]interface{}) error { - - for ID, config := range c.Ipconfig { - if ID > 15 { - return fmt.Errorf("only up to 16 Cloud-Init network configurations supported (ipconfig[0-15]), skipping ipconfig%d", ID) - } - - if config != "" { - ipconfigName := "ipconfig" + strconv.Itoa(ID) - params[ipconfigName] = config - } - } - - return nil -} - // Create RNG parameter. func (c ConfigQemu) CreateQemuRngParams(params map[string]interface{}) { rngParam := QemuDeviceParam{} @@ -1455,3 +1401,49 @@ func (c ConfigQemu) String() string { jsConf, _ := json.Marshal(c) return string(jsConf) } + +type QemuNetworkInterfaceID uint8 + +const ( + QemuNetworkInterfaceID_Error_Invalid string = "network interface ID must be in the range 0-31" + + QemuNetworkInterfaceID0 QemuNetworkInterfaceID = 0 + QemuNetworkInterfaceID1 QemuNetworkInterfaceID = 1 + QemuNetworkInterfaceID2 QemuNetworkInterfaceID = 2 + QemuNetworkInterfaceID3 QemuNetworkInterfaceID = 3 + QemuNetworkInterfaceID4 QemuNetworkInterfaceID = 4 + QemuNetworkInterfaceID5 QemuNetworkInterfaceID = 5 + QemuNetworkInterfaceID6 QemuNetworkInterfaceID = 6 + QemuNetworkInterfaceID7 QemuNetworkInterfaceID = 7 + QemuNetworkInterfaceID8 QemuNetworkInterfaceID = 8 + QemuNetworkInterfaceID9 QemuNetworkInterfaceID = 9 + QemuNetworkInterfaceID10 QemuNetworkInterfaceID = 10 + QemuNetworkInterfaceID11 QemuNetworkInterfaceID = 11 + QemuNetworkInterfaceID12 QemuNetworkInterfaceID = 12 + QemuNetworkInterfaceID13 QemuNetworkInterfaceID = 13 + QemuNetworkInterfaceID14 QemuNetworkInterfaceID = 14 + QemuNetworkInterfaceID15 QemuNetworkInterfaceID = 15 + QemuNetworkInterfaceID16 QemuNetworkInterfaceID = 16 + QemuNetworkInterfaceID17 QemuNetworkInterfaceID = 17 + QemuNetworkInterfaceID18 QemuNetworkInterfaceID = 18 + QemuNetworkInterfaceID19 QemuNetworkInterfaceID = 19 + QemuNetworkInterfaceID20 QemuNetworkInterfaceID = 20 + QemuNetworkInterfaceID21 QemuNetworkInterfaceID = 21 + QemuNetworkInterfaceID22 QemuNetworkInterfaceID = 22 + QemuNetworkInterfaceID23 QemuNetworkInterfaceID = 23 + QemuNetworkInterfaceID24 QemuNetworkInterfaceID = 24 + QemuNetworkInterfaceID25 QemuNetworkInterfaceID = 25 + QemuNetworkInterfaceID26 QemuNetworkInterfaceID = 26 + QemuNetworkInterfaceID27 QemuNetworkInterfaceID = 27 + QemuNetworkInterfaceID28 QemuNetworkInterfaceID = 28 + QemuNetworkInterfaceID29 QemuNetworkInterfaceID = 29 + QemuNetworkInterfaceID30 QemuNetworkInterfaceID = 30 + QemuNetworkInterfaceID31 QemuNetworkInterfaceID = 31 +) + +func (id QemuNetworkInterfaceID) Validate() error { + if id > 31 { + return fmt.Errorf(QemuNetworkInterfaceID_Error_Invalid) + } + return nil +} diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index fffa31d3..c53d9c0a 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -6,6 +6,7 @@ import ( "net/netip" "net/url" "regexp" + "strconv" "strings" ) @@ -44,11 +45,12 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { } type CloudInit struct { - Custom *CloudInitCustom `json:"cicustom"` - DNS *GuestDNS `json:"dns"` - PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` - UserPassword *string `json:"userpassword"` // TODO custom type - Username *string `json:"username"` // TODO custom type + Custom *CloudInitCustom `json:"cicustom"` + DNS *GuestDNS `json:"dns"` + NetworkInterfaces CloudInitNetworkInterfaces `json:"ipconfig"` + PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` + UserPassword *string `json:"userpassword"` // TODO custom type + Username *string `json:"username"` // TODO custom type } func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { @@ -115,6 +117,7 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface } } // Shared + config.NetworkInterfaces.mapToAPI(params) if config.UserPassword != nil { params["cipassword"] = *config.UserPassword } @@ -169,7 +172,8 @@ func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { } set = true } - if set { + ci.NetworkInterfaces = CloudInitNetworkInterfaces{}.mapToSDK(params) + if set || len(ci.NetworkInterfaces) > 0 { return &ci } return nil @@ -181,7 +185,7 @@ func (ci CloudInit) Validate() error { return err } } - return nil + return ci.NetworkInterfaces.Validate() } type CloudInitCustom struct { @@ -286,6 +290,36 @@ func (ci CloudInitCustom) String() string { return ci.mapToAPI(nil) } +type CloudInitNetworkInterfaces map[QemuNetworkInterfaceID]string // TODO string should be a custom type + +func (interfaces CloudInitNetworkInterfaces) mapToAPI(params map[string]interface{}) { + for i, e := range interfaces { + params["ipconfig"+strconv.FormatInt(int64(i), 10)] = e + } +} + +func (CloudInitNetworkInterfaces) mapToSDK(params map[string]interface{}) CloudInitNetworkInterfaces { + ci := make(CloudInitNetworkInterfaces) + for i := QemuNetworkInterfaceID(0); i < 32; i++ { + if v, isSet := params["ipconfig"+strconv.FormatInt(int64(i), 10)]; isSet { + tmp := v.(string) + if len(tmp) > 1 { // can be "" or " " + ci[i] = v.(string) + } + } + } + return ci +} + +func (interfaces CloudInitNetworkInterfaces) Validate() (err error) { + for i := range interfaces { + if err = i.Validate(); err != nil { + return + } + } + return +} + // If either Storage or FilePath is empty, the snippet will be removed type CloudInitSnippet struct { Storage string `json:"storage"` // TODO custom type (storage) diff --git a/proxmox/config_qemu_cloudinit_test.go b/proxmox/config_qemu_cloudinit_test.go index 37dd6ce3..189d725f 100644 --- a/proxmox/config_qemu_cloudinit_test.go +++ b/proxmox/config_qemu_cloudinit_test.go @@ -72,6 +72,9 @@ func Test_CloudInit_Validate(t *testing.T) { input: CloudInit{Custom: &CloudInitCustom{Network: &CloudInitSnippet{ FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}}, output: errors.New(CloudInitSnippetPath_Error_Relative)}, + {name: `Invalid errors.New(QemuNetworkInterfaceID_Error_Invalid)`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{32: ""}}, + output: errors.New(QemuNetworkInterfaceID_Error_Invalid)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 7b918d16..3e61f81b 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -125,6 +125,15 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit DNS SearchDomain empty`, config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("")}}}, output: map[string]interface{}{}}, + {name: `Create CloudInit NetworkInterfaces`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID1: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID20: "", + QemuNetworkInterfaceID30: "ip=10.20.4.7/22"}}}, + output: map[string]interface{}{ + "ipconfig1": "ip=dhcp,ip6=dhcp", + "ipconfig20": "", + "ipconfig30": "ip=10.20.4.7/22"}}, {name: `Create CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, output: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Output()}}, @@ -1537,6 +1546,19 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("")}}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.org")}}}, output: map[string]interface{}{"delete": "searchdomain"}}, + {name: `Update CloudInit NetworkInterfaces`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID1: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID20: "", + QemuNetworkInterfaceID30: "ip=10.20.4.7/22"}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID15: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID25: "ip=dhcp,ip6=dhcp", + }}}, + output: map[string]interface{}{ + "ipconfig1": "ip=dhcp,ip6=dhcp", + "ipconfig20": "", + "ipconfig30": "ip=10.20.4.7/22"}}, {name: `Update CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, @@ -3601,26 +3623,31 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { {name: `CloudInit Custom Meta`, input: map[string]interface{}{"cicustom": string("meta=local-zfs:ci-meta.yml")}, output: &ConfigQemu{CloudInit: &CloudInit{ - Custom: &CloudInitCustom{Meta: &CloudInitSnippet{FilePath: "ci-meta.yml", Storage: "local-zfs"}}}}}, + Custom: &CloudInitCustom{Meta: &CloudInitSnippet{FilePath: "ci-meta.yml", Storage: "local-zfs"}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit Custom Network`, input: map[string]interface{}{"cicustom": string("network=local-lvm:ci-network.yml")}, output: &ConfigQemu{CloudInit: &CloudInit{ - Custom: &CloudInitCustom{Network: &CloudInitSnippet{FilePath: "ci-network.yml", Storage: "local-lvm"}}}}}, + Custom: &CloudInitCustom{Network: &CloudInitSnippet{FilePath: "ci-network.yml", Storage: "local-lvm"}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit Custom User`, input: map[string]interface{}{ "cicustom": string("user=folder:ci-user.yml")}, output: &ConfigQemu{CloudInit: &CloudInit{ - Custom: &CloudInitCustom{User: &CloudInitSnippet{FilePath: "ci-user.yml", Storage: "folder"}}}}}, + Custom: &CloudInitCustom{User: &CloudInitSnippet{FilePath: "ci-user.yml", Storage: "folder"}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit Custom Vendor`, input: map[string]interface{}{"cicustom": string("vendor=local:snippets/ci-custom.yml")}, output: &ConfigQemu{CloudInit: &CloudInit{ - Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{FilePath: "snippets/ci-custom.yml", Storage: "local"}}}}}, + Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{FilePath: "snippets/ci-custom.yml", Storage: "local"}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit DNS SearchDomain`, input: map[string]interface{}{"searchdomain": string("example.com")}, output: &ConfigQemu{CloudInit: &CloudInit{ DNS: &GuestDNS{ SearchDomain: util.Pointer("example.com"), - NameServers: util.Pointer(uninitializedArray[netip.Addr]())}}}}, + NameServers: util.Pointer(uninitializedArray[netip.Addr]())}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit DNS SearchDomain empty`, input: map[string]interface{}{"searchdomain": string(" ")}, output: &ConfigQemu{}}, @@ -3629,19 +3656,33 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { output: &ConfigQemu{CloudInit: &CloudInit{ DNS: &GuestDNS{ SearchDomain: util.Pointer(""), - NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}}}}, + NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, + {name: `CloudInit NetworkInterfaces`, + input: map[string]interface{}{ + "ipconfig0": string("ip=dhcp,ip6=dhcp"), + "ipconfig19": string(""), + "ipconfig20": string(" "), // this single space is on porpuse to test if it is ignored + "ipconfig31": string("ip=10.20.4.7/22")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}}}}, {name: `CloudInit PublicSSHkeys`, input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, output: &ConfigQemu{CloudInit: &CloudInit{ - PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}, + PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, {name: `CloudInit UserPassword`, input: map[string]interface{}{"cipassword": string("Enter123!")}, output: &ConfigQemu{CloudInit: &CloudInit{ - UserPassword: util.Pointer("Enter123!")}}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}, + UserPassword: util.Pointer("Enter123!")}}}, {name: `CloudInit Username`, input: map[string]interface{}{"ciuser": string("root")}, output: &ConfigQemu{CloudInit: &CloudInit{ - Username: util.Pointer("root")}}}, + NetworkInterfaces: CloudInitNetworkInterfaces{}, + Username: util.Pointer("root")}}}, {name: `CloudInit Username empty`, input: map[string]interface{}{"ciuser": string(" ")}, output: &ConfigQemu{}}, @@ -6299,7 +6340,9 @@ func Test_ConfigQemu_Validate(t *testing.T) { Meta: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}, Network: &CloudInitSnippet{FilePath: ""}, User: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}, - Vendor: &CloudInitSnippet{FilePath: ""}}}}}, + Vendor: &CloudInitSnippet{FilePath: ""}}, + NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: ""}}}}, // Valid Disks {name: "Valid Disks Empty 0", input: ConfigQemu{Disks: &QemuStorages{}}, @@ -6447,6 +6490,10 @@ func Test_ConfigQemu_Validate(t *testing.T) { {name: `Invalid CloudInit errors.New(CloudInitSnippetPath_Error_Relative)`, input: ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{Vendor: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}}}, err: errors.New(CloudInitSnippetPath_Error_Relative)}, + {name: `Invalid CloudInit errors.New(QemuNetworkInterfaceID_Error_Invalid)`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + 32: ""}}}, + err: errors.New(QemuNetworkInterfaceID_Error_Invalid)}, // Invalid Disks Mutually exclusive Ide {name: "Invalid Disks MutuallyExclusive Ide 0", input: ConfigQemu{Disks: &QemuStorages{Ide: &QemuIdeDisks{Disk_0: &QemuIdeStorage{ @@ -7572,3 +7619,22 @@ func Test_ConfigQemu_Validate(t *testing.T) { } } } + +func Test_QemuNetworkInterfaceID_Validate(t *testing.T) { + tests := []struct { + name string + input QemuNetworkInterfaceID + output error + }{ + {name: "Valid", + input: QemuNetworkInterfaceID0}, + {name: "Invalid", + input: 32, + output: errors.New(QemuNetworkInterfaceID_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} diff --git a/test/api/CloudInit/cloudinit_test.go b/test/api/CloudInit/cloudinit_test.go index 7a117e3f..2dc14e8c 100644 --- a/test/api/CloudInit/cloudinit_test.go +++ b/test/api/CloudInit/cloudinit_test.go @@ -33,17 +33,18 @@ func Test_Cloud_Init_VM(t *testing.T) { err = config.Create(vmref, Test.GetClient()) require.NoError(t, err) - config.Ipconfig = pxapi.IpconfigMap{} config.Boot = "order=virtio0;ide2;net0" - config.Ipconfig[0] = "gw=10.0.0.1,ip=10.0.0.2/24" + config.CloudInit = &pxapi.CloudInit{ + NetworkInterfaces: pxapi.CloudInitNetworkInterfaces{ + pxapi.QemuNetworkInterfaceID0: "gw=10.0.0.1,ip=10.0.0.2/24"}} _, err = config.Update(true, vmref, Test.GetClient()) require.NoError(t, err) testConfig, _ := pxapi.NewConfigQemuFromApi(vmref, Test.GetClient()) - require.Equal(t, testConfig.Ipconfig[0], "gw=10.0.0.1,ip=10.0.0.2/24") + require.Equal(t, testConfig.CloudInit.NetworkInterfaces[pxapi.QemuNetworkInterfaceID0], "gw=10.0.0.1,ip=10.0.0.2/24") _, err = Test.GetClient().DeleteVm(vmref) require.NoError(t, err) From 1172954490fd1c800dc4be2ec2ce1f0a3fc59bc8 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:34:06 +0200 Subject: [PATCH 08/14] feat: add Upgrade to `CloudInit` --- proxmox/config_qemu_cloudinit.go | 9 +++++++++ proxmox/config_qemu_test.go | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index c53d9c0a..a2733c55 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -49,6 +49,7 @@ type CloudInit struct { DNS *GuestDNS `json:"dns"` NetworkInterfaces CloudInitNetworkInterfaces `json:"ipconfig"` PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` + UpgradePackages *bool `json:"ciupgrade"` UserPassword *string `json:"userpassword"` // TODO custom type Username *string `json:"username"` // TODO custom type } @@ -118,6 +119,9 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface } // Shared config.NetworkInterfaces.mapToAPI(params) + if config.UpgradePackages != nil { + params["ciupgrade"] = Btoi(*config.UpgradePackages) + } if config.UserPassword != nil { params["cipassword"] = *config.UserPassword } @@ -136,6 +140,11 @@ func (CloudInit) mapToSDK(params map[string]interface{}) *CloudInit { ci.UserPassword = &tmp set = true } + if v, isSet := params["ciupgrade"]; isSet { + tmp := Itob(int(v.(float64))) + ci.UpgradePackages = &tmp + set = true + } if v, isSet := params["ciuser"]; isSet { tmp := v.(string) if tmp != "" && tmp != " " { diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 3e61f81b..72851f5f 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -140,6 +140,9 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit PublicSSHkeys empty`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, output: map[string]interface{}{}}, + {name: `Create CloudInit UpgradePackages`, + config: &ConfigQemu{CloudInit: &CloudInit{UpgradePackages: util.Pointer(false)}}, + output: map[string]interface{}{"ciupgrade": 0}}, {name: `Create CloudInit UserPassword`, config: &ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Enter123!")}}, output: map[string]interface{}{"cipassword": "Enter123!"}}, @@ -1567,6 +1570,10 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, output: map[string]interface{}{"delete": "sshkeys"}}, + {name: `Update CloudInit UpgradePackages`, + config: &ConfigQemu{CloudInit: &CloudInit{UpgradePackages: util.Pointer(false)}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{UpgradePackages: util.Pointer(true)}}, + output: map[string]interface{}{"ciupgrade": 0}}, {name: `Update CloudInit UserPassword`, config: &ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Enter123!")}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{UserPassword: util.Pointer("Abc123!")}}, @@ -3673,6 +3680,11 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { output: &ConfigQemu{CloudInit: &CloudInit{ NetworkInterfaces: CloudInitNetworkInterfaces{}, PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}}}, + {name: `CloudInit UpgradePackages`, + input: map[string]interface{}{"ciupgrade": float64(0)}, + output: &ConfigQemu{CloudInit: &CloudInit{ + NetworkInterfaces: CloudInitNetworkInterfaces{}, + UpgradePackages: util.Pointer(false)}}}, {name: `CloudInit UserPassword`, input: map[string]interface{}{"cipassword": string("Enter123!")}, output: &ConfigQemu{CloudInit: &CloudInit{ From 7cfb2490a123e65b8f11ef29869c8b1ffb43451c Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:35:13 +0200 Subject: [PATCH 09/14] test: `CloudInit` full --- proxmox/config_qemu_test.go | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 72851f5f..101a66c6 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -87,6 +87,43 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { {name: `Create CloudInit=nil`, config: &ConfigQemu{}, output: map[string]interface{}{}}, + {name: `Create CloudInit Full`, + config: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{ + Storage: "local-zfs", + FilePath: "ci-meta.yml"}, + Network: &CloudInitSnippet{ + Storage: "local-lvm", + FilePath: "ci-network.yml"}, + User: &CloudInitSnippet{ + Storage: "folder", + FilePath: "ci-user.yml"}, + Vendor: &CloudInitSnippet{ + Storage: "local", + FilePath: "snippets/ci-custom.yml"}}, + DNS: &GuestDNS{ + SearchDomain: util.Pointer("example.com"), + NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}, + NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID19: "", + QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}, + PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input()), + UpgradePackages: util.Pointer(true), + UserPassword: util.Pointer("Enter123!"), + Username: util.Pointer("root")}}, + output: map[string]interface{}{ + "cicustom": "meta=local-zfs:ci-meta.yml,network=local-lvm:ci-network.yml,user=folder:ci-user.yml,vendor=local:snippets/ci-custom.yml", + "searchdomain": "example.com", + "nameserver": "1.1.1.1 8.8.8.8 9.9.9.9", + "ipconfig0": "ip=dhcp,ip6=dhcp", + "ipconfig19": "", + "ipconfig31": "ip=10.20.4.7/22", + "sshkeys": test_data_qemu.PublicKey_Encoded_Output(), + "ciupgrade": 1, + "cipassword": "Enter123!", + "ciuser": "root"}}, {name: `Create CloudInit Custom Network`, config: &ConfigQemu{CloudInit: &CloudInit{Custom: &CloudInitCustom{ Network: &CloudInitSnippet{ @@ -3627,6 +3664,42 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { input: map[string]interface{}{"agent": string("1,type=virtio")}, output: &ConfigQemu{Agent: &QemuGuestAgent{Enable: util.Pointer(true), Type: util.Pointer(QemuGuestAgentType_VirtIO)}}}, // CloudInit + {name: `CloudInit ALL`, + input: map[string]interface{}{ + "cicustom": string("meta=local-zfs:ci-meta.yml,network=local-lvm:ci-network.yml,user=folder:ci-user.yml,vendor=local:snippets/ci-custom.yml"), + "searchdomain": string("example.com"), + "nameserver": string("1.1.1.1 8.8.8.8 9.9.9.9"), + "ipconfig0": string("ip=dhcp,ip6=dhcp"), + "ipconfig19": string(""), + "ipconfig31": string("ip=10.20.4.7/22"), + "sshkeys": test_data_qemu.PublicKey_Encoded_Input(), + "ciupgrade": float64(1), + "cipassword": string("Enter123!"), + "ciuser": string("root")}, + output: &ConfigQemu{CloudInit: &CloudInit{ + Custom: &CloudInitCustom{ + Meta: &CloudInitSnippet{ + FilePath: "ci-meta.yml", + Storage: "local-zfs"}, + Network: &CloudInitSnippet{ + FilePath: "ci-network.yml", + Storage: "local-lvm"}, + User: &CloudInitSnippet{ + FilePath: "ci-user.yml", + Storage: "folder"}, + Vendor: &CloudInitSnippet{ + FilePath: "snippets/ci-custom.yml", + Storage: "local"}}, + DNS: &GuestDNS{ + SearchDomain: util.Pointer("example.com"), + NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}, + NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", + QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}, + PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output()), + UpgradePackages: util.Pointer(true), + UserPassword: util.Pointer("Enter123!"), + Username: util.Pointer("root")}}}, {name: `CloudInit Custom Meta`, input: map[string]interface{}{"cicustom": string("meta=local-zfs:ci-meta.yml")}, output: &ConfigQemu{CloudInit: &CloudInit{ From b2dcd74b1d08fe66323c1e3796dbdb61da171869 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:48:27 +0200 Subject: [PATCH 10/14] Remove unused code --- proxmox/config_qemu.go | 1 - 1 file changed, 1 deletion(-) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 20a4cb17..95b29e13 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -927,7 +927,6 @@ var ( rxSerialName = regexp.MustCompile(`serial\d+`) rxUsbName = regexp.MustCompile(`usb\d+`) rxPCIName = regexp.MustCompile(`hostpci\d+`) - rxIpconfigName = regexp.MustCompile(`ipconfig\d+`) ) func NewConfigQemuFromApi(vmr *VmRef, client *Client) (config *ConfigQemu, err error) { From 576a89261b9249dbfec0c716067a84594121de5a Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:48:40 +0200 Subject: [PATCH 11/14] fix: incorrect import --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3d20f5e0..6b14fb69 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Telmate/proxmox-api-go go 1.19 require ( + github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.2 ) @@ -10,7 +11,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect From 73a318ea2be6b2986738c1b6b7901145ad17b6a9 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sat, 15 Jun 2024 17:20:36 +0200 Subject: [PATCH 12/14] feat: type `CloudInitNetworkConfig` --- proxmox/config_qemu_cloudinit.go | 345 ++++++++++++++++++- proxmox/config_qemu_cloudinit_test.go | 460 +++++++++++++++++++++++++- proxmox/config_qemu_test.go | 378 +++++++++++++++++++-- proxmox/util.go | 8 + test/api/CloudInit/cloudinit_test.go | 13 +- 5 files changed, 1171 insertions(+), 33 deletions(-) diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index a2733c55..2698b1a5 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -3,6 +3,7 @@ package proxmox import ( "crypto" "errors" + "net" "net/netip" "net/url" "regexp" @@ -44,6 +45,7 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { return } +// TODO add omitempty everywhere type CloudInit struct { Custom *CloudInitCustom `json:"cicustom"` DNS *GuestDNS `json:"dns"` @@ -87,6 +89,7 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface } } } + delete += config.NetworkInterfaces.mapToAPI(current.NetworkInterfaces, params) if config.PublicSSHkeys != nil { if len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) @@ -113,12 +116,12 @@ func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface params["nameserver"] = nameservers[1:] } } + config.NetworkInterfaces.mapToAPI(nil, params) if config.PublicSSHkeys != nil && len(*config.PublicSSHkeys) > 0 { params["sshkeys"] = sshKeyUrlEncode(*config.PublicSSHkeys) } } // Shared - config.NetworkInterfaces.mapToAPI(params) if config.UpgradePackages != nil { params["ciupgrade"] = Btoi(*config.UpgradePackages) } @@ -299,12 +302,271 @@ func (ci CloudInitCustom) String() string { return ci.mapToAPI(nil) } -type CloudInitNetworkInterfaces map[QemuNetworkInterfaceID]string // TODO string should be a custom type +type CloudInitIPv4Config struct { + Address *IPv4CIDR `json:"address"` + Gateway *IPv4Address `json:"gateway"` + DHCP bool `json:"dhcp"` +} + +const CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive string = "ipv4 dhcp is mutually exclusive with address" +const CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive string = "ipv4 dhcp is mutually exclusive with gateway" + +func (config CloudInitIPv4Config) mapToAPI(current *CloudInitIPv4Config) string { + // config can only be nil during update + if config.DHCP { + return ",ip=dhcp" + } + if current != nil { // Update phase, Update value + var param string + if config.Address != nil { + if *config.Address != "" { + param = ",ip=" + string(*config.Address) + } + } else if current.Address != nil { + param = ",ip=" + string(*current.Address) + } + if config.Gateway != nil { + if *config.Gateway != "" { + param += ",gw=" + string(*config.Gateway) + } + } else if current.Gateway != nil { + param += ",gw=" + string(*current.Gateway) + } + return param + } + // Create phase + var param string + if config.Address != nil && *config.Address != "" { + param = ",ip=" + string(*config.Address) + } + if config.Gateway != nil && *config.Gateway != "" { + param += ",gw=" + string(*config.Gateway) + } + return param +} + +func (config CloudInitIPv4Config) String() string { + param := config.mapToAPI(nil) + if param != "" { + return param[1:] + } + return "" +} + +func (config CloudInitIPv4Config) Validate() error { + if config.Address != nil && *config.Address != "" { + if config.DHCP { + return errors.New(CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive) + } + if err := config.Address.Validate(); err != nil { + return err + } + } + if config.Gateway != nil && *config.Gateway != "" { + if config.DHCP { + return errors.New(CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive) + } + if err := config.Gateway.Validate(); err != nil { + return err + } + } + return nil +} + +type CloudInitIPv6Config struct { + Address *IPv6CIDR `json:"address"` + Gateway *IPv6Address `json:"gateway"` + DHCP bool `json:"dhcp"` + SLAAC bool `json:"slaac"` +} + +func (config CloudInitIPv6Config) mapToAPI(current *CloudInitIPv6Config) string { + if config.DHCP { + return ",ip6=dhcp" + } + if config.SLAAC { + return ",ip6=auto" + } + if current != nil { // Update + var param string + if config.Address != nil { + if *config.Address != "" { + param = ",ip6=" + string(*config.Address) + } + } else if current.Address != nil { + param = ",ip6=" + string(*current.Address) + } + if config.Gateway != nil { + if *config.Gateway != "" { + param += ",gw6=" + string(*config.Gateway) + } + } else if current.Gateway != nil { + param += ",gw6=" + string(*current.Gateway) + } + return param + } + // create + var param string + if config.Address != nil && *config.Address != "" { + param = ",ip6=" + string(*config.Address) + } + if config.Gateway != nil && *config.Gateway != "" { + param += ",gw6=" + string(*config.Gateway) + } + return param +} + +func (config CloudInitIPv6Config) String() string { + param := config.mapToAPI(nil) + if param != "" { + return param[1:] + } + return "" +} + +const CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive string = "ipv6 dhcp is mutually exclusive with address" +const CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive string = "ipv6 dhcp is mutually exclusive with gateway" +const CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive string = "ipv6 dhcp is mutually exclusive with slaac" +const CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive string = "ipv6 slaac is mutually exclusive with address" +const CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive string = "ipv6 slaac is mutually exclusive with gateway" + +func (config CloudInitIPv6Config) Validate() error { + if config.DHCP && config.SLAAC { + return errors.New(CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive) + } + if config.Address != nil && *config.Address != "" { + if config.DHCP { + return errors.New(CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive) + } + if config.SLAAC { + return errors.New(CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive) + } + if err := config.Address.Validate(); err != nil { + return err + } + } + if config.Gateway != nil && *config.Gateway != "" { + if config.DHCP { + return errors.New(CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive) + } + if config.SLAAC { + return errors.New(CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive) + } + if err := config.Gateway.Validate(); err != nil { + return err + } + } + return nil +} + +type CloudInitNetworkConfig struct { + IPv4 *CloudInitIPv4Config `json:"ip4"` + IPv6 *CloudInitIPv6Config `json:"ip6"` +} + +func (config CloudInitNetworkConfig) mapToAPI(current *CloudInitNetworkConfig) (param string) { + if current != nil { // Update + if config.IPv4 != nil { + param += config.IPv4.mapToAPI(current.IPv4) + } else { + if current.IPv4 != nil { + param += current.IPv4.mapToAPI(nil) + } + } + if config.IPv6 != nil { + param += config.IPv6.mapToAPI(current.IPv6) + } else { + if current.IPv6 != nil { + param += current.IPv6.mapToAPI(nil) + } + } + } else { // Create + if config.IPv4 != nil { + param += config.IPv4.mapToAPI(nil) + } + if config.IPv6 != nil { + param += config.IPv6.mapToAPI(nil) + } + } + return +} -func (interfaces CloudInitNetworkInterfaces) mapToAPI(params map[string]interface{}) { +func (CloudInitNetworkConfig) mapToSDK(param string) (config CloudInitNetworkConfig) { + params := splitStringOfSettings(param) + var ipv4Set bool + var ipv6Set bool + var ipv4 CloudInitIPv4Config + var ipv6 CloudInitIPv6Config + if v, isSet := params["ip"]; isSet { + ipv4Set = true + if v.(string) == "dhcp" { + ipv4.DHCP = true + } else { + tmp := IPv4CIDR(v.(string)) + ipv4.Address = &tmp + } + } + if v, isSet := params["gw"]; isSet { + ipv4Set = true + tmp := IPv4Address(v.(string)) + ipv4.Gateway = &tmp + } + if v, isSet := params["ip6"]; isSet { + ipv6Set = true + if v.(string) == "dhcp" { + ipv6.DHCP = true + } else if v.(string) == "auto" { + ipv6.SLAAC = true + } else { + tmp := IPv6CIDR(v.(string)) + ipv6.Address = &tmp + } + } + if v, isSet := params["gw6"]; isSet { + ipv6Set = true + tmp := IPv6Address(v.(string)) + ipv6.Gateway = &tmp + } + if ipv4Set { + config.IPv4 = &ipv4 + } + if ipv6Set { + config.IPv6 = &ipv6 + } + return +} + +func (config CloudInitNetworkConfig) Validate() (err error) { + if config.IPv4 != nil { + if err = config.IPv4.Validate(); err != nil { + return + } + } + if config.IPv6 != nil { + err = config.IPv6.Validate() + } + return +} + +type CloudInitNetworkInterfaces map[QemuNetworkInterfaceID]CloudInitNetworkConfig + +func (interfaces CloudInitNetworkInterfaces) mapToAPI(current CloudInitNetworkInterfaces, params map[string]interface{}) (delete string) { for i, e := range interfaces { - params["ipconfig"+strconv.FormatInt(int64(i), 10)] = e + var tmpCurrent *CloudInitNetworkConfig + if current != nil { + if _, isSet := current[i]; isSet { + tmp := current[i] + tmpCurrent = &tmp + } + } + param := e.mapToAPI(tmpCurrent) + if param != "" { + params["ipconfig"+strconv.FormatInt(int64(i), 10)] = param[1:] + } else if tmpCurrent != nil { + delete += ",ipconfig" + strconv.FormatInt(int64(i), 10) + } } + return } func (CloudInitNetworkInterfaces) mapToSDK(params map[string]interface{}) CloudInitNetworkInterfaces { @@ -313,7 +575,7 @@ func (CloudInitNetworkInterfaces) mapToSDK(params map[string]interface{}) CloudI if v, isSet := params["ipconfig"+strconv.FormatInt(int64(i), 10)]; isSet { tmp := v.(string) if len(tmp) > 1 { // can be "" or " " - ci[i] = v.(string) + ci[i] = CloudInitNetworkConfig{}.mapToSDK(tmp) } } } @@ -325,6 +587,9 @@ func (interfaces CloudInitNetworkInterfaces) Validate() (err error) { if err = i.Validate(); err != nil { return } + if err = interfaces[i].Validate(); err != nil { + return + } } return } @@ -397,3 +662,73 @@ func (path CloudInitSnippetPath) Validate() error { } return nil } + +type IPv4Address string + +const IPv4Address_Error_Invalid = "ipv4Address is not a valid ipv6 address" + +func (ip IPv4Address) Validate() error { + if ip == "" { + return nil + } + if net.ParseIP(string(ip)) == nil { + return errors.New(IPv4Address_Error_Invalid) + } + if !isIPv4(string(ip)) { + return errors.New(IPv4Address_Error_Invalid) + } + return nil +} + +type IPv4CIDR string + +const IPv4CIDR_Error_Invalid = "ipv4CIDR is not a valid ipv4 address" + +func (cidr IPv4CIDR) Validate() error { + if cidr == "" { + return nil + } + ip, _, err := net.ParseCIDR(string(cidr)) + if err != nil { + return errors.New(IPv4CIDR_Error_Invalid) + } + if !isIPv4(ip.String()) { + return errors.New(IPv4CIDR_Error_Invalid) + } + return err +} + +type IPv6Address string + +const IPv6Address_Error_Invalid = "ipv6Address is not a valid ipv6 address" + +func (ip IPv6Address) Validate() error { + if ip == "" { + return nil + } + if net.ParseIP(string(ip)) == nil { + return errors.New(IPv6Address_Error_Invalid) + } + if !isIPv6(string(ip)) { + return errors.New(IPv6Address_Error_Invalid) + } + return nil +} + +type IPv6CIDR string + +const IPv6CIDR_Error_Invalid = "ipv6CIDR is not a valid ipv6 address" + +func (cidr IPv6CIDR) Validate() error { + if cidr == "" { + return nil + } + ip, _, err := net.ParseCIDR(string(cidr)) + if err != nil { + return errors.New(IPv6CIDR_Error_Invalid) + } + if !isIPv6(ip.String()) { + return errors.New(IPv6CIDR_Error_Invalid) + } + return nil +} diff --git a/proxmox/config_qemu_cloudinit_test.go b/proxmox/config_qemu_cloudinit_test.go index 189d725f..526d3231 100644 --- a/proxmox/config_qemu_cloudinit_test.go +++ b/proxmox/config_qemu_cloudinit_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/Telmate/proxmox-api-go/internal/util" "github.com/Telmate/proxmox-api-go/test/data/test_data_qemu" "github.com/stretchr/testify/require" ) @@ -56,6 +57,60 @@ func Test_CloudInit_Validate(t *testing.T) { FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}}}}, {name: `Valid CloudInit CloudInitCustom FilePath empty`, input: CloudInit{Custom: &CloudInitCustom{Network: &CloudInitSnippet{}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 Address`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1/24"))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 Address empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 DHCP Address empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + DHCP: true})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 DHCP Gateway empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID3: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("")), + DHCP: true})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 Gateway`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1"))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv4 Gateway empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 Address`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID9: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64"))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 Address empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 DHCP Address empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + DHCP: true})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 DHCP Gateway empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID12: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + DHCP: true})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 Gateway`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID13: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 Gateway empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID14: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 SLAAC Address empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID15: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + SLAAC: true})}}}}, + {name: `Valid CloudInit CloudInitNetworkInterfaces IPv6 SLAAC Gateway empty`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID16: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + SLAAC: true})}}}}, {name: `Invalid errors.New(CloudInitSnippetPath_Error_InvalidCharacters)`, input: CloudInit{Custom: &CloudInitCustom{User: &CloudInitSnippet{ FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Character_Illegal()[0])}}}, @@ -73,8 +128,66 @@ func Test_CloudInit_Validate(t *testing.T) { FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Relative())}}}, output: errors.New(CloudInitSnippetPath_Error_Relative)}, {name: `Invalid errors.New(QemuNetworkInterfaceID_Error_Invalid)`, - input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{32: ""}}, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{32: CloudInitNetworkConfig{}}}, output: errors.New(QemuNetworkInterfaceID_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Address Mutually exclusive with DHCP`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.45.1/24")), + DHCP: true})}}}, + output: errors.New(CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Gateway Mutually exclusive with DHCP`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID6: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("192.168.45.1")), + DHCP: true})}}}, + output: errors.New(CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Address errors.New(IPv4CIDR_Error_Invalid)`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID7: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1"))})}}}, + output: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Gateway errors.New(IPv4Address_Error_Invalid)`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID8: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1/24"))})}}}, + output: errors.New(IPv4Address_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address Mutually exclusive with DHCP`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID17: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + DHCP: true})}}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address Mutually exclusive with SLAAC`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID18: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + SLAAC: true})}}}, + output: errors.New(CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 DHCP Mutually exclusive with SLAAC`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID19: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + DHCP: true, + SLAAC: true})}}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway Mutually exclusive with DHCP`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID20: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + DHCP: true})}}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway Mutually exclusive with SLAAC`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID21: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + SLAAC: true})}}}, + output: errors.New(CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address errors.New(IPv6CIDR_Error_Invalid)`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID22: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}}}, + output: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway errors.New(IPv6Address_Error_Invalid)`, + input: CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID23: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::/64"))})}}}, + output: errors.New(IPv6Address_Error_Invalid)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -179,3 +292,348 @@ func Test_CloudInitSnippetPath_Validate(t *testing.T) { }) } } + +func Test_CloudInitNetworkInterfaces_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInitNetworkInterfaces + output error + }{ + {name: `Valid IPv4 Address`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1/24"))})}}}, + {name: `Valid IPv4 Address empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))})}}}, + {name: `Valid IPv4 DHCP Address empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + DHCP: true})}}}, + {name: `Valid IPv4 DHCP Gateway empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID3: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("")), + DHCP: true})}}}, + {name: `Valid IPv4 Gateway`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1"))})}}}, + {name: `Valid IPv4 Gateway empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))})}}}, + {name: `Valid IPv6 Address`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID9: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64"))})}}}, + {name: `Valid IPv6 Address empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))})}}}, + {name: `Valid IPv6 DHCP Address empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + DHCP: true})}}}, + {name: `Valid IPv6 DHCP Gateway empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID12: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + DHCP: true})}}}, + {name: `Valid IPv6 Gateway`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID13: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}}}, + {name: `Valid IPv6 Gateway empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID14: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))})}}}, + {name: `Valid IPv6 SLAAC Address empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID15: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + SLAAC: true})}}}, + {name: `Valid IPv6 SLAAC Gateway empty`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID16: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + SLAAC: true})}}}, + {name: `Invalid IPv4 Address Mutually exclusive with DHCP`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.45.1/24")), + DHCP: true})}}, + output: errors.New(CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid IPv4 Gateway Mutually exclusive with DHCP`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID6: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("192.168.45.1")), + DHCP: true})}}, + output: errors.New(CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid IPv4 Address errors.New(IPv4CIDR_Error_Invalid)`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID7: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1"))})}}, + output: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid IPv4 Gateway errors.New(IPv4Address_Error_Invalid)`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID8: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1/24"))})}}, + output: errors.New(IPv4Address_Error_Invalid)}, + {name: `Invalid IPv6 Address Mutually exclusive with DHCP`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID17: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + DHCP: true})}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid IPv6 Address Mutually exclusive with SLAAC`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID18: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + SLAAC: true})}}, + output: errors.New(CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive)}, + {name: `Invalid IPv6 DHCP Mutually exclusive with SLAAC`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID19: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + DHCP: true, + SLAAC: true})}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive)}, + {name: `Invalid IPv6 Gateway Mutually exclusive with DHCP`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID20: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + DHCP: true})}}, + output: errors.New(CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid IPv6 Gateway Mutually exclusive with SLAAC`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID21: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + SLAAC: true})}}, + output: errors.New(CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive)}, + {name: `Invalid IPv6 Address errors.New(IPv6CIDR_Error_Invalid)`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID22: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}}, + output: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid IPv6 Gateway errors.New(IPv6Address_Error_Invalid)`, + input: CloudInitNetworkInterfaces{QemuNetworkInterfaceID23: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::/64"))})}}, + output: errors.New(IPv6Address_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_CloudInitIPv4Config_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInitIPv4Config + output error + }{ + {name: `Valid Address`, + input: CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1/24"))}}, + {name: `Valid Address empty`, + input: CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))}}, + {name: `Valid DHCP Address empty`, + input: CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + DHCP: true}}, + {name: `Valid DHCP Gateway empty`, + input: CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("")), + DHCP: true}}, + {name: `Valid Gateway`, + input: CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1"))}}, + {name: `Valid Gateway empty`, + input: CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))}}, + {name: `Invalid Address Mutually exclusive with DHCP`, + input: CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.45.1/24")), + DHCP: true}, + output: errors.New(CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid Gateway Mutually exclusive with DHCP`, + input: CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("192.168.45.1")), + DHCP: true}, + output: errors.New(CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid Address errors.New(IPv4CIDR_Error_Invalid)`, + input: CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1"))}, + output: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid Gateway errors.New(IPv4Address_Error_Invalid)`, + input: CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1/24"))}, + output: errors.New(IPv4Address_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_CloudInitIPv6Config_Validate(t *testing.T) { + tests := []struct { + name string + input CloudInitIPv6Config + output error + }{ + {name: `Valid Address`, + input: CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64"))}}, + {name: `Valid Address empty`, + input: CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))}}, + {name: `Valid DHCP Address empty`, + input: CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + DHCP: true}}, + {name: `Valid DHCP Gateway empty`, + input: CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + DHCP: true}}, + {name: `Valid Gateway`, + input: CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))}}, + {name: `Valid Gateway empty`, + input: CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))}}, + {name: `Valid SLAAC Address empty`, + input: CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + SLAAC: true}}, + {name: `Valid SLAAC Gateway empty`, + input: CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + SLAAC: true}}, + {name: `Invalid Address Mutually exclusive with DHCP`, + input: CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + DHCP: true}, + output: errors.New(CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid Address Mutually exclusive with SLAAC`, + input: CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + SLAAC: true}, + output: errors.New(CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive)}, + {name: `Invalid DHCP Mutually exclusive with SLAAC`, + input: CloudInitIPv6Config{ + DHCP: true, + SLAAC: true}, + output: errors.New(CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive)}, + {name: `Invalid Gateway Mutually exclusive with DHCP`, + input: CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + DHCP: true}, + output: errors.New(CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid Gateway Mutually exclusive with SLAAC`, + input: CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + SLAAC: true}, + output: errors.New(CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive)}, + {name: `Invalid Address errors.New(IPv6CIDR_Error_Invalid)`, + input: CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))}, + output: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid Gateway errors.New(IPv6Address_Error_Invalid)`, + input: CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::/64"))}, + output: errors.New(IPv6Address_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_IPv4Address_Validate(t *testing.T) { + tests := []struct { + name string + input IPv4Address + output error + }{ + {name: `Valid`, + input: "192.168.45.1"}, + {name: "Valid empty"}, + {name: `Invalid is CIDR`, + input: "192.168.45.1/24", + output: errors.New(IPv4Address_Error_Invalid)}, + {name: `Invalid is IPv6`, + input: "3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc", + output: errors.New(IPv4Address_Error_Invalid)}, + {name: `Invalid is gibberish`, + input: "ABCDEFG123", + output: errors.New(IPv4Address_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_IPv4CIDR_Validate(t *testing.T) { + tests := []struct { + name string + input IPv4CIDR + output error + }{ + {name: `Valid`, + input: "192.168.45.0/24"}, + {name: `Valid empty`}, + {name: `Invalid only IP no CIDR`, + input: "192.168.45.0", + output: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid is IPv6`, + input: "2001:0db8:85a3::/64", + output: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid gibberish`, + input: "ABCDEFG123", + output: errors.New(IPv4CIDR_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_IPv6Address_Validate(t *testing.T) { + tests := []struct { + name string + input IPv6Address + output error + }{ + {name: `Valid`, + input: "3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"}, + {name: `Valid empty`}, + {name: `Invalid is CIDR`, + input: "2001:0db8:85a3::/64", + output: errors.New(IPv6Address_Error_Invalid)}, + {name: `Invalid is IPv4`, + input: "192.168.45.0", + output: errors.New(IPv6Address_Error_Invalid)}, + {name: `Invalid is gibberish`, + input: "ABCDEFG123", + output: errors.New(IPv6Address_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} + +func Test_IPv6CIDR_Validate(t *testing.T) { + tests := []struct { + name string + input IPv6CIDR + output error + }{ + {name: `Valid`, + input: "2001:0db8:85a3::/64"}, + {name: `Valid empty`}, + {name: `Invalid only IP no CIDR`, + input: "2001:0db8:85a3::", + output: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid is IPv4`, + input: "192.168.45.0/24", + output: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid gibberish`, + input: "ABCDEFG123", + output: errors.New(IPv6CIDR_Error_Invalid)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate()) + }) + } +} diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 101a66c6..f12026c4 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -29,6 +29,15 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { Storage: "local", FilePath: "snippets/ci-custom.yml"}} } + cloudInitNetworkConfig := func() CloudInitNetworkConfig { + return CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.56.30/24")), + Gateway: util.Pointer(IPv4Address("192.168.56.1"))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:abcd::/48")), + Gateway: util.Pointer(IPv6Address("2001:0db8:abcd::1"))}} + } parseIP := func(rawIP string) (ip netip.Addr) { ip, _ = netip.ParseAddr(rawIP) return @@ -106,9 +115,12 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { SearchDomain: util.Pointer("example.com"), NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}, NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID19: "", - QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}, + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID19: CloudInitNetworkConfig{}, + QemuNetworkInterfaceID31: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}}, PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input()), UpgradePackages: util.Pointer(true), UserPassword: util.Pointer("Enter123!"), @@ -118,7 +130,6 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { "searchdomain": "example.com", "nameserver": "1.1.1.1 8.8.8.8 9.9.9.9", "ipconfig0": "ip=dhcp,ip6=dhcp", - "ipconfig19": "", "ipconfig31": "ip=10.20.4.7/22", "sshkeys": test_data_qemu.PublicKey_Encoded_Output(), "ciupgrade": 1, @@ -164,12 +175,14 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { output: map[string]interface{}{}}, {name: `Create CloudInit NetworkInterfaces`, config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID1: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID20: "", - QemuNetworkInterfaceID30: "ip=10.20.4.7/22"}}}, + QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID20: CloudInitNetworkConfig{}, + QemuNetworkInterfaceID30: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}}}}, output: map[string]interface{}{ "ipconfig1": "ip=dhcp,ip6=dhcp", - "ipconfig20": "", "ipconfig30": "ip=10.20.4.7/22"}}, {name: `Create CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, @@ -1586,19 +1599,221 @@ func Test_ConfigQemu_mapToApiValues(t *testing.T) { config: &ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("")}}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.org")}}}, output: map[string]interface{}{"delete": "searchdomain"}}, - {name: `Update CloudInit NetworkInterfaces`, + {name: `Update CloudInit NetworkInterfaces Ipv4.Address update`, config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID1: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID20: "", - QemuNetworkInterfaceID30: "ip=10.20.4.7/22"}}}, + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.1.10/24"))}}}}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID15: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID25: "ip=dhcp,ip6=dhcp", - }}}, + QemuNetworkInterfaceID0: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig0": "ip=192.168.1.10/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv4.Address remove`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID1: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig1": "gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv4.DHCP set`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID2: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig2": "ip=dhcp,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv4.Gateway update`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID3: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.1.1"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID3: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig3": "ip=192.168.56.30/24,gw=192.168.1.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv4.Gateway overwrite Ipv4.DHCP`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.1.1"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}}}}}, + output: map[string]interface{}{"ipconfig4": "gw=192.168.1.1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv4.Gateway remove`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID5: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig5": "ip=192.168.56.30/24,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Address update`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID6: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/48"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID6: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig6": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:85a3::/48,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Address remove`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID7: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID7: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig7": "ip=192.168.56.30/24,gw=192.168.56.1,gw6=2001:0db8:abcd::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.DHCP set`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID8: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{DHCP: true}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID8: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig8": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=dhcp"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Gateway update`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID9: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID9: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig9": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:85a3::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Gateway overwrite Ipv6.DHCP`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{DHCP: true}}}}}, + output: map[string]interface{}{"ipconfig10": "gw6=2001:0db8:85a3::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Gateway overwrite Ipv6.SLAAC`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{SLAAC: true}}}}}, + output: map[string]interface{}{"ipconfig11": "gw6=2001:0db8:85a3::1"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.Gateway remove`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID12: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID12: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig12": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48"}}, + {name: `Update CloudInit NetworkInterfaces Ipv6.SLAAC set`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID13: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{SLAAC: true}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID13: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"ipconfig13": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=auto"}}, + {name: `Update CloudInit NetworkInterfaces delete existing interface`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID14: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + Gateway: util.Pointer(IPv4Address(""))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + Gateway: util.Pointer(IPv6Address(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID14: cloudInitNetworkConfig()}}}, + output: map[string]interface{}{"delete": "ipconfig14"}}, + {name: `Update CloudInit NetworkInterfaces delete non-existing interface`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID20: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + Gateway: util.Pointer(IPv4Address(""))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + Gateway: util.Pointer(IPv6Address(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{}}}, + output: map[string]interface{}{}}, + {name: `Update CloudInit NetworkInterfaces no updates`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID29: cloudInitNetworkConfig(), + QemuNetworkInterfaceID30: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}}}}, + output: map[string]interface{}{}}, + {name: `Update CloudInit NetworkInterfaces full`, + config: &ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.1.10/24"))}}, + QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))}}, + QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}}, + QemuNetworkInterfaceID3: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.1.1"))}}, + QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.1.1"))}}, + QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))}}, + QemuNetworkInterfaceID6: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/48"))}}, + QemuNetworkInterfaceID7: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))}}, + QemuNetworkInterfaceID8: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID9: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}, + QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}, + QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::1"))}}, + QemuNetworkInterfaceID12: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))}}, + QemuNetworkInterfaceID13: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{SLAAC: true}}, + QemuNetworkInterfaceID14: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + Gateway: util.Pointer(IPv4Address(""))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + Gateway: util.Pointer(IPv6Address(""))}}, + QemuNetworkInterfaceID20: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + Gateway: util.Pointer(IPv4Address(""))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + Gateway: util.Pointer(IPv6Address(""))}}}}}, + currentConfig: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ + QemuNetworkInterfaceID0: cloudInitNetworkConfig(), + QemuNetworkInterfaceID1: cloudInitNetworkConfig(), + QemuNetworkInterfaceID2: cloudInitNetworkConfig(), + QemuNetworkInterfaceID3: cloudInitNetworkConfig(), + QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}}, + QemuNetworkInterfaceID5: cloudInitNetworkConfig(), + QemuNetworkInterfaceID6: cloudInitNetworkConfig(), + QemuNetworkInterfaceID7: cloudInitNetworkConfig(), + QemuNetworkInterfaceID8: cloudInitNetworkConfig(), + QemuNetworkInterfaceID9: cloudInitNetworkConfig(), + QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{SLAAC: true}}, + QemuNetworkInterfaceID12: cloudInitNetworkConfig(), + QemuNetworkInterfaceID13: cloudInitNetworkConfig(), + QemuNetworkInterfaceID14: cloudInitNetworkConfig(), + QemuNetworkInterfaceID29: cloudInitNetworkConfig(), + QemuNetworkInterfaceID30: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}}}}, output: map[string]interface{}{ - "ipconfig1": "ip=dhcp,ip6=dhcp", - "ipconfig20": "", - "ipconfig30": "ip=10.20.4.7/22"}}, + "ipconfig0": "ip=192.168.1.10/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1", + "ipconfig1": "gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1", + "ipconfig2": "ip=dhcp,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1", + "ipconfig3": "ip=192.168.56.30/24,gw=192.168.1.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1", + "ipconfig4": "gw=192.168.1.1", + "ipconfig5": "ip=192.168.56.30/24,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1", + "ipconfig6": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:85a3::/48,gw6=2001:0db8:abcd::1", + "ipconfig7": "ip=192.168.56.30/24,gw=192.168.56.1,gw6=2001:0db8:abcd::1", + "ipconfig8": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=dhcp", + "ipconfig9": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:85a3::1", + "ipconfig10": "gw6=2001:0db8:85a3::1", + "ipconfig11": "gw6=2001:0db8:85a3::1", + "ipconfig12": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48", + "ipconfig13": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=auto", + "delete": "ipconfig14"}}, {name: `Update CloudInit PublicSSHkeys`, config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}}, currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}}, @@ -3694,8 +3909,11 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { SearchDomain: util.Pointer("example.com"), NameServers: &[]netip.Addr{parseIP("1.1.1.1"), parseIP("8.8.8.8"), parseIP("9.9.9.9")}}, NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}, + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID31: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}}, PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output()), UpgradePackages: util.Pointer(true), UserPassword: util.Pointer("Enter123!"), @@ -3740,14 +3958,29 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { NetworkInterfaces: CloudInitNetworkInterfaces{}}}}, {name: `CloudInit NetworkInterfaces`, input: map[string]interface{}{ + "ipconfig0": string("ip=dhcp,ip6=dhcp"), + "ipconfig1": string("ip6=auto"), + "ipconfig2": string("ip=192.168.1.10/24,gw=192.168.56.1,ip6=2001:0db8:abcd::/48,gw6=2001:0db8:abcd::1"), "ipconfig19": string(""), "ipconfig20": string(" "), // this single space is on porpuse to test if it is ignored "ipconfig31": string("ip=10.20.4.7/22")}, output: &ConfigQemu{CloudInit: &CloudInit{ NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID0: "ip=dhcp,ip6=dhcp", - QemuNetworkInterfaceID31: "ip=10.20.4.7/22"}}}}, + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{DHCP: true}, + IPv6: &CloudInitIPv6Config{DHCP: true}}, + QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv6: &CloudInitIPv6Config{SLAAC: true}}, + QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.1.10/24")), + Gateway: util.Pointer(IPv4Address("192.168.56.1"))}, + IPv6: &CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:abcd::/48")), + Gateway: util.Pointer(IPv6Address("2001:0db8:abcd::1"))}}, + QemuNetworkInterfaceID31: CloudInitNetworkConfig{ + IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}}}}}, {name: `CloudInit PublicSSHkeys`, input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()}, output: &ConfigQemu{CloudInit: &CloudInit{ @@ -6427,7 +6660,46 @@ func Test_ConfigQemu_Validate(t *testing.T) { User: &CloudInitSnippet{FilePath: CloudInitSnippetPath(test_data_qemu.CloudInitSnippetPath_Max_Legal())}, Vendor: &CloudInitSnippet{FilePath: ""}}, NetworkInterfaces: CloudInitNetworkInterfaces{ - QemuNetworkInterfaceID0: ""}}}}, + QemuNetworkInterfaceID0: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1/24"))})}, + QemuNetworkInterfaceID1: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR(""))})}, + QemuNetworkInterfaceID2: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("")), + DHCP: true})}, + QemuNetworkInterfaceID3: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("")), + DHCP: true})}, + QemuNetworkInterfaceID4: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1"))})}, + QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address(""))})}, + QemuNetworkInterfaceID9: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64"))})}, + QemuNetworkInterfaceID10: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR(""))})}, + QemuNetworkInterfaceID11: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + DHCP: true})}, + QemuNetworkInterfaceID12: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + DHCP: true})}, + QemuNetworkInterfaceID13: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}, + QemuNetworkInterfaceID14: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address(""))})}, + QemuNetworkInterfaceID15: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("")), + SLAAC: true})}, + QemuNetworkInterfaceID16: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("")), + SLAAC: true})}}}}}, // Valid Disks {name: "Valid Disks Empty 0", input: ConfigQemu{Disks: &QemuStorages{}}, @@ -6577,8 +6849,66 @@ func Test_ConfigQemu_Validate(t *testing.T) { err: errors.New(CloudInitSnippetPath_Error_Relative)}, {name: `Invalid CloudInit errors.New(QemuNetworkInterfaceID_Error_Invalid)`, input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{ - 32: ""}}}, + 32: CloudInitNetworkConfig{}}}}, err: errors.New(QemuNetworkInterfaceID_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Address Mutually exclusive with DHCP`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID5: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Address: util.Pointer(IPv4CIDR("192.168.45.1/24")), + DHCP: true})}}}}, + err: errors.New(CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Gateway Mutually exclusive with DHCP`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID6: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{ + Gateway: util.Pointer(IPv4Address("192.168.45.1")), + DHCP: true})}}}}, + err: errors.New(CloudInitIPv4Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Address errors.New(IPv4CIDR_Error_Invalid)`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID7: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("192.168.45.1"))})}}}}, + err: errors.New(IPv4CIDR_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv4 Gateway errors.New(IPv4Address_Error_Invalid)`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID8: CloudInitNetworkConfig{ + IPv4: util.Pointer(CloudInitIPv4Config{Gateway: util.Pointer(IPv4Address("192.168.45.1/24"))})}}}}, + err: errors.New(IPv4Address_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address Mutually exclusive with DHCP`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID17: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + DHCP: true})}}}}, + err: errors.New(CloudInitIPv6Config_Error_DhcpAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address Mutually exclusive with SLAAC`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID18: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Address: util.Pointer(IPv6CIDR("2001:0db8:85a3::/64")), + SLAAC: true})}}}}, + err: errors.New(CloudInitIPv6Config_Error_SlaacAddressMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 DHCP Mutually exclusive with SLAAC`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID19: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + DHCP: true, + SLAAC: true})}}}}, + err: errors.New(CloudInitIPv6Config_Error_DhcpSlaacMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway Mutually exclusive with DHCP`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID20: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + DHCP: true})}}}}, + err: errors.New(CloudInitIPv6Config_Error_DhcpGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway Mutually exclusive with SLAAC`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID21: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{ + Gateway: util.Pointer(IPv6Address("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc")), + SLAAC: true})}}}}, + err: errors.New(CloudInitIPv6Config_Error_SlaacGatewayMutuallyExclusive)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Address errors.New(IPv6CIDR_Error_Invalid)`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID22: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Address: util.Pointer(IPv6CIDR("3f6d:5b2a:1e4d:7c91:abcd:1234:5678:9abc"))})}}}}, + err: errors.New(IPv6CIDR_Error_Invalid)}, + {name: `Invalid CloudInit CloudInitNetworkInterfaces IPv6 Gateway errors.New(IPv6Address_Error_Invalid)`, + input: ConfigQemu{CloudInit: &CloudInit{NetworkInterfaces: CloudInitNetworkInterfaces{QemuNetworkInterfaceID23: CloudInitNetworkConfig{ + IPv6: util.Pointer(CloudInitIPv6Config{Gateway: util.Pointer(IPv6Address("2001:0db8:85a3::/64"))})}}}}, + err: errors.New(IPv6Address_Error_Invalid)}, // Invalid Disks Mutually exclusive Ide {name: "Invalid Disks MutuallyExclusive Ide 0", input: ConfigQemu{Disks: &QemuStorages{Ide: &QemuIdeDisks{Disk_0: &QemuIdeStorage{ diff --git a/proxmox/util.go b/proxmox/util.go index 93f1708d..b6b268d5 100644 --- a/proxmox/util.go +++ b/proxmox/util.go @@ -236,6 +236,14 @@ func floatToTrimmedString(f float64, maxDecimals uint8) (s string) { return } +func isIPv4(address string) bool { + return strings.Count(address, ":") == 0 +} + +func isIPv6(address string) bool { + return strings.Count(address, ":") > 2 +} + func splitStringOfSettings(settings string) map[string]interface{} { settingValuePairs := strings.Split(settings, ",") settingMap := map[string]interface{}{} diff --git a/test/api/CloudInit/cloudinit_test.go b/test/api/CloudInit/cloudinit_test.go index 2dc14e8c..57ec7d1e 100644 --- a/test/api/CloudInit/cloudinit_test.go +++ b/test/api/CloudInit/cloudinit_test.go @@ -3,6 +3,7 @@ package api_test import ( "testing" + "github.com/Telmate/proxmox-api-go/internal/util" pxapi "github.com/Telmate/proxmox-api-go/proxmox" api_test "github.com/Telmate/proxmox-api-go/test/api" "github.com/stretchr/testify/require" @@ -37,14 +38,20 @@ func Test_Cloud_Init_VM(t *testing.T) { config.CloudInit = &pxapi.CloudInit{ NetworkInterfaces: pxapi.CloudInitNetworkInterfaces{ - pxapi.QemuNetworkInterfaceID0: "gw=10.0.0.1,ip=10.0.0.2/24"}} - + pxapi.QemuNetworkInterfaceID0: pxapi.CloudInitNetworkConfig{ + IPv4: &pxapi.CloudInitIPv4Config{ + Address: util.Pointer(pxapi.IPv4CIDR("10.0.0.2/24")), + Gateway: util.Pointer(pxapi.IPv4Address("10.0.0.1"))}}}} _, err = config.Update(true, vmref, Test.GetClient()) require.NoError(t, err) testConfig, _ := pxapi.NewConfigQemuFromApi(vmref, Test.GetClient()) - require.Equal(t, testConfig.CloudInit.NetworkInterfaces[pxapi.QemuNetworkInterfaceID0], "gw=10.0.0.1,ip=10.0.0.2/24") + require.Equal(t, testConfig.CloudInit.NetworkInterfaces[pxapi.QemuNetworkInterfaceID0], + pxapi.CloudInitNetworkConfig{ + IPv4: &pxapi.CloudInitIPv4Config{ + Address: util.Pointer(pxapi.IPv4CIDR("10.0.0.2/24")), + Gateway: util.Pointer(pxapi.IPv4Address("10.0.0.1"))}}) _, err = Test.GetClient().DeleteVm(vmref) require.NoError(t, err) From 92c6219918d9a9e8efc3d148bf79a6d45e63066e Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sat, 15 Jun 2024 17:24:42 +0200 Subject: [PATCH 13/14] refactor: add omitempty --- proxmox/config_guest.go | 4 +-- proxmox/config_qemu_cloudinit.go | 45 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/proxmox/config_guest.go b/proxmox/config_guest.go index 81602c8c..3349e3da 100644 --- a/proxmox/config_guest.go +++ b/proxmox/config_guest.go @@ -9,8 +9,8 @@ import ( // All code LXC and Qemu have in common should be placed here. type GuestDNS struct { - NameServers *[]netip.Addr `json:"nameservers"` - SearchDomain *string `json:"searchdomain"` // we are not validating this field, as validating domain names is a complex topic. + NameServers *[]netip.Addr `json:"nameservers,omitempty"` + SearchDomain *string `json:"searchdomain,omitempty"` // we are not validating this field, as validating domain names is a complex topic. } type GuestResource struct { diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index 2698b1a5..99d3cf03 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -45,15 +45,14 @@ func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) { return } -// TODO add omitempty everywhere type CloudInit struct { - Custom *CloudInitCustom `json:"cicustom"` - DNS *GuestDNS `json:"dns"` - NetworkInterfaces CloudInitNetworkInterfaces `json:"ipconfig"` - PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys"` - UpgradePackages *bool `json:"ciupgrade"` - UserPassword *string `json:"userpassword"` // TODO custom type - Username *string `json:"username"` // TODO custom type + Custom *CloudInitCustom `json:"cicustom,omitempty"` + DNS *GuestDNS `json:"dns,omitempty"` + NetworkInterfaces CloudInitNetworkInterfaces `json:"ipconfig,omitempty"` + PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys,omitempty"` + UpgradePackages *bool `json:"ciupgrade,omitempty"` + UserPassword *string `json:"userpassword,omitempty"` // TODO custom type + Username *string `json:"username,omitempty"` // TODO custom type } func (config CloudInit) mapToAPI(current *CloudInit, params map[string]interface{}) (delete string) { @@ -201,10 +200,10 @@ func (ci CloudInit) Validate() error { } type CloudInitCustom struct { - Meta *CloudInitSnippet `json:"meta"` - Network *CloudInitSnippet `json:"network"` - User *CloudInitSnippet `json:"user"` - Vendor *CloudInitSnippet `json:"vendor"` + Meta *CloudInitSnippet `json:"meta,omitempty"` + Network *CloudInitSnippet `json:"network,omitempty"` + User *CloudInitSnippet `json:"user,omitempty"` + Vendor *CloudInitSnippet `json:"vendor,omitempty"` } func (config CloudInitCustom) mapToAPI(current *CloudInitCustom) string { @@ -303,9 +302,9 @@ func (ci CloudInitCustom) String() string { } type CloudInitIPv4Config struct { - Address *IPv4CIDR `json:"address"` - Gateway *IPv4Address `json:"gateway"` - DHCP bool `json:"dhcp"` + Address *IPv4CIDR `json:"address,omitempty"` + Gateway *IPv4Address `json:"gateway,omitempty"` + DHCP bool `json:"dhcp,omitempty"` } const CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive string = "ipv4 dhcp is mutually exclusive with address" @@ -374,10 +373,10 @@ func (config CloudInitIPv4Config) Validate() error { } type CloudInitIPv6Config struct { - Address *IPv6CIDR `json:"address"` - Gateway *IPv6Address `json:"gateway"` - DHCP bool `json:"dhcp"` - SLAAC bool `json:"slaac"` + Address *IPv6CIDR `json:"address,omitempty"` + Gateway *IPv6Address `json:"gateway,omitempty"` + DHCP bool `json:"dhcp,omitempty"` + SLAAC bool `json:"slaac,omitempty"` } func (config CloudInitIPv6Config) mapToAPI(current *CloudInitIPv6Config) string { @@ -460,8 +459,8 @@ func (config CloudInitIPv6Config) Validate() error { } type CloudInitNetworkConfig struct { - IPv4 *CloudInitIPv4Config `json:"ip4"` - IPv6 *CloudInitIPv6Config `json:"ip6"` + IPv4 *CloudInitIPv4Config `json:"ip4,omitempty"` + IPv6 *CloudInitIPv6Config `json:"ip6,omitempty"` } func (config CloudInitNetworkConfig) mapToAPI(current *CloudInitNetworkConfig) (param string) { @@ -596,8 +595,8 @@ func (interfaces CloudInitNetworkInterfaces) Validate() (err error) { // If either Storage or FilePath is empty, the snippet will be removed type CloudInitSnippet struct { - Storage string `json:"storage"` // TODO custom type (storage) - FilePath CloudInitSnippetPath `json:"path"` + Storage string `json:"storage,omitempty"` // TODO custom type (storage) + FilePath CloudInitSnippetPath `json:"path,omitempty"` } func (ci CloudInitSnippet) mapToAPI(kind string) string { From 24460b82e9d7d366ab4019ee4f8bd2eef02b3e53 Mon Sep 17 00:00:00 2001 From: Tinyblargon <76069640+Tinyblargon@users.noreply.github.com> Date: Sun, 16 Jun 2024 10:09:22 +0200 Subject: [PATCH 14/14] refactor: alphabetical order --- proxmox/config_qemu_cloudinit.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proxmox/config_qemu_cloudinit.go b/proxmox/config_qemu_cloudinit.go index 99d3cf03..db2f3c1e 100644 --- a/proxmox/config_qemu_cloudinit.go +++ b/proxmox/config_qemu_cloudinit.go @@ -11,9 +11,9 @@ import ( "strings" ) +var regexMultipleNewlineEncoded = regexp.MustCompile(`(%0A)+`) var regexMultipleSpaces = regexp.MustCompile(`\s+`) var regexMultipleSpacesEncoded = regexp.MustCompile(`(%20)+`) -var regexMultipleNewlineEncoded = regexp.MustCompile(`(%0A)+`) // URL encodes the ssh keys func sshKeyUrlDecode(encodedKeys string) (keys []crypto.PublicKey) { @@ -303,8 +303,8 @@ func (ci CloudInitCustom) String() string { type CloudInitIPv4Config struct { Address *IPv4CIDR `json:"address,omitempty"` - Gateway *IPv4Address `json:"gateway,omitempty"` DHCP bool `json:"dhcp,omitempty"` + Gateway *IPv4Address `json:"gateway,omitempty"` } const CloudInitIPv4Config_Error_DhcpAddressMutuallyExclusive string = "ipv4 dhcp is mutually exclusive with address" @@ -374,8 +374,8 @@ func (config CloudInitIPv4Config) Validate() error { type CloudInitIPv6Config struct { Address *IPv6CIDR `json:"address,omitempty"` - Gateway *IPv6Address `json:"gateway,omitempty"` DHCP bool `json:"dhcp,omitempty"` + Gateway *IPv6Address `json:"gateway,omitempty"` SLAAC bool `json:"slaac,omitempty"` } @@ -595,8 +595,8 @@ func (interfaces CloudInitNetworkInterfaces) Validate() (err error) { // If either Storage or FilePath is empty, the snippet will be removed type CloudInitSnippet struct { - Storage string `json:"storage,omitempty"` // TODO custom type (storage) FilePath CloudInitSnippetPath `json:"path,omitempty"` + Storage string `json:"storage,omitempty"` // TODO custom type (storage) } func (ci CloudInitSnippet) mapToAPI(kind string) string {