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 27, 2023
1 parent ccbcc7d commit f3f5f19
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 16 deletions.
115 changes: 104 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,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 {

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 {
log.Error("Failed to get cloud init version from meta-data file: ", err)
curCIVersion = 0 // make sure the cloud init config gets executed
}

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

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L1509 - L1516 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 1523 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1519-L1523

Added lines #L1519 - L1523 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 1536 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1525-L1536

Added lines #L1525 - L1536 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 1546 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1539-L1546

Added lines #L1539 - L1546 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 +1965,38 @@ func configToStatus(ctx *domainContext, config types.DomainConfig,
//clean environment variables
status.EnvVariables = nil

// Fetch cloud-init userdata

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1968 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 1997 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

pkg/pillar/cmd/domainmgr/domainmgr.go#L1975-L1997

Added lines #L1975 - L1997 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 1999 in pkg/pillar/cmd/domainmgr/domainmgr.go

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1999 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 +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])
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
88 changes: 88 additions & 0 deletions pkg/pillar/utils/cloudconfig/cloudconfig.go
Original file line number Diff line number Diff line change
@@ -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 {

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

View workflow job for this annotation

GitHub Actions / yetus

revive: exported type CloudConfig should have comment or be unexported https://revive.run/r#exported
RunCmd []string `yaml:"runcmd"`
WriteFiles []WritableFile `yaml:"write_files"`

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

View workflow job for this annotation

GitHub Actions / yetus

golangcilint: yaml(camel): got 'write_files' want 'writeFiles' (tagliatelle)
}

type WritableFile struct {

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

View workflow job for this annotation

GitHub Actions / yetus

revive: exported type WritableFile should have comment or be unexported https://revive.run/r#exported
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 failure on line 30 in pkg/pillar/utils/cloudconfig/cloudconfig.go

View workflow job for this annotation

GitHub Actions / yetus

revive: exported function IsCloudConfig should have comment or be unexported https://revive.run/r#exported
// 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) {

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

View workflow job for this annotation

GitHub Actions / yetus

revive: exported function ParseCloudConfig should have comment or be unexported https://revive.run/r#exported
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 {

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

View workflow job for this annotation

GitHub Actions / yetus

revive: exported function WriteFile should have comment or be unexported https://revive.run/r#exported
// 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
}

0 comments on commit f3f5f19

Please sign in to comment.