diff --git a/lxd/cloudinit/config.go b/lxd/cloudinit/config.go new file mode 100644 index 000000000000..f8dc7d4d70b1 --- /dev/null +++ b/lxd/cloudinit/config.go @@ -0,0 +1,324 @@ +package cloudinit + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v2" + + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/logger" +) + +// sshKeyExtendedConfigTag defines comment to be added on the side of added keys. +var sshKeyExtendedConfigTag = "#lxd:cloud-init.ssh-keys" + +// GetEffectiveConfigKey gets the correct config key for some type of cloud-init configuration. +// Supported configTypes are "user-data", "vendor-data" or "network-config". +func GetEffectiveConfigKey(instanceConfig map[string]string, configType string) string { + // cloud-init.* keys take precedence over user.* ones + key := "cloud-init." + configType + value := instanceConfig["cloud-init."+configType] + // If cloud-init.* is not defined but user.* is, fallback on the latter. + if value == "" { + fallbackKey := "user." + configType + value = instanceConfig[fallbackKey] + if value == "" { + key = fallbackKey + } + } + + return key +} + +// GetResultingCloudConfig returns the resulting vendor-data and/or user-data for a certain instance. +// This method takes in the keys that point to user-data and vendor-data. If an empty key is provided for one of +// [vendor|user]-data, it is understood that the caller doesn't care about the resulting value for that data type. +func GetResultingCloudConfig(instanceConfig map[string]string, vendorDataKey string, userDataKey string, instanceName string, instanceProject string) (vendorData string, userData string) { + // If the caller is not interest in neither config, return early. + if vendorDataKey == "" && userDataKey == "" { + return "", "" + } + + var vendorErr error + var userErr error + + vendorDataKeyProvided := vendorDataKey != "" + userDataKeyProvided := userDataKey != "" + + // Defer logging a warning for each profided key in case of a parsing error. + defer func() { + if vendorDataKeyProvided && vendorErr != nil { + logger.Warn("Failed merging SSH keys into cloud-init seed data, abstain from injecting additional keys", logger.Ctx{"err": vendorErr, "project": instanceProject, "instance": instanceName, "dataConfigKey": vendorDataKey}) + } + + if userDataKeyProvided && userErr != nil { + logger.Warn("Failed merging SSH keys into cloud-init seed data, abstain from injecting additional keys", logger.Ctx{"err": userErr, "project": instanceProject, "instance": instanceName, "dataConfigKey": vendorDataKey}) + } + }() + + // If a key was not provided for [vendor|user]-data, derive the effective one to use in further checks. + if !vendorDataKeyProvided { + vendorDataKey = GetEffectiveConfigKey(instanceConfig, "vendor-data") + } + + if !userDataKeyProvided { + userDataKey = GetEffectiveConfigKey(instanceConfig, "user-data") + } + + // Extract additional SSH keys to merge into cloud-config. + userKeys := extractAdditionalSSHKeys(instanceConfig) + + // Parse data from instance config. + vendorCloudConfig, vendorErr := parseCloudConfig(instanceConfig[vendorDataKey]) + userCloudConfig, userErr := parseCloudConfig(instanceConfig[userDataKey]) + + // user-data's fields overwrite vendor-data's fields, so merging SSH keys can result in adding a "users" field + // that did not exist before, having the side effect of overwriting vendor-data's "users" field. + // So only merge into "user-data" when safe to do. + canMergeUserData := userCloudConfig.hasUsers() || !vendorCloudConfig.hasUsers() + + // Merge additional SSH keys into parsed config. + // If merging is not possible return the raw value for the provided key. + if vendorDataKeyProvided && vendorErr == nil { + vendorData, vendorErr = vendorCloudConfig.mergeSSHKeyCloudConfig(userKeys) + } + + if vendorDataKeyProvided && vendorData == "" { + vendorData = instanceConfig[vendorDataKey] + } + + if userDataKeyProvided && canMergeUserData && userErr == nil { + userData, userErr = userCloudConfig.mergeSSHKeyCloudConfig(userKeys) + } + + if userDataKeyProvided && userData == "" { + userData = instanceConfig[userDataKey] + } + + return vendorData, userData +} + +// parseCloudConfig attempts to unmarshal a string into a cloudConfig object. Returns an error if the +// provided string is not a valid YAML or lacks the needed "#cloud-config" comment. +func parseCloudConfig(rawCloudConfig string) (cloudConfig, error) { + // Check if rawCloudConfig is in a supported format. + // A YAML cloud config without #cloud-config is invalid. + // The "#cloud-config" tag can be either on the first or second lines. + if rawCloudConfig != "" && !shared.ValueInSlice("#cloud-config", shared.SplitNTrimSpace(rawCloudConfig, "\n", 3, false)) { + return nil, errors.New(`Parsing configuration is not supported as it is not "#cloud-config"`) + } + + // Parse YAML cloud-config into map. + cloudConfigMap := make(map[any]any) + err := yaml.Unmarshal([]byte(rawCloudConfig), cloudConfigMap) + if err != nil { + return nil, fmt.Errorf("Could not unmarshall cloud-config: %w", err) + } + + return cloudConfigMap, nil +} + +// userSSHKeys is a struct that keeps the SSH keys to be injected using cloud-init for a certain user. +type userSSHKeys struct { + importIDs []string + publicKeys []string +} + +// extractAdditionalSSHKeys extracts additional SSH keys from the instance config. +// Returns a map of userSSHKeys keyed on the name of the user that the keys should be injected for. +func extractAdditionalSSHKeys(instanceConfig map[string]string) map[string]*userSSHKeys { + // Use a pointer to userSSHKeys so we can append to its fields. + users := make(map[string]*userSSHKeys) + + // Populate map of userSSHKeys. + for key, value := range instanceConfig { + if strings.HasPrefix(key, "cloud-init.ssh-keys.") { + user, sshKey, found := strings.Cut(value, ":") + + // If the "cloud-init.ssh-keys." is badly formatted, skip it. + if !found { + continue + } + + // Create an empty userSSHKeys if the user is not configured. + _, ok := users[user] + if !ok { + users[user] = &userSSHKeys{} + } + + // Check if ssh key is an import ID with with the "keyServer:UserName". + // This is done by checking if the value does not contain a space which is always present in + // valid public key representations and never present on import IDs. + if !strings.Contains(sshKey, " ") { + users[user].importIDs = append(users[user].importIDs, sshKey) + continue + } + + users[user].publicKeys = append(users[user].publicKeys, sshKey) + } + } + + return users +} + +// cloudConfig represents a cloud-config parsed into a map. +type cloudConfig map[any]any + +// string marshals a cloud-config map into a YAML string. +func (config cloudConfig) string() (string, error) { + resultingConfigBytes, err := yaml.Marshal(config) + if err != nil { + return "", err + } + + // Add cloud-config tag and space before comments, as doing the latter + // while parsing would result in the comment to be included in the value on the same line. + resultingConfig := "#cloud-config\n" + strings.ReplaceAll(string(resultingConfigBytes), sshKeyExtendedConfigTag, " "+sshKeyExtendedConfigTag) + return resultingConfig, nil +} + +// string marshals a cloud-config map into a YAML string. +func (config cloudConfig) hasUsers() bool { + value, ok := config["users"] + return ok && value != "" +} + +// mergeSSHKeyCloudConfig merges keys present in a map of userSSHKeys into a CloudConfig. +// The provided map can be obtained by extracting user keys from an instance config with extractAdditionalSSHKeys. +// This also returns the resulting YAML string after the merging is done. +func (config cloudConfig) mergeSSHKeyCloudConfig(userKeys map[string]*userSSHKeys) (string, error) { + // If no keys are defined, return the original config passed in. + if len(userKeys) == 0 { + return config.string() + } + + // Get previously defined users list in provided config, if present. + userList, err := findOrCreateListInMap(config, "users") + if err != nil { + return "", err + } + + // Define comment to be added on the side of added keys. + sshKeyExtendedConfigTag := "#lxd:cloud-init.ssh-keys" + + // Merge the specified additional keys into the provided cloud config. + for user, keys := range userKeys { + var targetUser map[any]any + + for index, field := range userList { + mapField, ok := field.(map[any]any) + + // The user has to be either a mapping yaml node or a simple string indicating the name of a user to be created. + if !ok { + // If the field is not the user name we want, skip this one. + userName, isString := field.(string) + if isString && userName == user { + // Else, create a user map for us to add the keys into. Use the previously defined name as the name in the user map. + targetUser = make(map[any]any) + targetUser["name"] = userName + userList[index] = targetUser + break + } else if !isString { + return "", errors.New("Invalid user item on users list") + } + } else if mapField["name"] == user { + // If it is a map, check the name. + targetUser = mapField + break + } + } + + // If the target user was not present in the cloud config, create an entry for it. + if targetUser == nil { + targetUser = make(map[any]any) + targetUser["name"] = user + userList = append(userList, targetUser) + } + + // Using both the older and newer keys, since we do not know what version of cloud-init will be consuming this. + sshAuthorizedKeys := []string{"ssh_authorized_keys", "ssh-authorized-keys"} + importIDKeys := []string{"ssh_import_id", "ssh-import-id"} + + // Add public keys to cloud-config. + err = addValueToListsInMap(targetUser, keys.publicKeys, sshAuthorizedKeys, sshKeyExtendedConfigTag) + if err != nil { + return "", err + } + + // Add import IDs to cloud-config. + err = addValueToListsInMap(targetUser, keys.importIDs, importIDKeys, sshKeyExtendedConfigTag) + if err != nil { + return "", err + } + } + + // Only modify the original config map if everything went well. + config["users"] = userList + return config.string() +} + +// addValueToListsInMap finds or creates a list referenced on the provided user map for each key on fieldKeys +// and adds all provided values along with addedValueTag on the side to mark added values. +// addedKeyTag is simply appended to the values added, any parsing to separate the tag from the +// value should be done outside this function. +func addValueToListsInMap(user map[any]any, addedValues []string, fieldKeys []string, addedValueTag string) error { + // If there are no keys to add, this function should be a no-op. + if len(addedValues) == 0 { + return nil + } + + for _, fieldKey := range fieldKeys { + // Get the field with the provided key, if it exists. + // If it does not exist, create it as an empty list. + // If it exists and is not a list, switch it for a list containing the previously defined value. + targetList, err := findOrCreateListInMap(user, fieldKey) + if err != nil { + return err + } + + // Add the keys to the lists that will not be filled with an alias afterwards. + // Do not add if the key is already present on the slice and mark added keys. + for _, key := range addedValues { + if !shared.ValueInSlice(any(key), targetList) { + targetList = append(targetList, key+addedValueTag) + } + } + + // Update the map with the slice with appended keys. + user[fieldKey] = targetList + } + + return nil +} + +// findOrCreateListInMap finds a list under the provided key on a map that represents a YAML map field. +// If there is no value for the provided key, this returns a slice than can be used for the key, but +// this function does not change the provided map. +// If the value under the key is a string, the returned slice will contain it. +// If the value for the key is of any other type, return an error. +func findOrCreateListInMap(yamlMap map[any]any, key string) ([]any, error) { + // Get previously defined list in provided map, if present. + field, hasField := yamlMap[key] + listField, isSlice := field.([]any) + _, isString := field.(string) + + // If the field under the key is set to something other than a list or a string, both of which + // would be valid, return an error. + if hasField && !isSlice && !isString { + return nil, fmt.Errorf("Invalid value under %q", key) + } + + // If provided map did not include a field under the key or included one that was simply a string and + // not a list, create a slice. + if !hasField || isString { + listField = make([]any, 0) + // Preserve the previous string field as an item on the new list so it is still applied. + if isString { + listField = append(listField, field) + } + } + + return listField, nil +} diff --git a/lxd/cloudinit/config_test.go b/lxd/cloudinit/config_test.go new file mode 100644 index 000000000000..5c5c9049c956 --- /dev/null +++ b/lxd/cloudinit/config_test.go @@ -0,0 +1,99 @@ +package cloudinit + +import ( + "testing" +) + +func TestMergeSSHKeyCloudConfig(t *testing.T) { + instanceConfig := map[string]string{"cloud-init.ssh-keys.mykey": "root:gh:user1"} + + // Parsing an invalid config should leave it unchanged. + instanceConfig["cloud-init.vendor-data"] = `users: + - name: root + ssh-import-id: gh:user2 +` + + vendorData, userData := GetResultingCloudConfig(instanceConfig, "cloud-init.vendor-data", "cloud-init.user-data", "instance", "project") + + expectedOutput := `#cloud-config +users: +- name: root + ssh-import-id: + - gh:user1 #lxd:cloud-init.ssh-keys + ssh_import_id: + - gh:user1 #lxd:cloud-init.ssh-keys +` + + // Parsing invalid cloud-config should leave it unchanged. + if vendorData != instanceConfig["cloud-init.vendor-data"] { + t.Fatalf("Output %q is different from expected %q", vendorData, instanceConfig["cloud-init.vendor-data"]) + } + + if userData != expectedOutput { + t.Fatalf("Output %q is different from expected %q", userData, expectedOutput) + } + + instanceConfig["cloud-init.vendor-data"] = `#cloud-config +users: + - name: root + ssh-import-id: gh:user2 + ssh-authorized-keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 + shell: /bin/bash +` + + vendorData, userData = GetResultingCloudConfig(instanceConfig, "cloud-init.vendor-data", "cloud-init.user-data", "instance", "project") + + expectedOutput = `#cloud-config +users: +- name: root + shell: /bin/bash + ssh-authorized-keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 + ssh-import-id: + - gh:user2 + - gh:user1 #lxd:cloud-init.ssh-keys + ssh_import_id: + - gh:user1 #lxd:cloud-init.ssh-keys +` + + if vendorData != expectedOutput { + t.Fatalf("Output %q is different from expected %q", vendorData, expectedOutput) + } + + // Should not merge to user-data since vendor-data has "users" defined. + if userData != "" { + t.Fatalf(`Output %q is different from expected ""`, userData) + } + + // Add a pure public key to instance config. + instanceConfig["cloud-init.ssh-keys.otherkey"] = "user:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0" + instanceConfig["cloud-init.user-data"] = `#cloud-config +users: foo +` + + _, userData = GetResultingCloudConfig(instanceConfig, "", "cloud-init.user-data", "instance", "project") + + expectedOutput = `#cloud-config +users: +- foo +` + + rootUserConfig := `- name: root + ssh-import-id: + - gh:user1 #lxd:cloud-init.ssh-keys + ssh_import_id: + - gh:user1 #lxd:cloud-init.ssh-keys +` + + customUserConfig := `- name: user + ssh-authorized-keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 #lxd:cloud-init.ssh-keys + ssh_authorized_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 #lxd:cloud-init.ssh-keys +` + + // The order of maps inside a list is not predicatable during YAML marshalling, so the order + // of users can change and generate two different but equivalent results. + if expectedOutput+rootUserConfig+customUserConfig != userData && expectedOutput+customUserConfig+rootUserConfig != userData { + t.Fatalf("Output %q conflicts with expected %q", userData, expectedOutput+rootUserConfig+customUserConfig) + } +} diff --git a/lxd/device/disk.go b/lxd/device/disk.go index 0ba268363926..9f3e4ae7049b 100644 --- a/lxd/device/disk.go +++ b/lxd/device/disk.go @@ -16,6 +16,7 @@ import ( "golang.org/x/sys/unix" "github.com/canonical/lxd/lxd/cgroup" + "github.com/canonical/lxd/lxd/cloudinit" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/db/warningtype" @@ -27,7 +28,6 @@ import ( storagePools "github.com/canonical/lxd/lxd/storage" storageDrivers "github.com/canonical/lxd/lxd/storage/drivers" "github.com/canonical/lxd/lxd/storage/filesystem" - "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/lxd/warnings" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" @@ -2540,21 +2540,18 @@ func (d *disk) generateVMConfigDrive() (string, error) { instanceConfig := d.inst.ExpandedConfig() - // Use an empty vendor-data file if no custom vendor-data supplied. - vendorDataKey := "cloud-init.vendor-data" - vendorData, ok := instanceConfig[vendorDataKey] - if !ok { - vendorDataKey := "user.vendor-data" - vendorData = instanceConfig[vendorDataKey] - if vendorData == "" { - vendorData = "#cloud-config\n{}" - } + // Get raw data from instance config. + vendorDataKey := cloudinit.GetEffectiveConfigKey(instanceConfig, "vendor-data") + userDataKey := cloudinit.GetEffectiveConfigKey(instanceConfig, "user-data") + vendorData, userData := cloudinit.GetResultingCloudConfig(instanceConfig, vendorDataKey, userDataKey, d.inst.Name(), d.inst.Project().Name) + + // Use an empty cloud-config file if no custom *-data is supplied. + if vendorData == "" { + vendorData = "#cloud-config\n{}" } - // Merge additional SSH keys present on the instance config into vendorData. - vendorData, err = util.MergeSSHKeyCloudConfig(instanceConfig, vendorData) - if err != nil { - logger.Warn("Failed merging SSH keys into cloud-init seed data, abstain from injecting additional keys", logger.Ctx{"err": err, "project": d.inst.Project().Name, "instance": d.inst.Name(), "dataConfigKey": vendorDataKey}) + if userData == "" { + userData = "#cloud-config\n{}" } err = os.WriteFile(filepath.Join(scratchDir, "vendor-data"), []byte(vendorData), 0400) @@ -2562,33 +2559,13 @@ func (d *disk) generateVMConfigDrive() (string, error) { return "", err } - // Use an empty user-data file if no custom user-data supplied. - userDataKey := "cloud-init.user-data" - userData, ok := instanceConfig[userDataKey] - if !ok { - userDataKey = "user.user-data" - userData = instanceConfig[userDataKey] - if userData == "" { - userData = "#cloud-config\n{}" - } - } - - // Merge additional SSH keys present on the instance config into userData. - userData, err = util.MergeSSHKeyCloudConfig(instanceConfig, userData) - if err != nil { - logger.Warn("Failed merging SSH keys into cloud-init seed data, abstain from injecting additional keys", logger.Ctx{"err": err, "project": d.inst.Project().Name, "instance": d.inst.Name(), "dataConfigKey": userDataKey}) - } - err = os.WriteFile(filepath.Join(scratchDir, "user-data"), []byte(userData), 0400) if err != nil { return "", err } // Include a network-config file if the user configured it. - networkConfig, ok := instanceConfig["cloud-init.network-config"] - if !ok { - networkConfig = instanceConfig["user.network-config"] - } + networkConfig := instanceConfig[cloudinit.GetEffectiveConfigKey(instanceConfig, "network-config")] if networkConfig != "" { err = os.WriteFile(filepath.Join(scratchDir, "network-config"), []byte(networkConfig), 0400) diff --git a/lxd/devlxd.go b/lxd/devlxd.go index 86632e7003ae..f09ee34a2187 100644 --- a/lxd/devlxd.go +++ b/lxd/devlxd.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sys/unix" "github.com/canonical/lxd/lxd/auth" + "github.com/canonical/lxd/lxd/cloudinit" "github.com/canonical/lxd/lxd/events" "github.com/canonical/lxd/lxd/instance" "github.com/canonical/lxd/lxd/instance/instancetype" @@ -25,7 +26,6 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/lxd/ucred" - "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/logger" @@ -118,17 +118,19 @@ func devlxdConfigKeyGetHandler(d *Daemon, c instance.Instance, w http.ResponseWr return response.DevLxdErrorResponse(api.StatusErrorf(http.StatusForbidden, "not authorized"), c.Type() == instancetype.VM) } - value := c.ExpandedConfig()[key] + var value string - // If parsing the config is not possible, abstain from merging the additional keys into the config. - if strings.HasSuffix(key, ".vendor-data") || strings.HasSuffix(key, ".user-data") { - value, err = util.MergeSSHKeyCloudConfig(c.ExpandedConfig(), value) - if err != nil { - logger.Warn("Failed merging SSH keys into cloud-init seed data, abstain from injecting additional keys", logger.Ctx{"err": err, "project": c.Project().Name, "instance": c.Name(), "requestedKey": key}) - } + // For values containing cloud-init seed data, try to merge into them additional SSH keys present on the instance config. + // If parsing the config is not possible, abstain from merging the additional keys. + if strings.HasSuffix(key, ".user-data") { + _, value = cloudinit.GetResultingCloudConfig(c.ExpandedConfig(), "", key, c.Name(), c.Project().Name) + } else if strings.HasSuffix(key, ".vendor-data") { + value, _ = cloudinit.GetResultingCloudConfig(c.ExpandedConfig(), key, "", c.Name(), c.Project().Name) + } else { + value = c.ExpandedConfig()[key] } - // If the requested key is not defined and there isn't any addition SSH keys for this instance, return a 'not found' response. + // If the resulting value is empty, return Not Found. if value == "" { return response.DevLxdErrorResponse(api.StatusErrorf(http.StatusNotFound, "not found"), c.Type() == instancetype.VM) } diff --git a/lxd/devlxd_test.go b/lxd/devlxd_test.go index d0933134f347..6c21bae7c03f 100644 --- a/lxd/devlxd_test.go +++ b/lxd/devlxd_test.go @@ -15,7 +15,6 @@ import ( "github.com/canonical/lxd/lxd/sys" "github.com/canonical/lxd/lxd/ucred" - "github.com/canonical/lxd/lxd/util" ) var testDir string @@ -174,106 +173,3 @@ func TestHttpRequest(t *testing.T) { t.Fatal("resp error not expected: ", string(resp)) } } - -func TestMergeSSHKeyCloudConfig(t *testing.T) { - instanceConfig := map[string]string{"cloud-init.ssh-keys.mykey": "root:gh:user1"} - - // First try with an empty cloud-config. - out, err := util.MergeSSHKeyCloudConfig(instanceConfig, "") - if err != nil { - t.Fatal(err) - } - - expectedOutput := `#cloud-config -users: -- name: root - ssh-import-id: - - gh:user1 #lxd:cloud-init.ssh-keys - ssh_import_id: - - gh:user1 #lxd:cloud-init.ssh-keys -` - - if expectedOutput != out { - t.Fatalf("Output %q is different from expected %q", out, expectedOutput) - } - - invalidCloudConfig := `#cloud-config -users: - - name: root -ssh-import-id: gh:user2 -` - - // Check merging into invalid config returns an error. - _, err = util.MergeSSHKeyCloudConfig(instanceConfig, invalidCloudConfig) - if err == nil { - t.Fatal("Parsing invalid config did not return an error") - } - - cloudConfig := `#cloud-config -users: - - name: root - ssh-import-id: gh:user2 - ssh-authorized-keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 - shell: /bin/bash -` - - // Merge the instance config into a cloud-config that already contain some keys - out, err = util.MergeSSHKeyCloudConfig(instanceConfig, cloudConfig) - if err != nil { - t.Fatal(err) - } - - expectedOutput = `#cloud-config -users: -- name: root - shell: /bin/bash - ssh-authorized-keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 - ssh-import-id: - - gh:user2 - - gh:user1 #lxd:cloud-init.ssh-keys - ssh_import_id: - - gh:user1 #lxd:cloud-init.ssh-keys -` - - if expectedOutput != out { - t.Fatalf("Output %q is different from expected %q", out, expectedOutput) - } - - // Add a pure public key to instance config. - instanceConfig["cloud-init.ssh-keys.otherkey"] = "user:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0" - - scalarUserCloudConfig := `#cloud-config -users: foo -` - - // Merge the extended instance config with a cloud-config with a simple users string. - out, err = util.MergeSSHKeyCloudConfig(instanceConfig, scalarUserCloudConfig) - if err != nil { - t.Fatal(err) - } - - expectedOutput = `#cloud-config -users: -- foo -` - - rootUserConfig := `- name: root - ssh-import-id: - - gh:user1 #lxd:cloud-init.ssh-keys - ssh_import_id: - - gh:user1 #lxd:cloud-init.ssh-keys -` - - customUserConfig := `- name: user - ssh-authorized-keys: - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 #lxd:cloud-init.ssh-keys - ssh_authorized_keys: - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfOyl6A6lSE+e57RLf4GwDzlg6PALjtiweokxQeCPL0 #lxd:cloud-init.ssh-keys -` - - // The order of maps inside a list is not predicatable during YAML marshalling, so the order - // of users can change and generate two different but equivalent results. - if expectedOutput+rootUserConfig+customUserConfig != out && expectedOutput+customUserConfig+rootUserConfig != out { - t.Fatalf("Output %q is different from expected %q", out, expectedOutput) - } -} diff --git a/lxd/util/config.go b/lxd/util/config.go index 6964e1177abd..5d7562c5a5bf 100644 --- a/lxd/util/config.go +++ b/lxd/util/config.go @@ -1,13 +1,10 @@ package util import ( - "errors" "fmt" "sort" "strings" - "gopkg.in/yaml.v2" - "github.com/canonical/lxd/shared" ) @@ -63,193 +60,3 @@ func CopyConfig(config map[string]string) map[string]string { return newConfig } - -// cloudInitUserSSHKeys is a struct that keeps the SSH keys to be injected using cloud-init for a certain user. -type cloudInitUserSSHKeys struct { - importIDs []string - publicKeys []string -} - -// MergeSSHKeyCloudConfig merges any existing SSH keys defined in an instance config into a provided -// cloud-config YAML string. -// In the case where we were not able to parse the cloud config, return the original, unchanged config and -// the error. -func MergeSSHKeyCloudConfig(instanceConfig map[string]string, cloudConfig string) (string, error) { - // Use a pointer to cloudInitUserSSHKeys so we can append to its fields. - users := make(map[string]*cloudInitUserSSHKeys) - - for key, value := range instanceConfig { - if strings.HasPrefix(key, "cloud-init.ssh-keys.") { - user, sshKey, found := strings.Cut(value, ":") - - // If the "cloud-init.ssh-keys." is badly formatted, skip it. - if !found { - continue - } - - // Create an empty cloudInitUserSSHKeys if the user is not configured. - _, ok := users[user] - if !ok { - users[user] = &cloudInitUserSSHKeys{} - } - - // Check if ssh key is an import ID with with the "keyServer:UserName". - // This is done by checking if the value does not contain a space which is always present in - // valid public key representations and never present on import IDs. - if !strings.Contains(sshKey, " ") { - users[user].importIDs = append(users[user].importIDs, sshKey) - continue - } - - users[user].publicKeys = append(users[user].publicKeys, sshKey) - } - } - - // If no keys are defined, return the original config passed in. - if len(users) == 0 { - return cloudConfig, nil - } - - // Parse YAML cloud-config into map. - cloudConfigMap := make(map[any]any) - err := yaml.Unmarshal([]byte(cloudConfig), cloudConfigMap) - if err != nil { - return cloudConfig, fmt.Errorf("Could not unmarshall cloud-config: %w", err) - } - - // Get previously defined users list in provided config, if present. - userList, err := findOrCreateListInMap(cloudConfigMap, "users") - if err != nil { - return cloudConfig, err - } - - // Define comment to be added on the side of added keys. - sshKeyExtendedConfigTag := "#lxd:cloud-init.ssh-keys" - - // Merge the specified additional keys into the provided cloud config. - for user, keys := range users { - var targetUser map[any]any - - for index, field := range userList { - mapField, ok := field.(map[any]any) - - // The user has to be either a mapping yaml node or a simple string indicating the name of a user to be created. - if !ok { - // If the field is not the user name we want, skip this one. - userName, isString := field.(string) - if isString && userName == user { - // Else, create a user map for us to add the keys into. Use the previously defined name as the name in the user map. - targetUser = make(map[any]any) - targetUser["name"] = userName - userList[index] = targetUser - break - } else if !isString { - return cloudConfig, errors.New("Invalid user item on users list") - } - } else if mapField["name"] == user { - // If it is a map, check the name. - targetUser = mapField - break - } - } - - // If the target user was not present in the cloud config, create an entry for it. - if targetUser == nil { - targetUser = make(map[any]any) - targetUser["name"] = user - userList = append(userList, targetUser) - } - - // Using both the older and newer keys, since we do not know what version of cloud-init will be consuming this. - sshAuthorizedKeys := []string{"ssh_authorized_keys", "ssh-authorized-keys"} - importIDKeys := []string{"ssh_import_id", "ssh-import-id"} - - // Add public keys to cloud-config. - err = addValueToListsInMap(targetUser, keys.publicKeys, sshAuthorizedKeys, sshKeyExtendedConfigTag) - if err != nil { - return cloudConfig, err - } - - // Add import IDs to cloud-config. - err = addValueToListsInMap(targetUser, keys.importIDs, importIDKeys, sshKeyExtendedConfigTag) - if err != nil { - return cloudConfig, err - } - } - - // Only modify the original config map if everything went well. - cloudConfigMap["users"] = userList - resultingConfigBytes, err := yaml.Marshal(cloudConfigMap) - if err != nil { - return cloudConfig, err - } - - // Add cloud-config tag and space before comments, as doing the latter - // while parsing would result in the comment to be included in the value on the same line. - resultingConfig := "#cloud-config\n" + strings.ReplaceAll(string(resultingConfigBytes), sshKeyExtendedConfigTag, " "+sshKeyExtendedConfigTag) - return resultingConfig, nil -} - -// addValueToListsInMap finds or creates a list referenced on the provided user map for each key on fieldKeys -// and adds all provided values along with addedValueTag on the side to mark added values. -// addedKeyTag is simply appended to the values added, any parsing to separate the tag from the -// value should be done outside this function. -func addValueToListsInMap(user map[any]any, addedValues []string, fieldKeys []string, addedValueTag string) error { - // If there are no keys to add, this function should be a no-op. - if len(addedValues) == 0 { - return nil - } - - for _, fieldKey := range fieldKeys { - // Get the field with the provided key, if it exists. - // If it does not exist, create it as an empty list. - // If it exists and is not a list, switch it for a list containing the previously defined value. - targetList, err := findOrCreateListInMap(user, fieldKey) - if err != nil { - return err - } - - // Add the keys to the lists that will not be filled with an alias afterwards. - // Do not add if the key is already present on the slice and mark added keys. - for _, key := range addedValues { - if !shared.ValueInSlice(any(key), targetList) { - targetList = append(targetList, key+addedValueTag) - } - } - - // Update the map with the slice with appended keys. - user[fieldKey] = targetList - } - - return nil -} - -// findOrCreateListInMap finds a list under the provided key on a map that represents a YAML map field. -// If there is no value for the provided key, this returns a slice than can be used for the key, but -// this function does not change the provided map. -// If the value under the key is a string, the returned slice will contain it. -// If the value for the key is of any other type, return an error. -func findOrCreateListInMap(yamlMap map[any]any, key string) ([]any, error) { - // Get previously defined list in provided map, if present. - field, hasField := yamlMap[key] - listField, isSlice := field.([]any) - _, isString := field.(string) - - // If the field under the key is set to something other than a list or a string, both of which - // would be valid, return an error. - if hasField && !isSlice && !isString { - return nil, fmt.Errorf("Invalid value under %q", key) - } - - // If provided map did not include a field under the key or included one that was simply a string and - // not a list, create a slice. - if !hasField || isString { - listField = make([]any, 0) - // Preserve the previous string field as an item on the new list so it is still applied. - if isString { - listField = append(listField, field) - } - } - - return listField, nil -}