Skip to content

Commit

Permalink
add support for writing files to containers from cloud-init config
Browse files Browse the repository at this point in the history
We are adding the support for "write_files" section in cloud-init config
for containers. Also the definition of envs is moved to the section
"runcmd" with lines like "- VAR=value",
however the old syntax of "VAR=value"
is still supported. The rest of cloud-init config is ignored.

Signed-off-by: Paul Gaiduk <paulg@zededa.com>
  • Loading branch information
europaul committed Oct 30, 2023
1 parent ccbcc7d commit f9b4bc7
Show file tree
Hide file tree
Showing 6 changed files with 385 additions and 16 deletions.
114 changes: 103 additions & 11 deletions pkg/pillar/cmd/domainmgr/domainmgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package domainmgr

import (
"bufio"
"encoding/base64"
"errors"
"flag"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 warning on line 1428 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1410-L1428

Added lines #L1410 - L1428 were not covered by tests
}
}
}

// Check for scanner errors.
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("Reading the file failed: %s", err.Error())
}

Check warning on line 1436 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1434-L1436

Added lines #L1434 - L1436 were not covered by tests

return 0, errors.New("Version not found in meta-data file")

Check warning on line 1438 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1438

Added line #L1438 was not covered by tests
}

func doActivate(ctx *domainContext, config types.DomainConfig,
status *types.DomainStatus) {

Expand Down Expand Up @@ -1463,13 +1497,54 @@ 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 {

Check warning on line 1501 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1500-L1501

Added lines #L1500 - L1501 were not covered by tests
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 {
curCIVersion = 0 // make sure the cloud init config gets executed
}

Check warning on line 1515 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1509-L1515

Added lines #L1509 - L1515 were not covered by tests

// 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
}

Check warning on line 1522 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1518-L1522

Added lines #L1518 - L1522 were not covered by tests

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
}

Check warning on line 1535 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1524-L1535

Added lines #L1524 - L1535 were not covered by tests

// 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
}

Check warning on line 1545 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1538-L1545

Added lines #L1538 - L1545 were not covered by tests
}
}
default:
// assume everything else to be disk formats
format, err := utils.GetVolumeFormat(log, ds.FileLocation)
Expand Down Expand Up @@ -1889,20 +1964,38 @@ func configToStatus(ctx *domainContext, config types.DomainConfig,
//clean environment variables
status.EnvVariables = nil

// Fetch cloud-init userdata

Check warning on line 1967 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1967

Added line #L1967 was not covered by tests
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

Check warning on line 1996 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1974-L1996

Added lines #L1974 - L1996 were not covered by tests
}
status.EnvVariables = envList
} else {
} else { // If AppInstance is a VM, we need to create a cloud-init ISO

Check warning on line 1998 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1998

Added line #L1998 was not covered by tests
switch config.MetaDataType {
case types.MetaDataDrive, types.MetaDataDriveMultipart:
ds, err := createCloudInitISO(ctx, config, ciStr)
Expand Down Expand Up @@ -2515,11 +2608,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])
Expand Down
12 changes: 11 additions & 1 deletion pkg/pillar/cmd/domainmgr/domainmgr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/pillar/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions pkg/pillar/types/domainmgrtypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
107 changes: 107 additions & 0 deletions pkg/pillar/utils/cloudconfig/cloudconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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"
)

// CloudConfig represents the structure of the cloud configuration file.
// Only supported fields are defined here. The rest is ignored.
type CloudConfig struct {
RunCmd []string `yaml:"runcmd"`
WriteFiles []WritableFile `yaml:"write_files"` // nolint:tagliatelle // cloud-init standard uses snake_case

Check failure on line 21 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View workflow job for this annotation

GitHub Actions / yetus

golangcilint: directive `// nolint:tagliatelle // cloud-init standard uses snake_case` should be written without leading space as `//nolint:tagliatelle // cloud-init standard uses snake_case` (nolintlint)
}

// WritableFile represents a file that can be written to disk with the specified content, permissions, encoding and owner.
type WritableFile struct {
Path string `yaml:"path"`
Content string `yaml:"content"`
Permissions string `yaml:"permissions"`
Encoding string `yaml:"encoding"`
Owner string `yaml:"owner"`
}

// IsCloudConfig checks if the given string is a cloud-config file by checking if the first line starts with "#cloud-config".
// It returns true if the first line starts with "#cloud-config", otherwise it returns false.
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")

Check warning on line 41 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L35-L41

Added lines #L35 - L41 were not covered by tests
}

// ParseCloudConfig parses the given cloud-init configuration and returns a pointer to a CloudConfig struct and an error.
func ParseCloudConfig(ci string) (*CloudConfig, error) {
var cc CloudConfig
err := yaml.Unmarshal([]byte(ci), &cc)
if err != nil {
return nil, err
}

Check warning on line 50 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L49-L50

Added lines #L49 - L50 were not covered by tests
return &cc, nil
}

// WriteFile checks the content of a WritableFile and writes it to the specified rootPath.
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("detected possible attempt to write file outside of root path. invalid path %s", file.Path)
}

var contentBytes []byte
switch file.Encoding {
case "b64":
// decode base64 content
contentBytes, err = base64.StdEncoding.DecodeString(file.Content)
if err != nil {
return err
}

Check warning on line 76 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L75-L76

Added lines #L75 - L76 were not covered by tests
case "plain":
contentBytes = []byte(file.Content)
default:
return errors.New("unsupported encoding type. Only base64 and plain are supported")
}

// check if the parent directory exists
parentDir := filepath.Dir(writePath)
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
// create parent directory
err = os.MkdirAll(parentDir, 0755)
if err != nil {
return err
}

Check warning on line 90 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L89-L90

Added lines #L89 - L90 were not covered by tests
}

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
}

Check warning on line 97 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L96-L97

Added lines #L96 - L97 were not covered by tests
err = os.Chmod(writePath, mode)
if err != nil {
return err
}

Check warning on line 101 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L100-L101

Added lines #L100 - L101 were not covered by tests
if file.Owner != "" {
log.Warn("Changing owner of files written by cloud-init is not supported yet")
}

Check warning on line 104 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/utils/cloudconfig/cloudconfig.go#L103-L104

Added lines #L103 - L104 were not covered by tests

return nil
}
Loading

0 comments on commit f9b4bc7

Please sign in to comment.