Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for writing files to containers from cloud-init config #3520

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/CLOUD-INIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Cloud-Init

EVE supports [cloud-init](https://cloudinit.readthedocs.io/en/latest/) for configuring VMs and containers. The user-data is passed through the corresponding fields in the [AppInstanceConfig message](https://github.com/lf-edge/eve-api/tree/main/proto/config/appconfig.proto).

## Support in VMs

For VMs, EVE supports cloud-init configuration using the NoCloud datasource. With NoCloud, EVE manually provides both meta-data and user-data received from the controller to the VM. This is done by placing these files on a virtual CD-ROM in the form of an ISO image. Further EVE relies on the cloud-init system present in the ECO's VM image. Upon booting the VM instance, if a cloud-init installation is present, the system checks for these data files, and if found, processes the configurations or scripts defined in them.

For more information on the meta-data consult [ECO-METADATA.md](ECO-METADATA.md).

## Support in Containers

As opposed to the VM implementation, cloud-init in ECO containers does not rely on a cloud-init daemon being present in the container image. Instead, the cloud-init configuration is parsed by EVE and manually applied to the container. EVE's implementation supports two formats for user-data:

- The **legacy** format only supports the definition of environment variables in the form of a simple key-value map. The equal sign "=" is used as delimiter in this case:

```text
ENV1=value1
ENV2=value2
```

- In the **original** cloud-init format the user-data is specified like in any other cloud-init configuration. The current EVE implementation only supports two user-data fields: `runcmd` and `write_files`.

`runcmd` is only used to set environment variables, similar to the **legacy** format. Trying to use any other command will result in an error. The env definitions **must not** be preceded by an `export` keyword, but it's effect is implied in the implementation.

`write_files` field supports parameters such as path, content, permissions and encoding. It is used to write one or more files to the container image prior to the container start.

Every cloud-init configuration **must** begin with the `#cloud-config` header. Example:

```yaml
#cloud-config
runcmd:
- ENV1=value1
- ENV2=value2
write_files:
- path: /etc/injected_file.txt
permissions: '0644'
encoding: b64
content: YmxhYmxh
```

## Versioning

Both VM and container implementations support versioning of the user-data. This means that the cloud-init configuration is only reapplied if the version of the user-data has changed. The version is specified in the meta-data file in the `instance-id` field.

If the controller implementation does not support versioning, the user-data will be reapplied each time the version of the `AppInstanceConfig` changes.
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 @@
"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 @@
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 @@
// 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)
eriknordmark marked this conversation as resolved.
Show resolved Hide resolved
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))
eriknordmark marked this conversation as resolved.
Show resolved Hide resolved
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 @@
//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 @@
// 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 @@
"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 @@ -95,7 +96,7 @@
}

// GetTaskName assigns a unique name to the task representing this domain
// FIXME: given config.UUIDandVersion.Version part not sure config.AppNum is needed for uniqueness

Check failure on line 99 in pkg/pillar/types/domainmgrtypes.go

View workflow job for this annotation

GitHub Actions / yetus

golangcilint: types/domainmgrtypes.go:99: Line contains TODO/BUG/FIXME: "FIXME: given config.UUIDandVersion.Versi..." (godox)
func (config DomainConfig) GetTaskName() string {
return config.UUIDandVersion.UUID.String() + "." +
config.UUIDandVersion.Version + "." +
Expand All @@ -104,7 +105,7 @@

// DomainnameToUUID does the reverse of GetTaskName
func DomainnameToUUID(name string) (uuid.UUID, string, int, error) {
// FIXME: we can likely drop this altogether

Check failure on line 108 in pkg/pillar/types/domainmgrtypes.go

View workflow job for this annotation

GitHub Actions / yetus

golangcilint: types/domainmgrtypes.go:108: Line contains TODO/BUG/FIXME: "FIXME: we can likely drop this altogethe..." (godox)
if name == "Domain-0" {
return uuid.UUID{}, "", 0, nil
}
Expand Down Expand Up @@ -278,9 +279,10 @@
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 {
eriknordmark marked this conversation as resolved.
Show resolved Hide resolved
RunCmd []string `yaml:"runcmd"`
WriteFiles []WritableFile `yaml:"write_files"` //nolint:tagliatelle // cloud-init standard uses snake_case
}

// 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"`
eriknordmark marked this conversation as resolved.
Show resolved Hide resolved
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