diff --git a/pkg/pillar/cmd/domainmgr/domainmgr.go b/pkg/pillar/cmd/domainmgr/domainmgr.go index e965baa5e0e..c733a94b157 100644 --- a/pkg/pillar/cmd/domainmgr/domainmgr.go +++ b/pkg/pillar/cmd/domainmgr/domainmgr.go @@ -9,6 +9,7 @@ package domainmgr import ( + "bufio" "encoding/base64" "errors" "flag" @@ -38,6 +39,8 @@ import ( "github.com/lf-edge/eve/pkg/pillar/sriov" "github.com/lf-edge/eve/pkg/pillar/types" "github.com/lf-edge/eve/pkg/pillar/utils" + "github.com/lf-edge/eve/pkg/pillar/utils/cloudconfig" + fileutils "github.com/lf-edge/eve/pkg/pillar/utils/file" "github.com/opencontainers/runtime-spec/specs-go" uuid "github.com/satori/go.uuid" "github.com/sirupsen/logrus" @@ -1404,6 +1407,37 @@ func doAssignIoAdaptersToDomain(ctx *domainContext, config types.DomainConfig, return nil } +func getVersionFromMetaFile(path string) (uint64, error) { + var curCIVersion uint64 + + // read cloud init version from the meta-data file + metafile, err := os.Open(path) + if err != nil { + return 0, fmt.Errorf("Failed to open meta-data file: %s", err) + } + scanner := bufio.NewScanner(metafile) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "instance-id:") { + parts := strings.Split(line, "/") + if len(parts) >= 2 { + curCIVersion, err = strconv.ParseUint(parts[1], 10, 32) + if err != nil { + return 0, fmt.Errorf("Failed to parse cloud init version: %s", err.Error()) + } + return curCIVersion, nil + } + } + } + + // Check for scanner errors. + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("Reading the file failed: %s", err.Error()) + } + + return 0, errors.New("Version not found in meta-data file") +} + func doActivate(ctx *domainContext, config types.DomainConfig, status *types.DomainStatus) { @@ -1463,13 +1497,55 @@ func doActivate(ctx *domainContext, config types.DomainConfig, // do nothing case zconfig.Format_CONTAINER: snapshotID := containerd.GetSnapshotID(ds.FileLocation) - if err := ctx.casClient.MountSnapshot(snapshotID, cas.GetRoofFsPath(ds.FileLocation)); err != nil { + rootPath := cas.GetRoofFsPath(ds.FileLocation) + if err := ctx.casClient.MountSnapshot(snapshotID, rootPath); err != nil { err := fmt.Errorf("doActivate: Failed mount snapshot: %s for %s. Error %s", snapshotID, config.UUIDandVersion.UUID, err) log.Error(err.Error()) status.SetErrorNow(err.Error()) return } + + metadataPath := filepath.Join(rootPath, "meta-data") + + // get current cloud init version + curCIVersion, err := getVersionFromMetaFile(metadataPath) + if err != nil { + log.Error("Failed to get cloud init version from meta-data file: ", err) + curCIVersion = 0 // make sure the cloud init config gets executed + } + + // get new cloud init version + newCIVersion, err := strconv.ParseUint(getCloudInitVersion(config), 10, 32) + if err != nil { + log.Error("Failed to parse cloud init version: ", err) + newCIVersion = curCIVersion + 1 // make sure the cloud init config gets executed + } + + if curCIVersion < newCIVersion { + log.Notice("New cloud init config detected - applying") + + // write meta-data file + versionString := fmt.Sprintf("instance-id: %s/%s\n", config.UUIDandVersion.UUID.String(), getCloudInitVersion(config)) + err = fileutils.WriteRename(metadataPath, []byte(versionString)) + if err != nil { + err := fmt.Errorf("doActivate: Failed to write cloud-init metadata file. Error %s", err) + log.Error(err.Error()) + status.SetErrorNow(err.Error()) + return + } + + // apply cloud init config + for _, writableFile := range status.WritableFiles { + err := cloudconfig.WriteFile(log, writableFile, rootPath) + if err != nil { + err := fmt.Errorf("doActivate: Failed to apply cloud-init config. Error %s", err) + log.Error(err.Error()) + status.SetErrorNow(err.Error()) + return + } + } + } default: // assume everything else to be disk formats format, err := utils.GetVolumeFormat(log, ds.FileLocation) @@ -1889,20 +1965,38 @@ func configToStatus(ctx *domainContext, config types.DomainConfig, //clean environment variables status.EnvVariables = nil + // Fetch cloud-init userdata if config.IsCipher || config.CloudInitUserData != nil { ciStr, err := fetchCloudInit(ctx, config) if err != nil { return fmt.Errorf("failed to fetch cloud-init userdata: %s", err) } - if status.OCIConfigDir != "" { - envList, err := parseEnvVariablesFromCloudInit(ciStr) - if err != nil { - return fmt.Errorf("failed to parse environment variable from cloud-init userdata: %s", - err) + if status.OCIConfigDir != "" { // If AppInstance is a container, we need to parse cloud-init config and apply the supported parts + if cloudconfig.IsCloudConfig(ciStr) { // treat like the cloud-init config + cc, err := cloudconfig.ParseCloudConfig(ciStr) + if err != nil { + return fmt.Errorf("failed to unmarshal cloud-init userdata: %s", + err) + } + status.WritableFiles = cc.WriteFiles + + envList, err := parseEnvVariablesFromCloudInit(cc.RunCmd) + if err != nil { + return fmt.Errorf("failed to parse environment variable from cloud-init userdata: %s", + err) + } + status.EnvVariables = envList + } else { // treat like the key value map for envs (old syntax) + envPairs := strings.Split(ciStr, "\n") + envList, err := parseEnvVariablesFromCloudInit(envPairs) + if err != nil { + return fmt.Errorf("failed to parse environment variable from cloud-init env map: %s", + err) + } + status.EnvVariables = envList } - status.EnvVariables = envList - } else { + } else { // If AppInstance is a VM, we need to create a cloud-init ISO switch config.MetaDataType { case types.MetaDataDrive, types.MetaDataDriveMultipart: ds, err := createCloudInitISO(ctx, config, ciStr) @@ -2515,11 +2609,10 @@ func fetchCloudInit(ctx *domainContext, // Example: // Key1=Val1 // Key2=Val2 ... -func parseEnvVariablesFromCloudInit(ciStr string) (map[string]string, error) { +func parseEnvVariablesFromCloudInit(envPairs []string) (map[string]string, error) { envList := make(map[string]string, 0) - list := strings.Split(ciStr, "\n") - for _, v := range list { + for _, v := range envPairs { pair := strings.SplitN(v, "=", 2) if len(pair) != 2 { errStr := fmt.Sprintf("Variable \"%s\" not defined properly\nKey value pair should be delimited by \"=\"", pair[0]) diff --git a/pkg/pillar/cmd/domainmgr/domainmgr_test.go b/pkg/pillar/cmd/domainmgr/domainmgr_test.go index b491adc7854..60e9feacfc6 100644 --- a/pkg/pillar/cmd/domainmgr/domainmgr_test.go +++ b/pkg/pillar/cmd/domainmgr/domainmgr_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/lf-edge/eve/pkg/pillar/base" + "github.com/lf-edge/eve/pkg/pillar/utils/cloudconfig" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -104,7 +105,16 @@ func decodeAndParseEnvVariablesFromCloudInit(ciStr string) (map[string]string, e return nil, fmt.Errorf("base64 decode failed %s", err) } - return parseEnvVariablesFromCloudInit(string(ud)) + if cloudconfig.IsCloudConfig(string(ud)) { // treat like the cloud-init config + cc, err := cloudconfig.ParseCloudConfig(string(ud)) + if err != nil { + return nil, err + } + return parseEnvVariablesFromCloudInit(cc.RunCmd) + } else { // treat like the key value map for envs (old syntax) + envPairs := strings.Split(string(ud), "\n") + return parseEnvVariablesFromCloudInit(envPairs) + } } // Definitions of various cloud-init multi-part messages diff --git a/pkg/pillar/go.mod b/pkg/pillar/go.mod index dc26ae63543..566f665d58e 100644 --- a/pkg/pillar/go.mod +++ b/pkg/pillar/go.mod @@ -48,6 +48,7 @@ require ( golang.org/x/sys v0.13.0 google.golang.org/grpc v1.56.3 google.golang.org/protobuf v1.30.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -136,7 +137,6 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect oras.land/oras-go v1.2.0 // indirect diff --git a/pkg/pillar/types/domainmgrtypes.go b/pkg/pillar/types/domainmgrtypes.go index 19e5116b458..5aea17b5928 100644 --- a/pkg/pillar/types/domainmgrtypes.go +++ b/pkg/pillar/types/domainmgrtypes.go @@ -16,6 +16,7 @@ import ( "github.com/google/go-cmp/cmp" zconfig "github.com/lf-edge/eve-api/go/config" "github.com/lf-edge/eve/pkg/pillar/base" + "github.com/lf-edge/eve/pkg/pillar/utils/cloudconfig" ) // The information DomainManager needs to boot and halt domains @@ -278,9 +279,10 @@ type DomainStatus struct { ConfigFailed bool BootFailed bool AdaptersFailed bool - OCIConfigDir string // folder holding an OCI Image config for this domain (empty string means no config) - EnvVariables map[string]string // List of environment variables to be set in container - VmConfig // From DomainConfig + OCIConfigDir string // folder holding an OCI Image config for this domain (empty string means no config) + EnvVariables map[string]string // List of environment variables to be set in container + WritableFiles []cloudconfig.WritableFile // List of files from CloudInit scripts to be created in container + VmConfig // From DomainConfig Service bool } diff --git a/pkg/pillar/utils/cloudconfig/cloudconfig.go b/pkg/pillar/utils/cloudconfig/cloudconfig.go new file mode 100644 index 00000000000..05266e3e8b1 --- /dev/null +++ b/pkg/pillar/utils/cloudconfig/cloudconfig.go @@ -0,0 +1,88 @@ +package cloudconfig + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/lf-edge/eve/pkg/pillar/base" + fileutils "github.com/lf-edge/eve/pkg/pillar/utils/file" + "gopkg.in/yaml.v2" +) + +type CloudConfig struct { + RunCmd []string `yaml:"runcmd"` + WriteFiles []WritableFile `yaml:"write_files"` +} + +type WritableFile struct { + Path string `yaml:"path"` + Content string `yaml:"content"` + Permissions string `yaml:"permissions"` + Encoding string `yaml:"encoding"` + Owner string `yaml:"owner"` +} + +func IsCloudConfig(ci string) bool { + // check if the first line is #cloud-config + lines := strings.Split(ci, "\n") + if len(lines) == 0 { + return false + } + return strings.HasPrefix(lines[0], "#cloud-config") +} + +func ParseCloudConfig(ci string) (*CloudConfig, error) { + var cc CloudConfig + err := yaml.Unmarshal([]byte(ci), &cc) + if err != nil { + return nil, err + } + return &cc, nil +} + +func WriteFile(log *base.LogObject, file WritableFile, rootPath string) error { + // transform file.Permission to os.FileMode + perm, err := strconv.ParseUint(file.Permissions, 8, 32) + if err != nil { + return err + } + mode := os.FileMode(perm) + + writePath := filepath.Join(rootPath, file.Path) + // sanitize path + if !strings.HasPrefix(filepath.Clean(writePath), rootPath) { + return fmt.Errorf("invalid path %s", writePath) + } + + var contentBytes []byte + switch file.Encoding { + case "b64": + // decode base64 content + contentBytes, err = base64.StdEncoding.DecodeString(file.Content) + if err != nil { + return err + } + default: + return errors.New("unsupported encoding type. Only base64 is supported") + } + + log.Tracef("Creating file %s with mode %s in %s\n", file.Path, mode, rootPath) + err = fileutils.WriteRename(writePath, contentBytes) + if err != nil { + return err + } + err = os.Chmod(writePath, mode) + if err != nil { + return err + } + if file.Owner != "" { + log.Warn("Changing owner of files written by cloud-init is not supported yet") + } + + return nil +}