diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c66b60..17b1504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.3.0] - 2024-11-10 + +### Changed + +- Update easyto-assets to v0.4.0 to speed boot time. +- Update README to clarify behavior of `secrets-manager` volume. + +### Removed + +- Remove init from this repository. It has been replaced with a version developed in its [own repository](https://github.com/cloudboss/easyto-init). + ## [0.2.0] - 2024-08-06 ### Added @@ -29,5 +40,6 @@ Initial release +[0.3.0]: https://github.com/cloudboss/easyto/releases/tag/v0.3.0 [0.2.0]: https://github.com/cloudboss/easyto/releases/tag/v0.2.0 [0.1.0]: https://github.com/cloudboss/easyto/releases/tag/v0.1.0 diff --git a/Makefile b/Makefile index c7ee28a..31daec0 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ endif DIR_RELEASE = $(DIR_OUT)/release EASYTO_ASSETS_RELEASES = https://github.com/cloudboss/easyto-assets/releases/download -EASYTO_ASSETS_VERSION = v0.3.0 +EASYTO_ASSETS_VERSION = v0.4.0 EASYTO_ASSETS_BUILD = easyto-assets-build-$(EASYTO_ASSETS_VERSION) EASYTO_ASSETS_BUILD_ARCHIVE = $(EASYTO_ASSETS_BUILD).tar.gz EASYTO_ASSETS_BUILD_URL = $(EASYTO_ASSETS_RELEASES)/$(EASYTO_ASSETS_VERSION)/$(EASYTO_ASSETS_BUILD_ARCHIVE) @@ -30,6 +30,11 @@ EASYTO_ASSETS_PACKER_URL = $(EASYTO_ASSETS_RELEASES)/$(EASYTO_ASSETS_VERSION)/$( EASYTO_ASSETS_RUNTIME = easyto-assets-runtime-$(EASYTO_ASSETS_VERSION) EASYTO_ASSETS_RUNTIME_ARCHIVE = $(EASYTO_ASSETS_RUNTIME).tar.gz EASYTO_ASSETS_RUNTIME_URL = $(EASYTO_ASSETS_RELEASES)/$(EASYTO_ASSETS_VERSION)/$(EASYTO_ASSETS_RUNTIME_ARCHIVE) +EASYTO_INIT_RELEASES = https://github.com/cloudboss/easyto-init/releases/download +EASYTO_INIT_VERSION = v0.1.1 +EASYTO_INIT = easyto-init-$(EASYTO_INIT_VERSION) +EASYTO_INIT_ARCHIVE = easyto-init-$(EASYTO_INIT_VERSION).tar.gz +EASYTO_INIT_URL = $(EASYTO_INIT_RELEASES)/$(EASYTO_INIT_VERSION)/$(EASYTO_INIT_ARCHIVE) EASYTO_ASSETS_PACKER_OUT = $(DIR_STG_PACKER)/$(PACKER_EXE) \ $(DIR_STG_PACKER_PLUGIN)/$(PACKER_PLUGIN_AMZ_EXE) \ @@ -65,26 +70,8 @@ $(DIR_OUT)/$(EASYTO_ASSETS_PACKER_ARCHIVE): | $(HAS_COMMAND_CURL) $(DIR_OUT) $(DIR_OUT)/$(EASYTO_ASSETS_RUNTIME_ARCHIVE): | $(HAS_COMMAND_CURL) $(DIR_OUT) @curl -L -o $(DIR_OUT)/$(EASYTO_ASSETS_RUNTIME_ARCHIVE) $(EASYTO_ASSETS_RUNTIME_URL) -$(DIR_STG_INIT)/$(DIR_ET)/sbin/init: \ - hack/compile-init-ctr \ - go.mod \ - $(shell find cmd/initial -type f -path '*.go' ! -path '*_test.go') \ - $(shell find pkg -type f -path '*.go' ! -path '*_test.go') \ - $(shell find third_party -type f -path '*.go' ! -path '*_test.go') \ - | $(HAS_IMAGE_LOCAL) $(VAR_DIR_ET) $(DIR_STG_INIT)/$(DIR_ET)/sbin/ - @docker run --rm -t \ - -v $(DIR_ROOT):/code \ - -v $(DIR_ROOT)/$(DIR_STG_INIT):/install \ - -e OPENSSH_PRIVSEP_DIR=$(OPENSSH_PRIVSEP_DIR) \ - -e OPENSSH_PRIVSEP_USER=$(OPENSSH_PRIVSEP_USER) \ - -e CHRONY_USER=$(CHRONY_USER) \ - -e DIR_ET_ROOT=/$(DIR_ET) \ - -e DIR_OUT=/install/$(DIR_ET)/sbin \ - -e GOPATH=/code/$(DIR_OUT)/go \ - -e GOCACHE=/code/$(DIR_OUT)/gocache \ - -e CGO_ENABLED=1 \ - -w /code \ - $(CTR_IMAGE_LOCAL) /bin/sh -c "$$(cat hack/compile-init-ctr)" +$(DIR_OUT)/$(EASYTO_INIT_ARCHIVE): | $(HAS_COMMAND_CURL) $(DIR_OUT) + @curl -L -o $(DIR_OUT)/$(EASYTO_INIT_ARCHIVE) $(EASYTO_INIT_URL) $(EASYTO_ASSETS_PACKER_OUT) &: $(DIR_OUT)/$(EASYTO_ASSETS_PACKER_ARCHIVE) | $(DIR_STG_PACKER)/ @tar -zmx \ @@ -124,10 +111,11 @@ $(DIR_OUT)/ctr2disk: \ -w /code \ $(CTR_IMAGE_LOCAL) /bin/sh -c "$$(cat hack/compile-ctr2disk-ctr)" -$(DIR_STG_ASSETS)/init.tar: \ - $(DIR_STG_INIT)/$(DIR_ET)/sbin/init \ - | $(HAS_COMMAND_FAKEROOT) $(DIR_STG_ASSETS)/ - @cd $(DIR_STG_INIT) && fakeroot tar cf $(DIR_ROOT)/$(DIR_STG_ASSETS)/init.tar . + +$(DIR_STG_ASSETS)/init.tar: $(DIR_OUT)/$(EASYTO_INIT_ARCHIVE) | $(DIR_STG_ASSETS)/ + @tar -zmx \ + --xform "s|^$(EASYTO_INIT)|$(DIR_STG_ASSETS)|" \ + -f $(DIR_OUT)/$(EASYTO_INIT_ARCHIVE) $(DIR_STG_BIN)/easyto: \ hack/compile-easyto-ctr \ @@ -164,8 +152,6 @@ $(DIR_RELEASE)/easyto-$(VERSION)-$(OS)-$(ARCH).tar.gz: \ -f $(DIR_ROOT)/$(DIR_RELEASE)/easyto-$(VERSION)-$(OS)-$(ARCH).tar.gz assets bin packer test: - go vet -v ./third_party/... - go test -v ./third_party/... go vet -v ./... go test -v ./... diff --git a/README.md b/README.md index 4137403..ab57ff5 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ An SSM volume is a pseudo-volume, as the parameters from SSM Parameter Store are > [!NOTE] > The EC2 instance must have an instance profile with permission to call `secretsmanager:GetSecretValue`, and `kms:Decrypt` for the KMS key used to encrypt the secret if a customer-managed key was used. -A Secrets Manager volume is a pseudo-volume, as the secret from Secrets Manager is copied as a file to the path defined in `mount.destination` one time on boot. Any updates to the secret would require a reboot to get the new value. The file is always written with a mode of `0600`. The owner and group of the file defaults to `security.run-as-user-id` and `security.run-as-group-id` unless explicitly specified in the volume's `mount.user-id` and `mount.group-id`. +A Secrets Manager volume is a pseudo-volume, as the secret from Secrets Manager is copied as a file to the path defined in `mount.destination` one time on boot. Any updates to the secret would require a reboot to get the new value. This volume results in a single file being written, not a directory tree as is possible with S3 and SSM volumes. The file is always written with a mode of `0600`. The owner and group of the file defaults to `security.run-as-user-id` and `security.run-as-group-id` unless explicitly specified in the volume's `mount.user-id` and `mount.group-id`. `mount`: (Required, type [_mount_](#mount-object) object) - Configuration of the destination for the secret. diff --git a/cmd/easyto/tree/ami.go b/cmd/easyto/tree/ami.go index 2faad25..43bd1ca 100644 --- a/cmd/easyto/tree/ami.go +++ b/cmd/easyto/tree/ami.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/initial/vmspec" "github.com/spf13/cobra" ) @@ -41,7 +40,7 @@ var ( } amiCfg.packerDir = packerDir - return vmspec.ValidateServices(amiCfg.services) + return validateServices(amiCfg.services) }, RunE: func(cmd *cobra.Command, args []string) error { quotedServices := bytes.NewBufferString("") @@ -180,3 +179,15 @@ func expandPath(pth string) (string, error) { return filepath.Abs(expanded) } + +func validateServices(services []string) error { + for _, svc := range services { + switch svc { + case "chrony", "ssh": + continue + default: + return fmt.Errorf("invalid service %s", svc) + } + } + return nil +} diff --git a/cmd/initial/main.go b/cmd/initial/main.go deleted file mode 100644 index f6bf607..0000000 --- a/cmd/initial/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "fmt" - "os" - "syscall" - "time" - - "github.com/cloudboss/easyto/pkg/initial/initial" -) - -func main() { - err := initial.Run() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to set up init: %s\n", err) - } - - // Give console output time to catch up - // so we can see if there was an error. - time.Sleep(5 * time.Second) - - // Time to power down no matter what. - syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF) -} diff --git a/hack/compile-init-ctr b/hack/compile-init-ctr deleted file mode 100755 index 343d274..0000000 --- a/hack/compile-init-ctr +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -var_chrony_user="github.com/cloudboss/easyto/pkg/constants.ChronyUser=${CHRONY_USER}" -var_ssh_dir="github.com/cloudboss/easyto/pkg/constants.SSHPrivsepDir=${OPENSSH_PRIVSEP_DIR}" -var_ssh_user="github.com/cloudboss/easyto/pkg/constants.SSHPrivsepUser=${OPENSSH_PRIVSEP_USER}" -var_dir_et_root="github.com/cloudboss/easyto/pkg/constants.DirETRoot=${DIR_ET_ROOT}" -ldflags_vars="-X ${var_chrony_user} -X ${var_ssh_dir} -X ${var_ssh_user} -X ${var_dir_et_root}" -go build -o ${DIR_OUT}/init \ - -ldflags "${ldflags_vars} -linkmode external -extldflags -static -s -w" ./cmd/initial diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 58a2ff1..34c8207 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,16 +1,14 @@ package constants const ( - DirRoot = "/" DirProc = "/proc" - DirRun = "/run" - DirTmp = "/tmp" FileEtcPasswd = "/etc/passwd" FileEtcShadow = "/etc/shadow" FileEtcGroup = "/etc/group" FileEtcGShadow = "/etc/gshadow" - FileMetadata = "metadata.json" + + FileMetadata = "metadata.json" GroupNameWheel = "wheel" @@ -26,11 +24,8 @@ var ( SSHPrivsepDir string SSHPrivsepUser string - DirETRoot string - DirETBin = DirETRoot + "/bin" - DirETSbin = DirETRoot + "/sbin" - DirETEtc = DirETRoot + "/etc" - DirETHome = DirETRoot + "/home" - DirETRun = DirETRoot + "/run" - DirETServices = DirETRoot + "/services" + DirETRoot string + DirETBin = DirETRoot + "/bin" + DirETSbin = DirETRoot + "/sbin" + DirETHome = DirETRoot + "/home" ) diff --git a/pkg/initial/aws/asm.go b/pkg/initial/aws/asm.go deleted file mode 100644 index 95036e4..0000000 --- a/pkg/initial/aws/asm.go +++ /dev/null @@ -1,107 +0,0 @@ -package aws - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - asm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - "github.com/cloudboss/easyto/pkg/initial/collections" -) - -type asmAPI interface { - GetSecretValue(context.Context, *asm.GetSecretValueInput, - ...func(*asm.Options)) (*asm.GetSecretValueOutput, error) -} - -type ASMClient interface { - // GetSecretList retrieves only one item but returns a - // WritableList, for consistency with the other AWS clients, - // and since it has the desired behavior for writing to disk. - GetSecretList(secretID string) (collections.WritableList, error) - GetSecretMap(secretID string) (map[string]string, error) - GetSecretValue(secretID string) ([]byte, error) -} - -type asmClient struct { - api asmAPI -} - -func NewASMClient(cfg aws.Config) ASMClient { - return &asmClient{ - api: asm.NewFromConfig(cfg), - } -} - -func (a *asmClient) GetSecretList(secretID string) (collections.WritableList, error) { - secret, err := a.getSecret(secretID) - if err != nil { - return nil, err - } - return a.toList(secret) -} - -func (a *asmClient) GetSecretMap(secretID string) (map[string]string, error) { - secret, err := a.getSecret(secretID) - if err != nil { - return nil, err - } - var r io.Reader - if secret.SecretString != nil { - r = strings.NewReader(*secret.SecretString) - } else if secret.SecretBinary != nil { - r = bytes.NewReader(secret.SecretBinary) - } - m := make(map[string]string) - err = json.NewDecoder(r).Decode(&m) - if err != nil { - return nil, fmt.Errorf("unable to decode map from secret %s: %w", secretID, err) - } - return m, nil -} - -func (a *asmClient) GetSecretValue(secretID string) ([]byte, error) { - secret, err := a.getSecret(secretID) - if err != nil { - return nil, err - } - var value []byte - if secret.SecretString != nil { - value = []byte(*secret.SecretString) - } else if secret.SecretBinary != nil { - value = secret.SecretBinary - } - return value, nil -} - -func (a *asmClient) getSecret(secretID string) (*asm.GetSecretValueOutput, error) { - secret, err := a.api.GetSecretValue(context.Background(), &asm.GetSecretValueInput{ - SecretId: &secretID, - }) - if err != nil { - return nil, fmt.Errorf("unable to get secret %s: %w", secretID, err) - } - if secret == nil { - return nil, nil - } - if secret.SecretString == nil && secret.SecretBinary == nil { - return nil, fmt.Errorf("secret %s has no value", secretID) - } - return secret, nil -} - -func (a *asmClient) toList(secret *asm.GetSecretValueOutput) (collections.WritableList, error) { - value := &collections.WritableListEntry{} - if secret.SecretString != nil { - valueRC := io.NopCloser(strings.NewReader(*secret.SecretString)) - value.Value = valueRC - } else if secret.SecretBinary != nil { - valueRC := io.NopCloser(bytes.NewReader(secret.SecretBinary)) - value.Value = valueRC - } - return collections.WritableList{value}, nil -} diff --git a/pkg/initial/aws/asm_test.go b/pkg/initial/aws/asm_test.go deleted file mode 100644 index fc83bfe..0000000 --- a/pkg/initial/aws/asm_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package aws - -import ( - "context" - "errors" - "testing" - - asm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -type mockASMAPI struct { - secretBinary []byte - secretString *string -} - -func (m *mockASMAPI) GetSecretValue(ctx context.Context, in *asm.GetSecretValueInput, - opt ...func(*asm.Options)) (*asm.GetSecretValueOutput, error) { - out := &asm.GetSecretValueOutput{ - Name: p("thesecret"), - SecretBinary: m.secretBinary, - SecretString: m.secretString, - } - return out, nil -} - -func Test_ASMClient_GetSecretList(t *testing.T) { - testCases := []struct { - description string - dest string - secretString *string - secretBinary []byte - fsResult []file - err error - }{ - { - description: "Null test case", - fsResult: []file{}, - err: errors.New("secret thesecret has no value"), - }, - { - description: "String secret", - dest: "/abc", - secretString: p("abc-value"), - fsResult: []file{ - { - name: "/abc", - content: "abc-value", - mode: 0600, - }, - }, - }, - { - description: "Binary secret", - dest: "/def", - secretBinary: []byte("def-value"), - fsResult: []file{ - { - name: "/def", - content: "def-value", - mode: 0600, - }, - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - fs := afero.NewMemMapFs() - client := asmClient{ - api: &mockASMAPI{ - secretBinary: tc.secretBinary, - secretString: tc.secretString, - }, - } - secrets, err := client.GetSecretList("thesecret") - assert.Equal(t, tc.err, err) - err = secrets.Write(fs, tc.dest, 0, 0, true) - assert.NoError(t, err) - for _, file := range tc.fsResult { - contents, stat, err := fileRead(fs, file.name) - assert.NoError(t, err) - assert.Equal(t, string(file.content), contents) - assert.Equal(t, file.mode, stat.Mode()) - } - }) - } -} - -func Test_ASMClient_GetSecretMap(t *testing.T) { - testCases := []struct { - description string - dest string - secretString *string - secretBinary []byte - result map[string]string - err bool - }{ - { - description: "Secret is not map", - secretString: p("not-map"), - err: true, - }, - { - description: "Secret is nested map", - secretString: p(`{"abc": "123", "def": {"ghi": "789"}}`), - err: true, - }, - { - description: "Secret is valid map", - secretString: p(`{"abc": "123", "def": "456"}`), - result: map[string]string{ - "abc": "123", - "def": "456", - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := asmClient{ - api: &mockASMAPI{ - secretBinary: tc.secretBinary, - secretString: tc.secretString, - }, - } - secrets, err := client.GetSecretMap("thesecret") - if tc.err { - assert.Error(t, err) - } - assert.Equal(t, tc.result, secrets) - }) - } -} - -func Test_ASMClient_GetSecretValue(t *testing.T) { - testCases := []struct { - description string - dest string - secretString *string - secretBinary []byte - result []byte - err error - }{ - { - description: "Literal structured secret string value", - secretString: p(`{"abc": "123", "def": "456"}`), - result: []byte(`{"abc": "123", "def": "456"}`), - }, - { - description: "Literal unstructured secret string value", - secretString: p("Ham him compass you proceed calling detract"), - result: []byte("Ham him compass you proceed calling detract"), - }, - { - description: "Literal structured secret binary value", - secretBinary: []byte(`{"abc": "123", "def": "456"}`), - result: []byte(`{"abc": "123", "def": "456"}`), - }, - { - description: "Literal unstructured secret binary value", - secretBinary: []byte("Ham him compass you proceed calling detract"), - result: []byte("Ham him compass you proceed calling detract"), - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := asmClient{ - api: &mockASMAPI{ - secretBinary: tc.secretBinary, - secretString: tc.secretString, - }, - } - value, err := client.GetSecretValue("thesecret") - assert.Equal(t, tc.err, err) - assert.Equal(t, tc.result, value) - }) - } -} diff --git a/pkg/initial/aws/aws.go b/pkg/initial/aws/aws.go deleted file mode 100644 index 07a6c7d..0000000 --- a/pkg/initial/aws/aws.go +++ /dev/null @@ -1,55 +0,0 @@ -package aws - -import ( - "context" - "fmt" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" -) - -type Connection interface { - ASMClient() ASMClient - SSMClient() SSMClient - S3Client() S3Client -} - -type connection struct { - asmClient ASMClient - cfg aws.Config - ssmClient SSMClient - s3Client S3Client -} - -func NewConnection(region string) (*connection, error) { - cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region)) - if err != nil { - return nil, fmt.Errorf("unable to load AWS config: %w", err) - } - return &connection{cfg: cfg}, nil -} - -func (c *connection) ASMClient() ASMClient { - if c.asmClient == nil { - c.asmClient = NewASMClient(c.cfg) - } - return c.asmClient -} - -func (c *connection) SSMClient() SSMClient { - if c.ssmClient == nil { - c.ssmClient = NewSSMClient(c.cfg) - } - return c.ssmClient -} - -func (c *connection) S3Client() S3Client { - if c.s3Client == nil { - c.s3Client = NewS3Client(c.cfg) - } - return c.s3Client -} - -func p[T any](v T) *T { - return &v -} diff --git a/pkg/initial/aws/common_test.go b/pkg/initial/aws/common_test.go deleted file mode 100644 index cfcf321..0000000 --- a/pkg/initial/aws/common_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package aws - -import ( - "io" - "os" - "strings" - - "github.com/spf13/afero" -) - -func stringRC(s string) io.ReadCloser { - return io.NopCloser(strings.NewReader(s)) -} - -type file struct { - name string - content string - mode os.FileMode -} - -func fileRead(fs afero.Fs, path string) (string, os.FileInfo, error) { - stat, err := fs.Stat(path) - if err != nil { - return "", nil, err - } - b, err := afero.ReadFile(fs, path) - if err != nil { - return "", nil, err - } - return string(b), stat, nil -} diff --git a/pkg/initial/aws/metadata.go b/pkg/initial/aws/metadata.go deleted file mode 100644 index 38934c0..0000000 --- a/pkg/initial/aws/metadata.go +++ /dev/null @@ -1,77 +0,0 @@ -package aws - -import ( - "context" - "fmt" - "io" - "log/slog" - - "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" - "github.com/cloudboss/easyto/pkg/initial/vmspec" - yaml "github.com/goccy/go-yaml" -) - -var ( - imdsClient = imds.New(imds.Options{}) -) - -func GetIMDS(path string) (string, error) { - resp, err := imdsClient.GetMetadata(context.Background(), &imds.GetMetadataInput{ - Path: path, - }) - if err != nil { - return "", fmt.Errorf("error getting %s from instance metadata: %w", path, err) - } - - content, err := io.ReadAll(resp.Content) - if err != nil { - return "", fmt.Errorf("error reading the contents of %s: %w", path, err) - } - - return string(content), nil -} - -func GetUserData() (*vmspec.VMSpec, error) { - spec := &vmspec.VMSpec{} - - out, err := imdsClient.GetUserData(context.Background(), &imds.GetUserDataInput{}) - if err != nil { - slog.Warn("Unable to get user data", "error", err) - return spec, nil - } - - err = yaml.NewDecoder(out.Content).Decode(spec) - return spec, err -} - -func GetSSHPubKey() (string, error) { - resp, err := imdsClient.GetMetadata(context.Background(), &imds.GetMetadataInput{ - Path: "public-keys/0/openssh-key", - }) - if err != nil { - return "", fmt.Errorf("error getting SSH public key from metadata: %w", err) - } - - content, err := io.ReadAll(resp.Content) - if err != nil { - return "", fmt.Errorf("error reading SSH public key: %w", err) - } - - return string(content), nil -} - -func GetRegion() (string, error) { - resp, err := imdsClient.GetMetadata(context.Background(), &imds.GetMetadataInput{ - Path: "placement/region", - }) - if err != nil { - return "", fmt.Errorf("error getting region from metadata: %w", err) - } - - content, err := io.ReadAll(resp.Content) - if err != nil { - return "", fmt.Errorf("error reading region: %w", err) - } - - return string(content), nil -} diff --git a/pkg/initial/aws/s3.go b/pkg/initial/aws/s3.go deleted file mode 100644 index ad007af..0000000 --- a/pkg/initial/aws/s3.go +++ /dev/null @@ -1,170 +0,0 @@ -package aws - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/cloudboss/easyto/pkg/initial/collections" -) - -type s3API interface { - GetObject(context.Context, *s3.GetObjectInput, - ...func(*s3.Options)) (*s3.GetObjectOutput, error) - ListObjects(context.Context, *s3.ListObjectsInput, - ...func(*s3.Options)) (*s3.ListObjectsOutput, error) -} - -type S3Client interface { - GetObjectList(bucket, keyPrefix string) (collections.WritableList, error) - GetObjectMap(bucket, keyPrefix string) (map[string]string, error) - GetObjectValue(bucket, keyPrefix string) ([]byte, error) -} - -func NewS3Client(cfg aws.Config) S3Client { - return &s3Client{ - api: s3.NewFromConfig(cfg), - } -} - -type s3Client struct { - api s3API -} - -func (s *s3Client) GetObjectList(bucket, keyPrefix string) (collections.WritableList, error) { - objects, err := s.listObjects(bucket, keyPrefix) - if err != nil { - return nil, err - } - return s.toList(objects, bucket, keyPrefix), nil -} - -func (s *s3Client) GetObjectMap(bucket, key string) (map[string]string, error) { - object, err := s.getObject(bucket, key) - if err != nil { - return nil, err - } - defer object.Body.Close() - m := make(map[string]string) - err = json.NewDecoder(object.Body).Decode(&m) - if err != nil { - s3URL := "s3://" + bucket + "/" + key - return nil, fmt.Errorf("unable to decode map from object at %s: %w", s3URL, err) - } - return m, nil -} - -func (s *s3Client) GetObjectValue(bucket, key string) ([]byte, error) { - object, err := s.getObject(bucket, key) - if err != nil { - return nil, err - } - defer object.Body.Close() - var buf bytes.Buffer - _, err = io.Copy(&buf, object.Body) - if err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func (s *s3Client) getObject(bucket, key string) (*s3.GetObjectOutput, error) { - object, err := s.api.GetObject(context.Background(), &s3.GetObjectInput{ - Bucket: p(bucket), - Key: p(key), - }) - if err != nil { - s3URL := "s3://" + bucket + "/" + key - return nil, fmt.Errorf("unable to get object at %s: %w", s3URL, err) - } - if object.Body == nil { - return nil, fmt.Errorf("object %s has no body", key) - } - return object, nil -} - -func (s *s3Client) listObjects(bucket, keyPrefix string) ([]types.Object, error) { - var ( - objects []types.Object - marker *string - ) - for { - out, err := s.api.ListObjects(context.Background(), &s3.ListObjectsInput{ - Bucket: p(bucket), - Prefix: p(keyPrefix), - Marker: marker, - }) - if err != nil { - s3URL := "s3://" + bucket + "/" + keyPrefix - return nil, fmt.Errorf("unable to list objects at %s: %w", s3URL, err) - } - objects = append(objects, out.Contents...) - if out.IsTruncated == nil || !*out.IsTruncated { - break - } - marker = objects[len(objects)-1].Key - } - return objects, nil -} - -func (s *s3Client) toList(objects []types.Object, bucket, keyPrefix string) collections.WritableList { - list := collections.WritableList{} - for _, object := range objects { - // Skip any objects that are "folders". - if strings.HasSuffix(*object.Key, "/") { - continue - } - if !strings.HasPrefix(*object.Key, keyPrefix) { - continue - } - key := *object.Key - if len(keyPrefix) > 0 { - // If key and keyPrefix are the same, this will result in an empty - // string, which enables the destination to become the filename - // instead of directory when calling the Write method on the returned - // List. This is a special case for retrieving a single object. - fields := strings.Split(key, keyPrefix) - key = fields[1] - } - s3Object := &S3Object{bucket: bucket, api: s.api, key: *object.Key} - listEntry := &collections.WritableListEntry{Path: key, Value: s3Object} - list = append(list, listEntry) - } - return list -} - -type S3Object struct { - api s3API - bucket string - key string - object *s3.GetObjectOutput -} - -func (s *S3Object) Read(p []byte) (n int, err error) { - if s.object == nil { - s.object, err = s.api.GetObject(context.Background(), &s3.GetObjectInput{ - Bucket: &s.bucket, - Key: &s.key, - }) - if err != nil { - return 0, fmt.Errorf("unable to get object %s: %w", s.key, err) - } - } - if s.object.Body == nil { - return 0, fmt.Errorf("object %s has no body", s.key) - } - return s.object.Body.Read(p) -} - -func (s *S3Object) Close() error { - if s.object == nil || s.object.Body == nil { - return nil - } - return s.object.Body.Close() -} diff --git a/pkg/initial/aws/s3_test.go b/pkg/initial/aws/s3_test.go deleted file mode 100644 index 489ee78..0000000 --- a/pkg/initial/aws/s3_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package aws - -import ( - "context" - "errors" - "os" - "testing" - - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -var errObjectNotFound = errors.New("object not found") - -type mockS3API struct { - bucketObjects map[string]string -} - -func (s *mockS3API) GetObject(ctx context.Context, in *s3.GetObjectInput, - opt ...func(*s3.Options)) (*s3.GetObjectOutput, error) { - content, ok := s.bucketObjects[*in.Key] - if !ok { - return nil, errObjectNotFound - } - out := &s3.GetObjectOutput{ - Body: stringRC(content), - } - return out, nil -} - -func (s *mockS3API) ListObjects(ctx context.Context, in *s3.ListObjectsInput, - opt ...func(*s3.Options)) (*s3.ListObjectsOutput, error) { - objects := []types.Object{} - for k := range s.bucketObjects { - objects = append(objects, types.Object{ - Key: p(k), - }) - } - out := &s3.ListObjectsOutput{ - Contents: objects, - } - return out, nil -} - -func Test_S3Client_GetObjectList(t *testing.T) { - testCases := []struct { - description string - bucketObjects map[string]string - dest string - keyPrefix string - result []file - err error - }{ - { - description: "Single object", - bucketObjects: map[string]string{ - "b1/c1": "c1-value", - }, - dest: "/abc", - keyPrefix: "b1", - result: []file{ - { - name: "/abc/c1", - content: "c1-value", - mode: 0644, - }, - }, - }, - { - description: "Nested objects", - bucketObjects: map[string]string{ - "b1/c1": "c1-value", - "b1/d1/e1": "e1-value", - }, - dest: "/abc", - keyPrefix: "b1", - result: []file{ - { - name: "/abc/c1", - content: "c1-value", - mode: 0644, - }, - { - name: "/abc/d1", - mode: 0755 | os.ModeDir, - }, - { - name: "/abc/d1/e1", - content: "e1-value", - mode: 0644, - }, - }, - }, - { - description: "Same key and prefix", - bucketObjects: map[string]string{ - "b1/d1/e1": "e1-value", - }, - dest: "/abc", - keyPrefix: "b1/d1/e1", - result: []file{ - { - name: "/abc", - content: "e1-value", - mode: 0644, - }, - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - fs := afero.NewMemMapFs() - client := s3Client{ - api: &mockS3API{tc.bucketObjects}, - } - m, err := client.GetObjectList("thebucket", tc.keyPrefix) - assert.NoError(t, err) - err = m.Write(fs, tc.dest, 0, 0, false) - assert.NoError(t, err) - for _, file := range tc.result { - contents, stat, err := fileRead(fs, file.name) - assert.NoError(t, err) - assert.Equal(t, string(file.content), contents) - assert.Equal(t, file.mode, stat.Mode()) - } - }) - } -} - -func Test_S3Client_GetObjectMap(t *testing.T) { - testCases := []struct { - description string - bucketObjects map[string]string - key string - result map[string]string - err bool - }{ - { - description: "Object is not map", - bucketObjects: map[string]string{ - "a/b/c": "not-json", - }, - key: "a/b/c", - err: true, - }, - { - description: "Object is nested map", - bucketObjects: map[string]string{ - "a/b": `{"abc": "123", "def": {"ghi": "789"}}`, - }, - key: "a/b", - err: true, - }, - { - description: "Object is valid map", - bucketObjects: map[string]string{ - "a/b": `{"abc": "123", "def": "456"}`, - }, - key: "a/b", - result: map[string]string{ - "abc": "123", - "def": "456", - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := s3Client{ - api: &mockS3API{tc.bucketObjects}, - } - m, err := client.GetObjectMap("thebucket", tc.key) - if tc.err { - assert.Error(t, err) - } - assert.Equal(t, tc.result, m) - }) - } -} - -func Test_S3Client_GetObjectValue(t *testing.T) { - testCases := []struct { - description string - bucketObjects map[string]string - key string - result []byte - err error - }{ - { - description: "Object not found", - bucketObjects: map[string]string{ - "a/b": "Thirty for remove plenty regard you summer though", - }, - key: "x/y/z", - err: errObjectNotFound, - }, - { - description: "Single object", - bucketObjects: map[string]string{ - "a/b": "Thirty for remove plenty regard you summer though", - }, - key: "a/b", - result: []byte("Thirty for remove plenty regard you summer though"), - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := s3Client{ - api: &mockS3API{tc.bucketObjects}, - } - b, err := client.GetObjectValue("thebucket", tc.key) - assert.ErrorIs(t, err, tc.err) - assert.Equal(t, tc.result, b) - }) - } -} diff --git a/pkg/initial/aws/ssm.go b/pkg/initial/aws/ssm.go deleted file mode 100644 index abec1a9..0000000 --- a/pkg/initial/aws/ssm.go +++ /dev/null @@ -1,145 +0,0 @@ -package aws - -import ( - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ssm" - "github.com/aws/aws-sdk-go-v2/service/ssm/types" - "github.com/cloudboss/easyto/pkg/initial/collections" -) - -type ssmAPI interface { - GetParametersByPath(context.Context, *ssm.GetParametersByPathInput, - ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) - GetParameter(context.Context, *ssm.GetParameterInput, - ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) -} - -type SSMClient interface { - GetParameterList(ssmPath string) (collections.WritableList, error) - GetParameterMap(ssmPath string) (map[string]string, error) - GetParameterValue(ssmPath string) ([]byte, error) -} - -type ssmClient struct { - api ssmAPI -} - -func NewSSMClient(cfg aws.Config) SSMClient { - return &ssmClient{ - api: ssm.NewFromConfig(cfg), - } -} - -func (s *ssmClient) GetParameterList(ssmPath string) (collections.WritableList, error) { - parameters, err := s.getParameters(ssmPath) - if err != nil { - return nil, err - } - return s.toList(parameters, ssmPath) -} - -func (s *ssmClient) GetParameterMap(ssmPath string) (map[string]string, error) { - parameter, err := s.getParameter(ssmPath) - if err != nil { - return nil, err - } - var r io.Reader - r = strings.NewReader(*parameter.Value) - m := make(map[string]string) - err = json.NewDecoder(r).Decode(&m) - if err != nil { - err = fmt.Errorf("unable to decode map from parameter %s: %w", ssmPath, err) - return nil, err - } - return m, nil -} - -func (s *ssmClient) GetParameterValue(ssmPath string) ([]byte, error) { - parameter, err := s.getParameter(ssmPath) - if err != nil { - return nil, err - } - return []byte(*parameter.Value), nil -} - -func (s *ssmClient) getParameters(ssmPath string) ([]types.Parameter, error) { - var ( - parameters []types.Parameter - err error - ) - if strings.HasPrefix(ssmPath, "/") { - parameters, err = s.getParametersByPath(ssmPath) - if err != nil { - return nil, err - } - } - if len(parameters) == 0 { - parameter, err := s.getParameter(ssmPath) - if err != nil { - return nil, err - } - parameters = []types.Parameter{*parameter} - } - return parameters, nil -} - -func (s *ssmClient) getParametersByPath(ssmPath string) ([]types.Parameter, error) { - var ( - parameters []types.Parameter - nextToken *string - ) - for { - out, err := s.api.GetParametersByPath(context.Background(), - &ssm.GetParametersByPathInput{ - Path: p(ssmPath), - Recursive: p(true), - WithDecryption: p(true), - NextToken: nextToken, - }) - if err != nil { - return nil, fmt.Errorf("unable to get SSM parameters at path %s: %w", - ssmPath, err) - } - parameters = append(parameters, out.Parameters...) - if out.NextToken == nil { - break - } - nextToken = out.NextToken - } - return parameters, nil -} - -func (s *ssmClient) getParameter(ssmPath string) (*types.Parameter, error) { - out, err := s.api.GetParameter(context.Background(), &ssm.GetParameterInput{ - Name: p(ssmPath), - WithDecryption: p(true), - }) - if err != nil { - return nil, fmt.Errorf("unable to get SSM parameter %s: %w", ssmPath, err) - } - return out.Parameter, nil -} - -func (s *ssmClient) toList(parameters []types.Parameter, ssmPath string) (collections.WritableList, error) { - list := collections.WritableList{} - for _, parameter := range parameters { - if !strings.HasPrefix(*parameter.Name, ssmPath) { - continue - } - name := *parameter.Name - if len(ssmPath) > 0 { - fields := strings.Split(name, ssmPath) - name = fields[1] - } - valueRC := io.NopCloser(strings.NewReader(*parameter.Value)) - listEntry := &collections.WritableListEntry{Path: name, Value: valueRC} - list = append(list, listEntry) - } - return list, nil -} diff --git a/pkg/initial/aws/ssm_test.go b/pkg/initial/aws/ssm_test.go deleted file mode 100644 index b3d5bec..0000000 --- a/pkg/initial/aws/ssm_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package aws - -import ( - "context" - "errors" - "os" - "strings" - "testing" - - "github.com/aws/aws-sdk-go-v2/service/ssm" - "github.com/aws/aws-sdk-go-v2/service/ssm/types" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -var ( - errParameterNotFound = errors.New("parameter not found") -) - -type mockSSMAPI struct { - parameters map[string]string -} - -func (s *mockSSMAPI) GetParametersByPath(ctx context.Context, in *ssm.GetParametersByPathInput, - opt ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) { - parameters := []types.Parameter{} - for k := range s.parameters { - if strings.HasPrefix(k, *in.Path) { - parameters = append(parameters, types.Parameter{ - Name: p(k), - Value: p(s.parameters[k]), - }) - } - } - out := &ssm.GetParametersByPathOutput{ - Parameters: parameters, - } - return out, nil -} - -func (s *mockSSMAPI) GetParameter(ctx context.Context, in *ssm.GetParameterInput, - opt ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { - value, ok := s.parameters[*in.Name] - if !ok { - return nil, errParameterNotFound - } - out := &ssm.GetParameterOutput{ - Parameter: &types.Parameter{ - Name: in.Name, - Value: &value, - }, - } - return out, nil -} - -func Test_SSMClient_GetParameterList(t *testing.T) { - testCases := []struct { - description string - dest string - parameters map[string]string - path string - result []file - secret bool - err error - }{ - { - description: "Null test case", - path: "/zzzzz", - err: errParameterNotFound, - }, - { - description: "Nonsecret parameters", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": "xyz-value", - }, - dest: "/abc", - path: "/easy/to", - result: []file{ - { - name: "/abc/abc", - content: "abc-value", - mode: 0644, - }, - { - name: "/abc/subpath", - mode: 0755 | os.ModeDir, - }, - { - name: "/abc/subpath/abc", - content: "subpath-abc-value", - mode: 0644, - }, - { - name: "/abc/xyz", - content: "xyz-value", - mode: 0644, - }, - }, - }, - { - description: "Secret parameters", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": "xyz-value", - }, - dest: "/abc", - path: "/easy/to", - secret: true, - result: []file{ - { - name: "/abc/abc", - content: "abc-value", - mode: 0600, - }, - { - name: "/abc/subpath", - mode: 0700 | os.ModeDir, - }, - { - name: "/abc/subpath/abc", - content: "subpath-abc-value", - mode: 0600, - }, - { - name: "/abc/xyz", - content: "xyz-value", - mode: 0600, - }, - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - fs := afero.NewMemMapFs() - client := ssmClient{ - api: &mockSSMAPI{tc.parameters}, - } - parameters, err := client.GetParameterList(tc.path) - assert.ErrorIs(t, err, tc.err) - err = parameters.Write(fs, tc.dest, 0, 0, tc.secret) - assert.NoError(t, err) - for _, file := range tc.result { - contents, stat, err := fileRead(fs, file.name) - assert.NoError(t, err) - assert.Equal(t, string(file.content), contents) - assert.Equal(t, file.mode, stat.Mode()) - } - }) - } -} - -func Test_SSMClient_GetParameterMap(t *testing.T) { - testCases := []struct { - description string - parameters map[string]string - path string - result map[string]string - err bool - }{ - { - description: "Parameter not found", - path: "/zzzzz", - err: true, - }, - { - description: "Invalid parameter is not map", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": `"abc": "123", "def": "456"}`, - }, - path: "/easy/to/xyz", - err: true, - }, - { - description: "Valid parameter is map", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": `{"abc": "123", "def": "456"}`, - }, - path: "/easy/to/xyz", - result: map[string]string{ - "abc": "123", - "def": "456", - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := ssmClient{ - api: &mockSSMAPI{tc.parameters}, - } - parameters, err := client.GetParameterMap(tc.path) - if tc.err { - assert.Error(t, err) - } - assert.Equal(t, tc.result, parameters) - }) - } -} - -func Test_SSMClient_GetParameterValue(t *testing.T) { - testCases := []struct { - description string - parameters map[string]string - path string - result []byte - err error - }{ - { - description: "Parameter not found", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": `{"abc": "123", "def": "456"}`, - }, - path: "/easy/to/xyz/123", - err: errParameterNotFound, - }, - { - description: "Literal structured parameter value", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": `{"abc": "123", "def": "456"}`, - }, - path: "/easy/to/xyz", - result: []byte(`{"abc": "123", "def": "456"}`), - }, - { - description: "Literal unstructured parameter value", - parameters: map[string]string{ - "/easy/to/abc": "abc-value", - "/easy/to/subpath/abc": "subpath-abc-value", - "/easy/to/xyz": `"abc": "123", "def": "456"}`, - }, - path: "/easy/to/subpath/abc", - result: []byte("subpath-abc-value"), - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - client := ssmClient{ - api: &mockSSMAPI{ - parameters: tc.parameters, - }, - } - value, err := client.GetParameterValue(tc.path) - assert.ErrorIs(t, err, tc.err) - assert.Equal(t, tc.result, value) - }) - } -} diff --git a/pkg/initial/collections/list.go b/pkg/initial/collections/list.go deleted file mode 100644 index 6fb956b..0000000 --- a/pkg/initial/collections/list.go +++ /dev/null @@ -1,63 +0,0 @@ -package collections - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/cloudboss/easyto/pkg/initial/files" - "github.com/spf13/afero" -) - -type WritableListEntry struct { - Path string - Value io.ReadCloser -} - -type WritableList []*WritableListEntry - -func (w WritableList) Write(fs afero.Fs, dest string, uid, gid int, secret bool) error { - for _, le := range w { - err := le.Write(fs, dest, uid, gid, secret) - if err != nil { - return err - } - } - return nil -} - -func (w WritableListEntry) Write(fs afero.Fs, dest string, uid, gid int, secret bool) error { - modeDir := os.FileMode(0755) - modeFile := os.FileMode(0644) - if secret { - modeDir = os.FileMode(0700) - modeFile = os.FileMode(0600) - } - - finalDest := filepath.Join(dest, w.Path) - destDir := filepath.Dir(finalDest) - err := files.Mkdirs(fs, destDir, uid, gid, modeDir) - if err != nil { - return err - } - - f, err := fs.OpenFile(finalDest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, modeFile) - if err != nil { - return fmt.Errorf("unable to create file %s: %w", finalDest, err) - } - defer f.Close() - - _, err = io.Copy(f, w.Value) - if err != nil { - return fmt.Errorf("unable to write %s: %w", finalDest, err) - } - - err = fs.Chown(finalDest, uid, gid) - if err != nil { - return fmt.Errorf("unable to set permissions on file %s: %w", - finalDest, err) - } - - return nil -} diff --git a/pkg/initial/collections/list_test.go b/pkg/initial/collections/list_test.go deleted file mode 100644 index eff115d..0000000 --- a/pkg/initial/collections/list_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package collections - -import ( - "fmt" - "io" - "os" - "strings" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -func Test_WritableList_Write(t *testing.T) { - testCases := []struct { - description string - dest string - in WritableList - secret bool - result []file - err error - }{ - { - description: "Null test case", - dest: "dest", - in: nil, - }, - { - description: "Empty map", - dest: "dest", - in: WritableList{}, - }, - { - description: "Single string entry", - dest: "/single", - in: WritableList{ - &WritableListEntry{Path: "abc/xyz", Value: stringRC("xyz-1")}, - }, - result: []file{ - { - name: "/single", - mode: 0755 | os.ModeDir, - }, - { - name: "/single/abc/xyz", - content: "xyz-1", - mode: 0644, - }, - }, - }, - { - description: "Multiple string entries", - dest: "/multiple", - in: WritableList{ - &WritableListEntry{Path: "abc/def", Value: stringRC("def-1")}, - &WritableListEntry{Path: "ghi/jkl", Value: stringRC("jkl-1")}, - }, - result: []file{ - { - name: "/multiple", - mode: 0755 | os.ModeDir, - }, - { - name: "/multiple/abc", - mode: 0755 | os.ModeDir, - }, - { - name: "/multiple/abc/def", - content: "def-1", - mode: 0644, - }, - { - name: "/multiple/ghi/jkl", - content: "jkl-1", - mode: 0644, - }, - }, - }, - { - description: "Nested string entries", - dest: "/nested", - in: WritableList{ - &WritableListEntry{Path: "abc/def", Value: stringRC("def-1")}, - &WritableListEntry{Path: "abc/ghi/jkl", Value: stringRC("jkl-1")}, - &WritableListEntry{Path: "abc/ghi/mno", Value: stringRC("mno-1")}, - &WritableListEntry{Path: "abc/ghi/pqr/stu", Value: stringRC("stu-1")}, - }, - result: []file{ - { - name: "/nested", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/ghi", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/ghi/pqr", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/def", - content: "def-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/jkl", - content: "jkl-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/mno", - content: "mno-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/pqr/stu", - content: "stu-1", - mode: 0644, - }, - }, - }, - { - description: "Single ReadCloser entry", - dest: "/single", - in: WritableList{ - &WritableListEntry{Path: "abc/xyz", Value: stringRC("xyz-1")}, - }, - result: []file{ - { - name: "/single", - mode: 0755 | os.ModeDir, - }, - { - name: "/single/abc/xyz", - content: "xyz-1", - mode: 0644, - }, - }, - }, - { - description: "Multiple ReadCloser entries", - dest: "/multiple", - in: WritableList{ - &WritableListEntry{Path: "abc/def", Value: stringRC("def-1")}, - &WritableListEntry{Path: "ghi/jkl", Value: stringRC("jkl-1")}, - }, - result: []file{ - { - name: "/multiple", - mode: 0755 | os.ModeDir, - }, - { - name: "/multiple/abc", - mode: 0755 | os.ModeDir, - }, - { - name: "/multiple/abc/def", - content: "def-1", - mode: 0644, - }, - { - name: "/multiple/ghi/jkl", - content: "jkl-1", - mode: 0644, - }, - }, - }, - { - description: "Nested mixed entries", - dest: "/nested", - in: WritableList{ - &WritableListEntry{Path: "abc/def", Value: stringRC("def-1")}, - &WritableListEntry{Path: "abc/ghi/jkl", Value: stringRC("jkl-1")}, - &WritableListEntry{Path: "abc/ghi/mno", Value: stringRC("mno-1")}, - &WritableListEntry{Path: "abc/ghi/pqr/stu", Value: stringRC("stu-1")}, - &WritableListEntry{Path: "abc/ghi/pqr/vwx", Value: stringRC("vwx-1")}, - }, - result: []file{ - { - name: "/nested", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/ghi", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/ghi/pqr", - mode: 0755 | os.ModeDir, - }, - { - name: "/nested/abc/def", - content: "def-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/jkl", - content: "jkl-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/mno", - content: "mno-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/pqr/stu", - content: "stu-1", - mode: 0644, - }, - { - name: "/nested/abc/ghi/pqr/vwx", - content: "vwx-1", - mode: 0644, - }, - }, - }, - { - description: "Nested mixed secret entries", - dest: "/nested", - in: WritableList{ - &WritableListEntry{Path: "abc/def", Value: stringRC("def-1")}, - &WritableListEntry{Path: "abc/ghi/jkl", Value: stringRC("jkl-1")}, - &WritableListEntry{Path: "abc/ghi/mno", Value: stringRC("mno-1")}, - &WritableListEntry{Path: "abc/ghi/pqr/stu", Value: stringRC("stu-1")}, - &WritableListEntry{Path: "abc/ghi/pqr/vwx", Value: stringRC("vwx-1")}, - }, - secret: true, - result: []file{ - { - name: "/nested", - mode: 0700 | os.ModeDir, - }, - { - name: "/nested/abc", - mode: 0700 | os.ModeDir, - }, - { - name: "/nested/abc/ghi", - mode: 0700 | os.ModeDir, - }, - { - name: "/nested/abc/ghi/pqr", - mode: 0700 | os.ModeDir, - }, - { - name: "/nested/abc/def", - content: "def-1", - mode: 0600, - }, - { - name: "/nested/abc/ghi/jkl", - content: "jkl-1", - mode: 0600, - }, - { - name: "/nested/abc/ghi/mno", - content: "mno-1", - mode: 0600, - }, - { - name: "/nested/abc/ghi/pqr/stu", - content: "stu-1", - mode: 0600, - }, - { - name: "/nested/abc/ghi/pqr/vwx", - content: "vwx-1", - mode: 0600, - }, - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - fs := afero.NewMemMapFs() - err := tc.in.Write(fs, tc.dest, 123, 456, tc.secret) - assert.Equal(t, tc.err, err) - for _, file := range tc.result { - contents, stat, err := fileRead(fs, file.name) - assert.NoError(t, err) - assert.Equal(t, string(file.content), contents) - assert.Equal(t, file.mode, stat.Mode()) - } - }) - } -} - -type file struct { - name string - content string - mode os.FileMode -} - -func fileRead(fs afero.Fs, path string) (string, os.FileInfo, error) { - stat, err := fs.Stat(path) - if err != nil { - return "", nil, fmt.Errorf("unable to stat file %s: %w", path, err) - } - b, err := afero.ReadFile(fs, path) - if err != nil { - return "", nil, fmt.Errorf("unable to read file %s: %w", path, err) - } - return string(b), stat, nil -} - -func stringRC(s string) io.ReadCloser { - return io.NopCloser(strings.NewReader(s)) -} - -func p[T any](v T) *T { - return &v -} diff --git a/pkg/initial/files/files.go b/pkg/initial/files/files.go deleted file mode 100644 index 6404e6a..0000000 --- a/pkg/initial/files/files.go +++ /dev/null @@ -1,46 +0,0 @@ -package files - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/afero" -) - -// DescendingDirs returns an array of directory names where each subsequent -// name in the array is one level deeper than the previous. -func DescendingDirs(dir string) []string { - return descendingDirs(dir, "") -} - -func descendingDirs(dir, acc string) []string { - if len(dir) == 0 { - return []string{} - } - dirs := strings.Split(dir, string(os.PathSeparator)) - if len(dirs[0]) == 0 { - // dir is an absolute path. - dirs[0] = string(os.PathSeparator) - } - newAcc := filepath.Join(acc, dirs[0]) - return append([]string{newAcc}, descendingDirs(filepath.Join(dirs[1:]...), newAcc)...) -} - -func Mkdirs(fs afero.Fs, dir string, uid, gid int, mode os.FileMode) error { - for _, d := range DescendingDirs(dir) { - err := fs.Mkdir(d, mode) - if !(err == nil || os.IsExist(err)) { - return fmt.Errorf("unable to create directory %s: %w", d, err) - } - if os.IsExist(err) { - continue - } - err = fs.Chown(d, uid, gid) - if err != nil { - return fmt.Errorf("unable to set permissions on directory %s: %w", d, err) - } - } - return nil -} diff --git a/pkg/initial/files/files_test.go b/pkg/initial/files/files_test.go deleted file mode 100644 index 14b9cf7..0000000 --- a/pkg/initial/files/files_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package files - -import ( - "fmt" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -func Test_DescendingDirs(t *testing.T) { - testCases := []struct { - dir string - result []string - }{ - { - dir: "", - result: []string{}, - }, - { - dir: "abc", - result: []string{"abc"}, - }, - { - dir: "abc/xyz", - result: []string{"abc", "abc/xyz"}, - }, - { - dir: "abc/xyz/zzz", - result: []string{"abc", "abc/xyz", "abc/xyz/zzz"}, - }, - { - dir: "abc///xyz/////zzz", - result: []string{"abc", "abc/xyz", "abc/xyz/zzz"}, - }, - { - dir: "/", - result: []string{"/"}, - }, - { - dir: "////", - result: []string{"/"}, - }, - { - dir: "/abc/xyz/zzz", - result: []string{"/", "/abc", "/abc/xyz", "/abc/xyz/zzz"}, - }, - { - dir: "/abc/////xyz///zzz", - result: []string{"/", "/abc", "/abc/xyz", "/abc/xyz/zzz"}, - }, - } - for _, tc := range testCases { - actual := DescendingDirs(tc.dir) - assert.ElementsMatch(t, tc.result, actual) - } -} - -func Test_Mkdirs(t *testing.T) { - testCases := []struct { - dir string - err error - result []string - }{ - { - dir: "", - err: nil, - result: []string{}, - }, - { - dir: "/", - err: nil, - result: []string{"/"}, - }, - { - dir: "//", - err: nil, - result: []string{"/"}, - }, - { - dir: "/aaa/zzz/bbb/yyy", - err: nil, - result: []string{ - "/", - "/aaa", - "/aaa/zzz", - "/aaa/zzz/bbb", - "/aaa/zzz/bbb/yyy", - }, - }, - { - dir: "/aaa///zzz//bbb/////yyy", - err: nil, - result: []string{ - "/", - "/aaa", - "/aaa/zzz", - "/aaa/zzz/bbb", - "/aaa/zzz/bbb/yyy", - }, - }, - } - for _, tc := range testCases { - fs := afero.NewMemMapFs() - err := Mkdirs(fs, tc.dir, 0, 0, 755) - assert.Equal(t, tc.err, err) - for _, dir := range tc.result { - t.Run(fmt.Sprintf("directory %s", dir), func(t *testing.T) { - dirExists, err := afero.DirExists(fs, dir) - assert.Nil(t, err, "error was not nil: %s", err) - assert.True(t, dirExists, "directory %s does not exist", dir) - }) - } - } -} - -func ExampleDescendingDirs() { - fmt.Println(DescendingDirs("abc/123/000/343")) - // Output: [abc abc/123 abc/123/000 abc/123/000/343] -} diff --git a/pkg/initial/initial/device.go b/pkg/initial/initial/device.go deleted file mode 100644 index d77cc63..0000000 --- a/pkg/initial/initial/device.go +++ /dev/null @@ -1,300 +0,0 @@ -package initial - -import ( - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "unsafe" - - "github.com/cloudboss/easyto/pkg/constants" - diskfs "github.com/diskfs/go-diskfs" - "github.com/diskfs/go-diskfs/disk" - "github.com/diskfs/go-diskfs/partition/gpt" - "github.com/mvisonneau/go-ebsnvme/pkg/ebsnvme" - "golang.org/x/sys/unix" -) - -// linkEBSDevices creates symlinks for user-defined EBS device names such as /dev/sdf -// to the real underlying device if it differs. This is needed for NVME devices. -func linkEBSDevices() error { - dirs, err := os.ReadDir("/sys/block") - if err != nil { - return fmt.Errorf("unable to get entries in /sys/block: %w", err) - } - for _, dir := range dirs { - deviceName := dir.Name() - devicePath := filepath.Join("/dev", deviceName) - deviceInfo, err := ebsnvme.ScanDevice(devicePath) - - if err != nil { - // Skip any block devices that are not EBS volumes. The - // ebsnvme.ScanDevice() function returns fmt.Errorf() errors rather - // than custom error types, so check the contents of the strings - // here. Any updates to the version of that dependency should ensure - // that the error messages continue to return "AWS EBS" within them. - if strings.Contains(err.Error(), "AWS EBS") { - continue - } - return fmt.Errorf("unable to scan device %s: %w", devicePath, err) - } - - deviceLinkPath := deviceInfo.Name - if !strings.HasPrefix(deviceLinkPath, "/") { - deviceLinkPath = filepath.Join("/dev", deviceLinkPath) - } - - err = os.Symlink(deviceName, deviceLinkPath) - if !(err == nil || os.IsExist(err)) { - return fmt.Errorf("unable to create link %s: %w", deviceLinkPath, err) - } - - // Link partitions too if they exist. - partitions, err := diskPartitions(deviceName) - if err != nil { - return fmt.Errorf("unable to get partitions for device %s: %w", deviceName, err) - } - for _, partition := range partitions { - partitionSuffix := partition.partition - if deviceHasNumericSuffix(deviceInfo.Name) { - partitionSuffix = "p" + partitionSuffix - } - partitionLinkPath := deviceLinkPath + partitionSuffix - err = os.Symlink(partition.device, partitionLinkPath) - if !(err == nil || os.IsExist(err)) { - return fmt.Errorf("unable to create link %s: %w", partitionLinkPath, err) - } - } - } - return nil -} - -type partitionInfo struct { - device string - partition string -} - -func diskPartitions(device string) ([]partitionInfo, error) { - partitions := []partitionInfo{} - - deviceDir := filepath.Join("/sys/block", device) - entries, err := os.ReadDir(deviceDir) - if err != nil { - return nil, fmt.Errorf("unable to read directory %s: %w", deviceDir, err) - } - - for _, entry := range entries { - deviceName := entry.Name() - // Look for partition subdirectories, e.g. /sys/block/nvme0n1/nvme0n1*/. - if entry.IsDir() && strings.HasPrefix(deviceName, device) { - partitionFile := filepath.Join(deviceDir, deviceName, "partition") - contents, err := os.ReadFile(partitionFile) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return nil, fmt.Errorf("unable to read file %s: %w", partitionFile, err) - } - partitions = append(partitions, partitionInfo{ - device: deviceName, - partition: strings.TrimSpace(string(contents)), - }) - } - } - return partitions, nil -} - -func deviceHasNumericSuffix(device string) bool { - return len(device) > 0 && device[len(device)-1] >= '0' && device[len(device)-1] <= '9' -} - -func deviceHasFS(devicePath string) (bool, error) { - cmd := blkid(devicePath) - err := cmd.Run() - switch cmd.ProcessState.ExitCode() { - case 0: - return true, nil - case 2: - return false, nil - default: - return false, err - } -} - -func resizeRootVolume() error { - rootDisk, rootPartition, err := findRootDevice() - if err != nil { - return fmt.Errorf("unable to find root device: %w", err) - } - - err = resizeRootPartition(rootDisk, rootPartition) - if err != nil { - return err - } - - return growFilesystem(rootPartition) -} - -// findRootDevice returns the disk device and partition device for the root partition. -func findRootDevice() (string, string, error) { - cmd := blkid("-t", "PARTLABEL=root", "-o", "device") - out, err := cmd.Output() - if err != nil { - return "", "", fmt.Errorf("unable to find partition with root label: %w", err) - } - - rootPartition := strings.TrimSpace(string(out)) - dir, rootPartitionFile := filepath.Split(rootPartition) - if dir != "/dev/" { - return "", "", fmt.Errorf("unexpected blkid output trying to find root partition: %s", rootPartition) - } - - blockEntries, err := os.ReadDir("/sys/block") - if err != nil { - return "", "", fmt.Errorf("unable to read /sys/block: %w", err) - } - - for _, entry := range blockEntries { - entryName := entry.Name() - statPath := filepath.Join("/sys/block", entryName, rootPartitionFile) - _, err := os.Stat(statPath) - if err != nil { - if os.IsNotExist(err) { - continue - } - return "", "", fmt.Errorf("unable to stat %s: %w", rootPartitionFile, err) - } - rootDisk := filepath.Join("/dev", entryName) - return rootDisk, rootPartition, nil - } - - return "", "", fmt.Errorf("unable to find root device") -} - -func resizeRootPartition(rootDiskDevice, rootPartitionDevice string) error { - disk, err := diskfs.Open(rootDiskDevice, diskfs.WithOpenMode(diskfs.ReadWrite)) - if err != nil { - return fmt.Errorf("unable to open device %s: %w", rootDiskDevice, err) - } - - table, err := disk.GetPartitionTable() - if err != nil { - return fmt.Errorf("unable to get partition table for device %s: %w", rootDiskDevice, err) - } - - gptTable, ok := table.(*gpt.Table) - if !ok { - return fmt.Errorf("device %s does not have a GPT partition table", rootDiskDevice) - } - - const expectedPartitions = 2 - - // The image should have an EFI boot partition and a root partition. - if len(gptTable.Partitions) != expectedPartitions { - return fmt.Errorf("expected %d partitions, got %d", expectedPartitions, len(gptTable.Partitions)) - } - - // The last partition should be the root partition. - rootPartition := gptTable.Partitions[len(gptTable.Partitions)-1] - if rootPartition.Name != "root" { - return fmt.Errorf("expected a partition named 'root', got '%s'", rootPartition.Name) - } - - const gptHeaderSectors = 1 - const gptPartitionEntrySectors = 32 - const gptSectors = gptHeaderSectors + gptPartitionEntrySectors - lastDataSector := disk.Size/int64(disk.LogicalBlocksize) - gptSectors - 1 - - if int64(rootPartition.End) < lastDataSector { - slog.Info("extending root partition", "last-partition-sector", rootPartition.End, - "last-available-sector", lastDataSector) - - rootPartition.End = uint64(lastDataSector) - rootPartition.Size = (rootPartition.End - rootPartition.Start + 1) * uint64(disk.LogicalBlocksize) - - // The Repair method resets the locations of the secondary GPT - // header and partition entries to the end of the disk. - err = gptTable.Repair(uint64(disk.Size)) - if err != nil { - return fmt.Errorf("unable to reset end of partition table: %w", err) - } - - // Rewrite the GPT table on disk. - err = disk.Partition(gptTable) - if err != nil { - // The diskfs library uses the BLKRRPART ioctl to re-read the - // partition table during a call to Partition, but if the disk - // is mounted, it fails because the device is busy. When that - // error is returned, ignore it. We'll call rereadPartition() - // that uses the BLKPG ioctl instead. - if !strings.Contains(err.Error(), "device or resource busy") { - return fmt.Errorf("unable to resize root partition: %w", err) - } - } - - err = rereadPartition(disk, rootPartition, rootPartitionDevice, expectedPartitions) - if err != nil { - return fmt.Errorf("unable to re-read partition after resizing: %w", err) - } - - slog.Info("root partition extended") - } - - return nil -} - -// Use the BLKPG ioctl to re-read a partition after resizing. -func rereadPartition(disk *disk.Disk, partition *gpt.Partition, devicePath string, num int) error { - const blkpgNameLen = 64 - - volname := [blkpgNameLen]uint8{} - for i, b := range []byte(partition.Name) { - volname[i] = uint8(b) - } - - devname := [blkpgNameLen]uint8{} - for i, b := range []byte(devicePath) { - devname[i] = uint8(b) - } - - bp := unix.BlkpgPartition{ - Start: int64(partition.Start) * disk.LogicalBlocksize, - Length: int64(partition.Size), - Pno: int32(num), - Devname: devname, - Volname: volname, - } - - arg := unix.BlkpgIoctlArg{ - Op: unix.BLKPG_RESIZE_PARTITION, - Datalen: int32(unsafe.Sizeof(unix.BlkpgPartition{})), - Data: (*byte)(unsafe.Pointer(&bp)), - } - - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(disk.File.Fd()), uintptr(unix.BLKPG), - uintptr(unsafe.Pointer(&arg))) - if errno != 0 { - return syscall.Errno(errno) - } - - return nil -} - -func growFilesystem(devicePath string) error { - resize2fsPath := filepath.Join(constants.DirETSbin, "resize2fs") - cmd := exec.Command(resize2fsPath, devicePath) - err := cmd.Run() - if err != nil { - return fmt.Errorf("unable to resize filesystem: %w", err) - } - return nil -} - -func blkid(args ...string) *exec.Cmd { - blkidPath := filepath.Join(constants.DirETSbin, "blkid") - return exec.Command(blkidPath, args...) -} diff --git a/pkg/initial/initial/initial.go b/pkg/initial/initial/initial.go deleted file mode 100644 index 1b70e26..0000000 --- a/pkg/initial/initial/initial.go +++ /dev/null @@ -1,1050 +0,0 @@ -package initial - -import ( - "bufio" - _ "crypto/sha256" // For JSON decoder. - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/initial/aws" - "github.com/cloudboss/easyto/pkg/initial/service" - "github.com/cloudboss/easyto/pkg/initial/vmspec" - "github.com/cloudboss/easyto/third_party/forked/golang/expansion" - "github.com/google/go-containerregistry/pkg/v1" - "github.com/spf13/afero" - "golang.org/x/sys/unix" -) - -const ( - fileCACerts = "amazon.pem" - fileMounts = constants.DirProc + "/mounts" - execBits = 0111 -) - -type link struct { - target string - path string -} - -type mount struct { - source string - flags uintptr - fsType string - mode os.FileMode - options []string - target string -} - -func mounts() error { - ms := []mount{ - { - source: "devtmpfs", - flags: syscall.MS_NOSUID, - fsType: "devtmpfs", - mode: 0755, - target: "/dev", - }, - { - source: "devpts", - flags: syscall.MS_NOATIME | syscall.MS_NOEXEC | syscall.MS_NOSUID, - fsType: "devpts", - mode: 0755, - options: []string{ - "mode=0620", - "gid=5", - "ptmxmode=666", - }, - target: "/dev/pts", - }, - { - source: "mqueue", - flags: syscall.MS_NODEV | syscall.MS_NOEXEC | syscall.MS_NOSUID, - fsType: "mqueue", - mode: 0755, - target: "/dev/mqueue", - }, - { - source: "tmpfs", - flags: syscall.MS_NODEV | syscall.MS_NOSUID, - fsType: "tmpfs", - mode: 0777 | fs.ModeSticky, - target: "/dev/shm", - }, - { - source: "hugetlbfs", - flags: syscall.MS_RELATIME, - fsType: "hugetlbfs", - mode: 0755, - target: "/dev/hugepages", - }, - { - source: "proc", - flags: syscall.MS_NODEV | syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_RELATIME, - fsType: "proc", - mode: 0555, - target: constants.DirProc, - }, - { - source: "sys", - flags: syscall.MS_NODEV | syscall.MS_NOEXEC | syscall.MS_NOSUID, - fsType: "sysfs", - mode: 0555, - target: "/sys", - }, - { - source: "tmpfs", - flags: syscall.MS_NODEV | syscall.MS_NOSUID, - fsType: "tmpfs", - mode: 0755, - options: []string{ - "mode=0755", - }, - target: constants.DirETRun, - }, - { - source: "cgroup2", - flags: syscall.MS_NODEV | syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_RELATIME, - fsType: "cgroup2", - options: []string{ - "nsdelegate", - }, - target: "/sys/fs/cgroup", - }, - { - source: "nodev", - fsType: "debugfs", - mode: 0500, - target: "/sys/kernel/debug", - }, - } - - // Temporarily unset umask to ensure directory modes are exactly as configured. - oldUmask := syscall.Umask(0) - defer func() { - syscall.Umask(oldUmask) - }() - - for _, m := range ms { - slog.Debug("About to process mount", "mount", m) - _, err := os.Stat(m.target) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("unexpected error checking status of %s: %w", m.target, err) - } - slog.Debug("About to make directory", "directory", m.target, "mode", m.mode) - err := os.MkdirAll(m.target, m.mode) - if err != nil { - return fmt.Errorf("unable to create directory %s: %w", m.target, err) - } - } - skipMount := len(m.fsType) == 0 - if skipMount { - continue - } - err = unix.Mount(m.source, m.target, m.fsType, m.flags, strings.Join(m.options, ",")) - if err != nil { - return fmt.Errorf("unable to mount %s on %s: %w", m.source, m.target, err) - } - } - return nil -} - -func links() error { - ls := []link{ - { - target: filepath.Join(constants.DirProc, "self/fd"), - path: "/dev/fd", - }, - { - target: filepath.Join(constants.DirProc, "self/fd/0"), - path: "/dev/stdin", - }, - { - target: filepath.Join(constants.DirProc, "self/fd/1"), - path: "/dev/stdout", - }, - { - target: filepath.Join(constants.DirProc, "self/fd/2"), - path: "/dev/stderr", - }, - } - for _, l := range ls { - err := os.Symlink(l.target, l.path) - if err != nil { - return fmt.Errorf("unable to symlink %s to %s: %w", l.path, l.target, err) - } - } - return nil -} - -func debug() { - commands := [][]string{ - { - "/bin/lsmod", - }, - { - "/bin/mount", - }, - { - "/bin/ps", - "-ef", - }, - { - "/bin/ls", - "-l", - constants.DirRoot, - }, - { - "/bin/ls", - "-l", - filepath.Join(constants.DirRoot, "dev"), - }, - } - for _, command := range commands { - args := []string{} - if len(command) > 0 { - args = command[1:] - } - err := runCommand(command[0], args...) - if err != nil { - fmt.Fprintf(os.Stderr, "Error running '%s': %s\n", - strings.Join(command, " "), err) - } - } - -} - -func runCommand(executable string, args ...string) error { - return runCommandWithEnv(executable, nil, args...) -} - -func runCommandWithEnv(executable string, env []string, args ...string) error { - cmd := exec.Command(executable, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = env - return cmd.Run() -} - -// getenv gets the value of an environment variable from the environment -// passed in env, rather than the process's environment as with os.Getenv. -func getenv(env []string, key string) string { - for _, envVar := range env { - fields := strings.Split(envVar, "=") - if fields[0] == key { - return strings.Join(fields[1:], "=") - } - } - return "" -} - -func findExecutableInPath(executable, pathEnv string) (string, error) { - for _, dir := range filepath.SplitList(pathEnv) { - findPath := filepath.Join(constants.DirRoot, dir, executable) - fi, err := os.Stat(findPath) - if err != nil { - continue - } - if fi.Mode()&execBits != 0 { - return filepath.Join(dir, executable), nil - } - } - return "", fmt.Errorf("executable %s not found", executable) -} - -func readMetadata(metadataPath string) (*v1.ConfigFile, error) { - f, err := os.Open(metadataPath) - if err != nil { - return nil, fmt.Errorf("unable to open %s: %w", metadataPath, err) - } - defer f.Close() - - metadata := &v1.ConfigFile{} - err = json.NewDecoder(f).Decode(metadata) - if err != nil { - return nil, fmt.Errorf("unable to decode metadata: %w", err) - } - - return metadata, nil -} - -func fullCommand(spec *vmspec.VMSpec, env vmspec.NameValueSource) ([]string, error) { - exe := append(spec.Command, spec.Args...) - if exe == nil { - exe = []string{"/bin/sh"} - } - - pathEnv, _ := spec.Env.Find("PATH") - - if !strings.HasPrefix(exe[0], constants.DirRoot) { - executablePath, err := findExecutableInPath(exe[0], pathEnv) - if err != nil { - return nil, err - } - exe[0] = executablePath - } - - // Expand $(VAR) references from the environment. - resolvedExe := make([]string, len(exe)) - mapping := expansion.MappingFuncFor(env.ToMap()) - for i, arg := range exe { - resolvedExe[i] = expansion.Expand(arg, mapping) - } - - return resolvedExe, nil -} - -// envToEnv converts an array of "key=value" strings to a NameValueSource. -func envToEnv(envVars []string) (vmspec.NameValueSource, error) { - source := make(vmspec.NameValueSource, len(envVars)) - for i, envVar := range envVars { - fields := strings.Split(envVar, "=") - if len(fields) < 1 { - return nil, fmt.Errorf("invalid environment variable '%s'", envVar) - } - source[i].Name = fields[0] - if len(fields) > 1 { - source[i].Value = strings.Join(fields[1:], "=") - } - } - return source, nil -} - -func metadataToVMSpec(metadata *v1.ConfigFile) (*vmspec.VMSpec, error) { - spec := &vmspec.VMSpec{ - Command: metadata.Config.Entrypoint, - Args: metadata.Config.Cmd, - ShutdownGracePeriod: 10, - Security: vmspec.SecurityContext{}, - } - - env, err := envToEnv(metadata.Config.Env) - if err != nil { - return nil, err - } - spec.Env = env - - spec.WorkingDir = metadata.Config.WorkingDir - if len(spec.WorkingDir) == 0 { - spec.WorkingDir = constants.DirRoot - } - - uid, gid, err := getUserGroup(metadata.Config.User) - if err != nil { - return nil, err - } - spec.Security.RunAsUserID = &uid - spec.Security.RunAsGroupID = &gid - - return spec, nil -} - -// entryID parses entryFile in the format of /etc/passwd or /etc/group to get -// the numeric ID for the given entry. The entryFile has fields delimited by `:` -// characters; the first field is the entry (user or group name as a string), and -// the third field is its numeric ID. Additional fields are ignored, so it is able -// to parse /etc/passwd and /etc/group, although their number of fields differ. -// The function returns the numeric ID or an error if it is not found. -func entryID(entryFile, entry string) (int, error) { - id := 0 - - p, err := os.Open(entryFile) - if err != nil { - return 0, fmt.Errorf("unable to open %s: %w", entryFile, err) - } - defer p.Close() - - entryFound := false - pScanner := bufio.NewScanner(p) - for pScanner.Scan() { - line := pScanner.Text() - fields := strings.Split(line, ":") - if fields[0] == entry { - entryFound = true - id, err = strconv.Atoi(fields[2]) - if err != nil { - return 0, fmt.Errorf("unexpected error reading %s: %w", - entryFile, err) - } - break - } - } - if err = pScanner.Err(); err != nil { - return 0, fmt.Errorf("unable to read %s: %w", entryFile, err) - } - if !entryFound { - return 0, fmt.Errorf("%s not found in %s", entry, entryFile) - } - - return id, nil -} - -func getUserGroup(userEntry string) (int, int, error) { - var ( - user string - group string - uid int - gid int - err error - ) - - userEntryFields := strings.Split(userEntry, ":") - if len(userEntryFields) == 1 { - user = userEntryFields[0] - } - - if len(user) == 0 || user == "root" { - return 0, 0, nil - } - - if len(userEntryFields) == 2 { - group = userEntryFields[1] - } - - uid, err = entryID(constants.FileEtcPasswd, user) - if err != nil { - return 0, 0, err - } - - if len(group) == 0 || group == "root" { - return uid, gid, nil - } - - gid, err = entryID(constants.FileEtcGroup, group) - if err != nil { - return 0, 0, err - } - - return uid, gid, nil -} - -func parseMode(mode string) (fs.FileMode, error) { - if len(mode) == 0 { - return 0755, nil - } - n, err := strconv.ParseInt(mode, 8, 0) - if err != nil { - return 0, fmt.Errorf("invalid mode %s", mode) - } - if n < 0 { - return 0, fmt.Errorf("invalid mode %s", mode) - } - return fs.FileMode(n), nil -} - -func handleVolumeEBS(volume *vmspec.EBSVolumeSource, index int) error { - slog.Debug("Handling volume", "volume", volume) - - if len(volume.Device) == 0 { - return errors.New("volume must have device") - } - - if len(volume.FSType) == 0 { - return errors.New("volume must have filesystem type") - } - - if len(volume.Mount.Destination) == 0 { - return errors.New("volume must have mount point") - } - - mode, err := parseMode(volume.Mount.Mode) - if err != nil { - return err - } - slog.Debug("Parsed mode", "before", volume.Mount.Mode, "mode", mode) - - err = os.MkdirAll(volume.Mount.Destination, mode) - if err != nil { - return fmt.Errorf("unable to create mount point %s: %w", - volume.Mount.Destination, err) - } - slog.Debug("Created mount point", "destination", volume.Mount.Destination) - - err = os.Chown(volume.Mount.Destination, *volume.Mount.UserID, *volume.Mount.GroupID) - if err != nil { - return fmt.Errorf("unable to change ownership of mount point: %w", err) - } - slog.Debug("Changed ownership of mount point", "destination", volume.Mount.Destination) - - hasFS, err := deviceHasFS(volume.Device) - if err != nil { - return fmt.Errorf("unable to determine if %s has a filesystem: %w", volume.Device, err) - } - if !hasFS { - mkfsPath := filepath.Join(constants.DirETSbin, "mkfs."+volume.FSType) - if _, err := os.Stat(mkfsPath); os.IsNotExist(err) { - return fmt.Errorf("unsupported filesystem type %s for volume at index %d", - volume.FSType, index) - } - err = runCommand(mkfsPath, volume.Device) - if err != nil { - return fmt.Errorf("unable to create filesystem on %s: %w", volume.Device, err) - } - slog.Debug("Created filesystem", "device", volume.Device, "fstype", volume.FSType) - } - - err = unix.Mount(volume.Device, volume.Mount.Destination, volume.FSType, 0, "") - if err != nil { - return fmt.Errorf("unable to mount %s on %s: %w", volume.Mount.Destination, - volume.FSType, err) - } - slog.Debug("Mounted volume", "device", volume.Device, "destination", volume.Mount.Destination) - - return nil -} - -func handleVolumeSSM(fs afero.Fs, volume *vmspec.SSMVolumeSource, conn aws.Connection) error { - parameters, err := conn.SSMClient().GetParameterList(volume.Path) - if !(err == nil || volume.Optional) { - return err - } - if err == nil { - return parameters.Write(fs, volume.Mount.Destination, *volume.Mount.UserID, - *volume.Mount.GroupID, true) - } - return nil -} - -func handleVolumeSecretsManager(fs afero.Fs, volume *vmspec.SecretsManagerVolumeSource, conn aws.Connection) error { - secret, err := conn.ASMClient().GetSecretList(volume.SecretID) - if !(err == nil || volume.Optional) { - return err - } - if err == nil { - return secret.Write(fs, volume.Mount.Destination, *volume.Mount.UserID, - *volume.Mount.GroupID, true) - } - return nil -} - -func handleVolumeS3(fs afero.Fs, volume *vmspec.S3VolumeSource, conn aws.Connection) error { - s3Client := conn.S3Client() - objects, err := s3Client.GetObjectList(volume.Bucket, volume.KeyPrefix) - if !(err == nil || volume.Optional) { - return err - } - if err == nil { - return objects.Write(fs, volume.Mount.Destination, *volume.Mount.UserID, - *volume.Mount.GroupID, false) - } - return nil -} - -func replaceInit(spec *vmspec.VMSpec, command []string, env []string, readonlyRootFS bool) error { - err := os.Chdir(spec.WorkingDir) - if err != nil { - return fmt.Errorf("unable to change working directory to %s: %w", - spec.WorkingDir, err) - } - - err = syscall.Setgid(*spec.Security.RunAsGroupID) - if err != nil { - return fmt.Errorf("unable to set GID: %w", err) - } - - err = syscall.Setuid(*spec.Security.RunAsUserID) - if err != nil { - return fmt.Errorf("unable to set UID: %w", err) - } - - if readonlyRootFS { - err = unix.Mount("", constants.DirRoot, "", syscall.MS_REMOUNT|syscall.MS_RDONLY, "") - if err != nil { - return fmt.Errorf("unable to remount root as readonly: %w", err) - } - } - - return syscall.Exec(command[0], command, env) -} - -func supervise(fs afero.Fs, spec *vmspec.VMSpec, command []string, env []string, readonlyRootFS bool) error { - err := disableServices(fs, spec.DisableServices) - if err != nil { - return err - } - - supervisor := &service.Supervisor{ - Main: service.NewMainService( - command, - env, - spec.WorkingDir, - uint32(*spec.Security.RunAsGroupID), - uint32(*spec.Security.RunAsUserID), - ), - ReadonlyRootFS: readonlyRootFS, - Timeout: time.Duration(spec.ShutdownGracePeriod) * time.Second, - } - err = supervisor.Start() - if err != nil { - return fmt.Errorf("unable to start supervisor: %w", err) - } - - waitForShutdown(fs, spec, supervisor) - return nil -} - -// disableServices removes services files from the image that are not disabled in the spec. -func disableServices(fs afero.Fs, specServices []string) error { - serviceFiles, err := afero.ReadDir(fs, constants.DirETServices) - if !(err == nil || errors.Is(err, os.ErrNotExist)) { - return fmt.Errorf("unable to read directory %s: %w", constants.DirETServices, err) - } - for _, serviceFile := range serviceFiles { - amiService := serviceFile.Name() - found := false - for _, specService := range specServices { - if specService == amiService { - found = true - break - } - } - if found { - slog.Debug("Disabling service", "service", amiService) - err := fs.Remove(filepath.Join(constants.DirETServices, amiService)) - if err != nil { - return fmt.Errorf("unable to disable service %s: %w", amiService, err) - } - } - } - return nil -} - -func waitForShutdown(fs afero.Fs, spec *vmspec.VMSpec, supervisor *service.Supervisor) { - supervisor.Wait() - - mountPoints := spec.Volumes.MountPoints() - - err := unmountAll(mountPoints) - if err != nil { - slog.Error("Error unmounting volumes", "error", err) - } - - // Best-effort wait, even if there were unmount errors. This can be improved - // so it doesn't wait unnecessarily if no calls to unmount succeeded. - waitForUnmounts(fs, fileMounts, mountPoints, 10*time.Second) -} - -// unmountAll remounts / as readonly and lazily unmounts all the volumes in the list of mount points. -func unmountAll(mountPoints []string) error { - var errs error - - err := unix.Mount("", constants.DirRoot, "", syscall.MS_REMOUNT|syscall.MS_RDONLY, "") - if err != nil { - errs = errors.Join(errs, fmt.Errorf("unable to remount / as read-only: %w", err)) - } - - for _, mountPoint := range mountPoints { - err := syscall.Unmount(mountPoint, syscall.MNT_DETACH) - if err != nil { - errs = errors.Join(errs, fmt.Errorf("unable to unmount %s: %w", mountPoint, err)) - } - } - - syscall.Sync() - - return errs -} - -func waitForUnmounts(fs afero.Fs, mtab string, mountPoints []string, timeout time.Duration) { - unmounted := map[string]struct{}{} - end := time.Now().Add(timeout) - -loop: - slog.Debug("Waiting for unmounts", "mountpoints", mountPoints) - for _, mountPoint := range mountPoints { - mounted, err := isMounted(fs, mountPoint, mtab) - if err != nil { - slog.Error("Unable to check if mount point is mounted", "mountpoint", mountPoint, "error", err) - } - if !mounted { - unmounted[mountPoint] = struct{}{} - slog.Debug("Mount point is unmounted", "mountpoint", mountPoint) - } - } - - now := time.Now() - lenUnmounted := len(unmounted) - lenMountPoints := len(mountPoints) - - if now.Before(end) && lenUnmounted < lenMountPoints { - goto loop - } - - if now.After(end) && lenUnmounted < lenMountPoints { - slog.Error("Timeout waiting for unmounts") - return - } - - slog.Info("All mount points unmounted") -} - -func isMounted(fs afero.Fs, mountPoint, mtab string) (bool, error) { - f, err := fs.Open(mtab) - if err != nil { - return false, fmt.Errorf("unable to open %s: %w", mtab, err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - fields := strings.Fields(line) - if len(fields) < 2 { - return false, fmt.Errorf("invalid line in %s: %s", mtab, line) - } - mtabMountPoint := fields[1] - if mtabMountPoint == mountPoint { - return true, nil - } - } - - return false, err -} - -type bufGet func() ([]byte, error) -type mapGet func() (map[string]string, error) - -func resolveEnvFrom(name string, b64encode bool, bg bufGet, mg mapGet) (vmspec.NameValueSource, error) { - if len(name) > 0 { - buf, err := bg() - if err != nil { - return nil, err - } - value := "" - if b64encode { - value = base64.StdEncoding.EncodeToString(buf) - } else { - value = string(buf) - } - ev := vmspec.NameValue{Name: name, Value: value} - return vmspec.NameValueSource{ev}, nil - } - m, err := mg() - if err != nil { - return nil, err - } - nvs := make(vmspec.NameValueSource, len(m)) - i := 0 - for k, v := range m { - nvs[i] = vmspec.NameValue{Name: k, Value: v} - i++ - } - return nvs, nil -} - -func resolveIMDSEnvFrom(imds *vmspec.IMDSEnvSource) (vmspec.NameValueSource, error) { - value, err := aws.GetIMDS(imds.Path) - if err != nil { - return nil, err - } - nvs := vmspec.NameValueSource{vmspec.NameValue{Name: imds.Name, Value: value}} - return nvs, nil -} - -func resolveS3EnvFrom(conn aws.Connection, s3 *vmspec.S3EnvSource) (vmspec.NameValueSource, error) { - bg := func() ([]byte, error) { - return conn.S3Client().GetObjectValue(s3.Bucket, s3.Key) - } - mg := func() (map[string]string, error) { - return conn.S3Client().GetObjectMap(s3.Bucket, s3.Key) - } - return resolveEnvFrom(s3.Name, s3.Base64Encode, bg, mg) -} - -func resolveSecretsManagerEnvFrom(conn aws.Connection, - asm *vmspec.SecretsManagerEnvSource) (vmspec.NameValueSource, error) { - bg := func() ([]byte, error) { - return conn.ASMClient().GetSecretValue(asm.SecretID) - } - mg := func() (map[string]string, error) { - return conn.ASMClient().GetSecretMap(asm.SecretID) - } - return resolveEnvFrom(asm.Name, asm.Base64Encode, bg, mg) -} - -func resolveSSMEnvFrom(conn aws.Connection, ssm *vmspec.SSMEnvSource) (vmspec.NameValueSource, error) { - bg := func() ([]byte, error) { - return conn.SSMClient().GetParameterValue(ssm.Path) - } - mg := func() (map[string]string, error) { - return conn.SSMClient().GetParameterMap(ssm.Path) - } - return resolveEnvFrom(ssm.Name, ssm.Base64Encode, bg, mg) -} - -func expandEnv(env, resolvedEnv vmspec.NameValueSource) vmspec.NameValueSource { - nvs := make(vmspec.NameValueSource, len(env)) - mappingFunc := expansion.MappingFuncFor(env.ToMap(), resolvedEnv.ToMap()) - i := 0 - for _, e := range env { - expanded := expansion.Expand(e.Value, mappingFunc) - nvs[i] = vmspec.NameValue{Name: e.Name, Value: expanded} - i++ - } - return nvs -} - -func resolveAllEnvs(conn aws.Connection, env vmspec.NameValueSource, - envFrom vmspec.EnvFromSource) (vmspec.NameValueSource, error) { - var ( - errs error - resolvedEnv vmspec.NameValueSource - ) - - for _, e := range envFrom { - if e.IMDS != nil { - imdsEnv, err := resolveIMDSEnvFrom(e.IMDS) - if !(err == nil || e.IMDS.Optional) { - errs = errors.Join(errs, err) - } - if err == nil { - resolvedEnv = append(resolvedEnv, imdsEnv...) - } - } - if e.S3 != nil { - s3Env, err := resolveS3EnvFrom(conn, e.S3) - if !(err == nil || e.S3.Optional) { - errs = errors.Join(errs, err) - } - if err == nil { - resolvedEnv = append(resolvedEnv, s3Env...) - } - } - if e.SecretsManager != nil { - asmEnv, err := resolveSecretsManagerEnvFrom(conn, e.SecretsManager) - if !(err == nil || e.SecretsManager.Optional) { - errs = errors.Join(errs, err) - } - if err == nil { - resolvedEnv = append(resolvedEnv, asmEnv...) - } - } - if e.SSM != nil { - ssmEnv, err := resolveSSMEnvFrom(conn, e.SSM) - if !(err == nil || e.SSM.Optional) { - errs = errors.Join(errs, err) - } - if err == nil { - resolvedEnv = append(resolvedEnv, ssmEnv...) - } - } - } - - if errs != nil { - return nil, errs - } - - expandedEnv := expandEnv(env, resolvedEnv) - lenEnv := len(env) - allEnv := make(vmspec.NameValueSource, lenEnv+len(resolvedEnv)) - - for i, e := range expandedEnv { - allEnv[i] = e - } - - for i, e := range resolvedEnv { - allEnv[lenEnv+i] = e - } - - return allEnv, nil -} - -func writeInitScripts(fs afero.Fs, scripts []string) ([]string, error) { - written := make([]string, len(scripts)) - for i, script := range scripts { - tf, err := afero.TempFile(fs, constants.DirETRun, "init-script") - if err != nil { - return nil, fmt.Errorf("unable to create temp file for init script: %w", err) - } - _, err = tf.Write([]byte(script)) - if err != nil { - return nil, fmt.Errorf("unable to write init script %s: %w", script, err) - } - err = tf.Close() - if err != nil { - return nil, fmt.Errorf("unable to close temp file for init script: %w", err) - } - err = fs.Chmod(tf.Name(), 0755) - if err != nil { - return nil, fmt.Errorf("unable to set mode on init script %s: %w", tf.Name(), err) - } - written[i] = tf.Name() - } - return written, nil -} - -func runInitScripts(fs afero.Fs, scripts, env []string) error { - for _, script := range scripts { - slog.Debug("Running init script", "script", script) - err := runCommandWithEnv(script, env) - if err != nil { - return fmt.Errorf("unable to run init script %s: %w", script, err) - } - err = fs.Remove(script) - if err != nil { - return fmt.Errorf("unable to remove init script %s after executing: %w", - script, err) - } - } - return nil -} - -func Run() error { - slog.Info("Starting init") - - // Override Go's builtin known certificate directories, for - // making API calls to AWS. - os.Setenv("SSL_CERT_FILE", filepath.Join(constants.DirETEtc, fileCACerts)) - - err := mounts() - if err != nil { - return err - } - - err = links() - if err != nil { - return err - } - - linkEBSDevicesErrC := make(chan error, 1) - go func() { - linkEBSDevicesErrC <- linkEBSDevices() - }() - - metadata, err := readMetadata(filepath.Join(constants.DirETRoot, - constants.FileMetadata)) - if err != nil { - return err - } - - spec, err := metadataToVMSpec(metadata) - if err != nil { - return err - } - - userData, err := aws.GetUserData() - if err != nil { - return fmt.Errorf("unable to get user data: %w", err) - } - - err = spec.Merge(userData) - if err != nil { - return fmt.Errorf("unable to merge VMSpec with user data: %w", err) - } - - if spec.Debug { - slog.SetLogLoggerLevel(slog.LevelDebug) - } - - slog.Debug("Instance configuration", "spec", spec) - - err = spec.Validate() - if err != nil { - return fmt.Errorf("user data failed to validate: %w", err) - } - - osFS := afero.NewOsFs() - writeInitScriptsErrC := make(chan error, 1) - var initScripts []string - go func() { - var e error - initScripts, e = writeInitScripts(osFS, spec.InitScripts) - writeInitScriptsErrC <- e - }() - - err = SetSysctls(spec.Sysctls) - if err != nil { - return err - } - - region, err := aws.GetRegion() - if err != nil { - return err - } - - conn, err := aws.NewConnection(region) - if err != nil { - return err - } - - // Ensure linkEBSDevices() is done before handling volumes. - err = <-linkEBSDevicesErrC - if err != nil { - return err - } - - err = resizeRootVolume() - if err != nil { - return err - } - - for i, volume := range spec.Volumes { - if volume.EBS != nil { - err = handleVolumeEBS(volume.EBS, i) - if err != nil { - return err - } - } - if volume.SecretsManager != nil { - err = handleVolumeSecretsManager(osFS, volume.SecretsManager, conn) - if err != nil { - return err - } - } - if volume.SSM != nil { - err = handleVolumeSSM(osFS, volume.SSM, conn) - if err != nil { - return err - } - } - if volume.S3 != nil { - err = handleVolumeS3(osFS, volume.S3, conn) - if err != nil { - return err - } - } - } - - resolvedEnv, err := resolveAllEnvs(conn, spec.Env, spec.EnvFrom) - if err != nil { - return fmt.Errorf("unable to resolve all environment variables: %w", err) - } - env := resolvedEnv.ToStrings() - - command, err := fullCommand(spec, resolvedEnv) - if err != nil { - return err - } - - err = <-writeInitScriptsErrC - if err != nil { - return err - } - - err = runInitScripts(osFS, initScripts, env) - if err != nil { - return err - } - - if spec.ReplaceInit { - slog.Debug("Replacing init with command", "command", command) - err = replaceInit(spec, command, env, spec.Security.ReadonlyRootFS) - } else { - err = supervise(osFS, spec, command, env, spec.Security.ReadonlyRootFS) - } - - return err -} diff --git a/pkg/initial/initial/initial_test.go b/pkg/initial/initial/initial_test.go deleted file mode 100644 index 891a11c..0000000 --- a/pkg/initial/initial/initial_test.go +++ /dev/null @@ -1,674 +0,0 @@ -package initial - -import ( - "errors" - "io/fs" - "os" - "testing" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/initial/aws" - "github.com/cloudboss/easyto/pkg/initial/collections" - "github.com/cloudboss/easyto/pkg/initial/vmspec" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" -) - -func Test_getenv(t *testing.T) { - testCases := []struct { - env []string - envVar string - expected string - }{ - { - env: []string{}, - envVar: "", - expected: "", - }, - { - env: []string{ - "HOME=/root", - "PATH=/bin:/sbin", - }, - envVar: "PATH", - expected: "/bin:/sbin", - }, - } - for _, tc := range testCases { - ev := getenv(tc.env, tc.envVar) - assert.Equal(t, tc.expected, ev) - } -} - -func Test_parseMode(t *testing.T) { - testCases := []struct { - mode string - result fs.FileMode - err error - }{ - { - mode: "", - result: 0755, - err: nil, - }, - { - mode: "0755", - result: 0755, - err: nil, - }, - { - mode: "0644", - result: 0644, - err: nil, - }, - { - mode: "abc", - result: 0, - err: errors.New("invalid mode abc"), - }, - { - mode: "-1", - result: 0, - err: errors.New("invalid mode -1"), - }, - { - mode: "258", - result: 0, - err: errors.New("invalid mode 258"), - }, - { - mode: "1234567890", - result: 0, - err: errors.New("invalid mode 1234567890"), - }, - } - for _, tc := range testCases { - actual, err := parseMode(tc.mode) - assert.Equal(t, tc.result, actual) - assert.Equal(t, tc.err, err) - } -} - -type mockConnection struct { - asmClient *mockASMClient - s3Client *mockS3Client - ssmClient *mockSSMClient -} - -func newMockConnection(fail bool) *mockConnection { - return &mockConnection{ - &mockASMClient{fail}, - &mockS3Client{fail}, - &mockSSMClient{fail}, - } -} - -type mockASMClient struct { - fail bool -} - -func (m *mockASMClient) GetSecretList(secretID string) (collections.WritableList, error) { - return nil, nil -} - -func (m *mockASMClient) GetSecretMap(secretID string) (map[string]string, error) { - if m.fail { - return nil, errors.New("fail") - } - mapp := map[string]string{ - "JKL": "jkl-value", - "MNO": "mno-value", - } - return mapp, nil -} - -func (m *mockASMClient) GetSecretValue(secretID string) ([]byte, error) { - if m.fail { - return nil, errors.New("fail") - } - b := []byte("Two before narrow not relied how except moment myself") - return b, nil -} - -func (c *mockConnection) ASMClient() aws.ASMClient { - return c.asmClient -} - -func (c *mockConnection) SSMClient() aws.SSMClient { - return c.ssmClient -} - -func (c *mockConnection) S3Client() aws.S3Client { - return c.s3Client -} - -type mockS3Client struct { - fail bool -} - -func (m *mockS3Client) GetObjectList(bucket, keyPrefix string) (collections.WritableList, error) { - return nil, nil -} - -func (m *mockS3Client) GetObjectMap(bucket, keyPrefix string) (map[string]string, error) { - if m.fail { - return nil, errors.New("fail") - } - mapp := map[string]string{ - "JKL": "jkl-value", - "MNO": "mno-value", - } - return mapp, nil -} - -func (m *mockS3Client) GetObjectValue(bucket, keyPrefix string) ([]byte, error) { - if m.fail { - return nil, errors.New("fail") - } - b := []byte("Had denoting properly jointure you occasion directly raillery") - return b, nil -} - -type mockSSMClient struct { - fail bool -} - -func (m *mockSSMClient) GetParameterList(ssmPath string) (collections.WritableList, error) { - return nil, nil -} - -func (m *mockSSMClient) GetParameterMap(ssmPath string) (map[string]string, error) { - if m.fail { - return nil, errors.New("fail") - } - mapp := map[string]string{ - "ABC": "abc-value", - "XYZ": "xyz-value", - } - return mapp, nil -} - -func (m *mockSSMClient) GetParameterValue(ssmPath string) ([]byte, error) { - if m.fail { - return nil, errors.New("fail") - } - b := []byte("Occasional middletons everything so to") - return b, nil -} - -func Test_resolveAllEnvs(t *testing.T) { - testCases := []struct { - description string - env vmspec.NameValueSource - envFrom vmspec.EnvFromSource - result vmspec.NameValueSource - err error - fail bool - }{ - { - description: "Null test case", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{}, - result: vmspec.NameValueSource{}, - err: nil, - }, - { - description: "Single env without EnvFrom", - env: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - }, - envFrom: vmspec.EnvFromSource{}, - result: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - }, - err: nil, - }, - { - description: "No env with single SSM EnvFrom", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc-value", - }, - { - Name: "XYZ", - Value: "xyz-value", - }, - }, - err: nil, - }, - { - description: "Single env with single SSM EnvFrom", - env: vmspec.NameValueSource{ - { - Name: "CDE", - Value: "cde", - }, - }, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "CDE", - Value: "cde", - }, - { - Name: "ABC", - Value: "abc-value", - }, - { - Name: "XYZ", - Value: "xyz-value", - }, - }, - err: nil, - }, - { - description: "Single env and single SSM EnvFrom with duplicate", - // Environment variable names within the image metadata are overridden - // if they are defined in user data, but no check is done to ensure - // there are no duplicates in the user data itself. Let execve() be the - // decider on the behavior in this case. - env: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - }, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - { - Name: "ABC", - Value: "abc-value", - }, - { - Name: "XYZ", - Value: "xyz-value", - }, - }, - err: nil, - }, - { - description: "Failed optional SSM EnvFrom", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - Optional: true, - }, - }, - }, - result: vmspec.NameValueSource{}, - err: nil, - fail: true, - }, - { - description: "Single env and failed optional SSM EnvFrom", - env: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - }, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - Optional: true, - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc", - }, - }, - err: nil, - fail: true, - }, - { - description: "Failed non-optional SSM EnvFrom", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - Optional: false, - }, - }, - }, - result: nil, - err: errors.Join(errors.New("fail")), - fail: true, - }, - { - description: "Mixed SSM and S3 EnvFrom", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - SSM: &vmspec.SSMEnvSource{ - Path: "/aaaaa", - Optional: true, - }, - }, - { - S3: &vmspec.S3EnvSource{ - Bucket: "thebucket", - Key: "/bbbbb", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ABC", - Value: "abc-value", - }, - { - Name: "XYZ", - Value: "xyz-value", - }, - { - Name: "JKL", - Value: "jkl-value", - }, - { - Name: "MNO", - Value: "mno-value", - }, - }, - err: nil, - }, - { - description: "Raw SSM and S3 EnvFrom with Name defined", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - S3: &vmspec.S3EnvSource{ - Bucket: "thebucket", - Key: "/aaaaa", - Name: "S3", - }, - }, - { - SSM: &vmspec.SSMEnvSource{ - Path: "/bbbbb", - Name: "SSM", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "S3", - Value: "Had denoting properly jointure you occasion directly raillery", - }, - { - Name: "SSM", - Value: "Occasional middletons everything so to", - }, - }, - err: nil, - }, - { - description: "Base64 encoded SSM and S3 EnvFrom with Name defined", - env: vmspec.NameValueSource{}, - envFrom: vmspec.EnvFromSource{ - { - SecretsManager: &vmspec.SecretsManagerEnvSource{ - Base64Encode: true, - SecretID: "secret-id", - Name: "ASM", - }, - }, - { - S3: &vmspec.S3EnvSource{ - Base64Encode: true, - Bucket: "thebucket", - Key: "/aaaaa", - Name: "S3", - }, - }, - { - SSM: &vmspec.SSMEnvSource{ - Base64Encode: true, - Path: "/bbbbb", - Name: "SSM", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ASM", - Value: "VHdvIGJlZm9yZSBuYXJyb3cgbm90IHJlbGllZCBob3cgZXhjZXB0IG1vbWVudCBteXNlbGY=", - }, - { - Name: "S3", - Value: "SGFkIGRlbm90aW5nIHByb3Blcmx5IGpvaW50dXJlIHlvdSBvY2Nhc2lvbiBkaXJlY3RseSByYWlsbGVyeQ==", - }, - { - Name: "SSM", - Value: "T2NjYXNpb25hbCBtaWRkbGV0b25zIGV2ZXJ5dGhpbmcgc28gdG8=", - }, - }, - err: nil, - }, - { - description: "Expand variables within values", - env: vmspec.NameValueSource{ - { - Name: "ENV", - Value: "value", - }, - { - Name: "EXPAND_ENV", - Value: "$(ENV)", - }, - { - Name: "ESCAPED", - Value: "$$(ENV)", - }, - { - Name: "NOT_FOUND", - Value: "$(NOT_FOUND)", - }, - { - Name: "NO_EXPAND_EXPANDED", - Value: "$(EXPAND_ENV)", - }, - { - Name: "EXPAND_ASM", - Value: "$(ASM)", - }, - { - Name: "EXPAND_S3", - Value: "$(S3)", - }, - { - Name: "EXPAND_SSM", - Value: "$(SSM)", - }, - { - Name: "EXPAND_MULTIPLE", - Value: "ENV: $(ENV), ASM: $(ASM), S3: $(S3), SSM: $(SSM)", - }, - }, - envFrom: vmspec.EnvFromSource{ - { - SecretsManager: &vmspec.SecretsManagerEnvSource{ - Base64Encode: true, - SecretID: "secret-id", - Name: "ASM", - }, - }, - { - S3: &vmspec.S3EnvSource{ - Base64Encode: true, - Bucket: "thebucket", - Key: "/aaaaa", - Name: "S3", - }, - }, - { - SSM: &vmspec.SSMEnvSource{ - Base64Encode: true, - Path: "/bbbbb", - Name: "SSM", - }, - }, - }, - result: vmspec.NameValueSource{ - { - Name: "ENV", - Value: "value", - }, - { - Name: "EXPAND_ENV", - Value: "value", - }, - { - Name: "ESCAPED", - Value: "$(ENV)", - }, - { - Name: "NOT_FOUND", - Value: "$(NOT_FOUND)", - }, - { - Name: "NO_EXPAND_EXPANDED", - Value: "$(ENV)", - }, - { - Name: "EXPAND_ASM", - Value: "VHdvIGJlZm9yZSBuYXJyb3cgbm90IHJlbGllZCBob3cgZXhjZXB0IG1vbWVudCBteXNlbGY=", - }, - { - Name: "EXPAND_S3", - Value: "SGFkIGRlbm90aW5nIHByb3Blcmx5IGpvaW50dXJlIHlvdSBvY2Nhc2lvbiBkaXJlY3RseSByYWlsbGVyeQ==", - }, - { - Name: "EXPAND_SSM", - Value: "T2NjYXNpb25hbCBtaWRkbGV0b25zIGV2ZXJ5dGhpbmcgc28gdG8=", - }, - { - Name: "EXPAND_MULTIPLE", - Value: "ENV: value, ASM: VHdvIGJlZm9yZSBuYXJyb3cgbm90IHJlbGllZCBob3cgZXhjZXB0IG1vbWVudCBteXNlbGY=, S3: SGFkIGRlbm90aW5nIHByb3Blcmx5IGpvaW50dXJlIHlvdSBvY2Nhc2lvbiBkaXJlY3RseSByYWlsbGVyeQ==, SSM: T2NjYXNpb25hbCBtaWRkbGV0b25zIGV2ZXJ5dGhpbmcgc28gdG8=", - }, - { - Name: "ASM", - Value: "VHdvIGJlZm9yZSBuYXJyb3cgbm90IHJlbGllZCBob3cgZXhjZXB0IG1vbWVudCBteXNlbGY=", - }, - { - Name: "S3", - Value: "SGFkIGRlbm90aW5nIHByb3Blcmx5IGpvaW50dXJlIHlvdSBvY2Nhc2lvbiBkaXJlY3RseSByYWlsbGVyeQ==", - }, - { - Name: "SSM", - Value: "T2NjYXNpb25hbCBtaWRkbGV0b25zIGV2ZXJ5dGhpbmcgc28gdG8=", - }, - }, - err: nil, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - conn := newMockConnection(tc.fail) - actual, err := resolveAllEnvs(conn, tc.env, tc.envFrom) - assert.ElementsMatch(t, tc.result, actual) - assert.EqualValues(t, tc.err, err) - }) - } -} - -func Test_isMounted(t *testing.T) { - const mtabPath = constants.DirProc + "/mounts" - testCases := []struct { - name string - mountPoint string - mtabPath string - mtabContents string - mounted bool - errored bool - }{ - { - name: "returns error", - mountPoint: "/abc", - mtabPath: "/wrong/mounts", - mtabContents: `/dev/nvme0n1p2 /boot ext4 rw,seclabel,relatime 0 0 -/dev/nvme0n1p1 /boot/efi vfat rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=ascii,shortname=winnt,errors=remount-ro 0 0 -tmpfs /tmp tmpfs rw,seclabel,nosuid,nodev,nr_inodes=1048576,inode64 0 0 -binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0`, - mounted: false, - errored: true, - }, - { - name: "not mounted", - mountPoint: "/abc", - mtabPath: mtabPath, - mtabContents: `/dev/nvme0n1p2 /boot ext4 rw,seclabel,relatime 0 0 -/dev/nvme0n1p1 /boot/efi vfat rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=ascii,shortname=winnt,errors=remount-ro 0 0 -tmpfs /tmp tmpfs rw,seclabel,nosuid,nodev,nr_inodes=1048576,inode64 0 0 -binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0`, - mounted: false, - }, - { - name: "is mounted", - mountPoint: "/abc", - mtabPath: mtabPath, - mtabContents: `/dev/nvme0n1p2 /boot ext4 rw,seclabel,relatime 0 0 -/dev/nvme0n1p3 /abc ext4 rw,seclabel,relatime 0 0 -/dev/nvme0n1p1 /boot/efi vfat rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=ascii,shortname=winnt,errors=remount-ro 0 0 -tmpfs /tmp tmpfs rw,seclabel,nosuid,nodev,nr_inodes=1048576,inode64 0 0 -binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc rw,nosuid,nodev,noexec,relatime 0 0`, - mounted: true, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - fs := afero.NewMemMapFs() - mounts, err := fs.OpenFile(mtabPath, os.O_RDWR|os.O_CREATE, 0644) - if err != nil { - t.Fatal(err) - } - defer mounts.Close() - _, err = mounts.WriteString(tc.mtabContents) - if err != nil { - t.Fatal(err) - } - mounted, err := isMounted(fs, tc.mountPoint, tc.mtabPath) - assert.Equal(t, tc.mounted, mounted) - if err != nil { - assert.True(t, tc.errored) - } - }) - } -} diff --git a/pkg/initial/initial/sysctl.go b/pkg/initial/initial/sysctl.go deleted file mode 100644 index 376778e..0000000 --- a/pkg/initial/initial/sysctl.go +++ /dev/null @@ -1,56 +0,0 @@ -package initial - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/initial/vmspec" -) - -func keyToPath(key string) string { - return filepath.Join(constants.DirProc, "sys", strings.Replace(key, ".", "/", -1)) -} - -func sysctl(key, value string) error { - procPath := keyToPath(key) - f, err := os.OpenFile(procPath, os.O_RDWR, 0) - if err != nil { - return fmt.Errorf("unable to open %s: %w", procPath, err) - } - defer f.Close() - _, err = f.Write([]byte(value)) - if err != nil { - return fmt.Errorf("unable to write sysctl %s with value %s: %w", key, value, err) - } - return nil -} - -func SetSysctls(sysctls vmspec.NameValueSource) error { - wg := sync.WaitGroup{} - lenSysctls := len(sysctls) - wg.Add(lenSysctls) - - errC := make(chan error, lenSysctls) - - for _, sc := range sysctls { - go func(sc vmspec.NameValue) { - defer wg.Done() - errC <- sysctl(sc.Name, sc.Value) - }(sc) - } - - wg.Wait() - - close(errC) - - var errs error - for err := range errC { - errs = errors.Join(errs, err) - } - return errs -} diff --git a/pkg/initial/initial/sysctl_test.go b/pkg/initial/initial/sysctl_test.go deleted file mode 100644 index 2ca6bc3..0000000 --- a/pkg/initial/initial/sysctl_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package initial - -import ( - "path/filepath" - "testing" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/stretchr/testify/assert" -) - -func Test_keyToPath(t *testing.T) { - testCases := []struct { - key string - result string - }{ - { - key: "", - result: filepath.Join(constants.DirProc, "sys"), - }, - { - key: "kernel.poweroff_cmd", - result: filepath.Join(constants.DirProc, "sys/kernel/poweroff_cmd"), - }, - { - key: "net.netfilter.nf_log.0", - result: filepath.Join(constants.DirProc, "sys/net/netfilter/nf_log/0"), - }, - } - for _, tc := range testCases { - actual := keyToPath(tc.key) - assert.Equal(t, tc.result, actual) - } -} diff --git a/pkg/initial/service/chrony.go b/pkg/initial/service/chrony.go deleted file mode 100644 index f9a3281..0000000 --- a/pkg/initial/service/chrony.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/login" -) - -type ChronyService struct { - svc -} - -func NewChronyService() Service { - svc := newSvc() - svc.Args = []string{ - filepath.Join(constants.DirETSbin, "chronyd"), - "-d", - } - svc.Init = chronyInit - - return &ChronyService{svc: svc} -} - -func chronyInit() error { - slog.Info("Initializing chrony") - - _, usersByName, _, err := login.ParsePasswd(fs, constants.FileEtcPasswd) - if err != nil { - return fmt.Errorf("unable to parse %s: %s", constants.FileEtcPasswd, err) - } - user, ok := usersByName[constants.ChronyUser] - if !ok { - return fmt.Errorf("user %s not found", constants.ChronyUser) - } - - chronyRunPath := filepath.Join(constants.DirETRun, "chrony") - err = os.Mkdir(chronyRunPath, 0750) - if err != nil && !os.IsExist(err) { - return fmt.Errorf("unable to create %s: %w", chronyRunPath, err) - } - - err = os.Chown(chronyRunPath, int(user.UID), int(user.GID)) - if err != nil { - return fmt.Errorf("unable to change ownership of %s: %w", chronyRunPath, err) - } - - return nil -} diff --git a/pkg/initial/service/main.go b/pkg/initial/service/main.go deleted file mode 100644 index 244c490..0000000 --- a/pkg/initial/service/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package service - -import ( - "log/slog" -) - -type Main struct { - svc -} - -func NewMainService(command, env []string, workingDir string, uid, gid uint32) Service { - svc := newSvc() - svc.Args = command - svc.Dir = workingDir - svc.Env = env - svc.GID = gid - svc.UID = uid - - return &Main{svc: svc} -} - -func (m *Main) Start() error { - m.InitC <- struct{}{} - m.setCmd() - - slog.Info("Starting main command", "command", m.cmd.Args) - - go func() { - err := m.cmd.Start() - m.StartC <- struct{}{} - if err != nil { - m.ErrC <- err - return - } - m.ErrC <- m.cmd.Wait() - }() - - return nil -} diff --git a/pkg/initial/service/service.go b/pkg/initial/service/service.go deleted file mode 100644 index 682297c..0000000 --- a/pkg/initial/service/service.go +++ /dev/null @@ -1,148 +0,0 @@ -package service - -import ( - "log/slog" - "os" - "os/exec" - "syscall" - "time" - - "github.com/spf13/afero" -) - -var ( - fs = afero.NewOsFs() -) - -type Service interface { - Start() error - WaitInit() - WaitStart() - WaitStop() error - Stop() - Optional() bool - PID() int -} - -type InitFunc func() error - -type svc struct { - Args []string - Dir string - Env []string - GID uint32 - UID uint32 - Init InitFunc - ErrC chan error - InitC chan struct{} - StartC chan struct{} - optional bool - shutdown bool - cmd exec.Cmd -} - -func (s *svc) Start() error { - if s.Init == nil { - s.InitC <- struct{}{} - } else { - err := s.Init() - s.InitC <- struct{}{} - if err != nil { - return err - } - } - - go func() { - s.setCmd() - slog.Info("Starting service", "service", s.cmd.Args) - firstStart := true - - for { - err := s.cmd.Start() - - if err != nil { - if s.shutdown { - s.ErrC <- err - break - } - } else { - if firstStart { - s.StartC <- struct{}{} - firstStart = false - } - err = s.cmd.Wait() - if s.shutdown { - s.ErrC <- err - break - } - } - - if err != nil { - slog.Error("Service errored, will restart", "process", s.Args[0], - "error", err) - } else { - slog.Warn("Service exited, will restart", "process", s.Args[0]) - } - - time.Sleep(5 * time.Second) - s.setCmd() - } - }() - - return nil -} - -func (s *svc) WaitStop() error { - return <-s.ErrC -} - -func (s *svc) WaitInit() { - <-s.InitC -} - -func (s *svc) WaitStart() { - <-s.StartC -} - -func (s *svc) Stop() { - s.shutdown = true -} - -func (s *svc) Optional() bool { - return s.optional -} - -func (s *svc) PID() int { - if s.cmd.Process != nil { - return s.cmd.Process.Pid - } - return 0 -} - -func newSvc() svc { - return svc{ - Dir: "/", - Env: []string{}, - ErrC: make(chan error, 1), - InitC: make(chan struct{}, 1), - StartC: make(chan struct{}, 1), - } -} - -func (s *svc) setCmd() { - s.cmd = exec.Cmd{ - Args: s.Args, - Dir: s.Dir, - Env: s.Env, - Path: s.Args[0], - Stderr: os.Stderr, - Stdin: os.Stdin, - Stdout: os.Stdout, - SysProcAttr: &syscall.SysProcAttr{ - Credential: &syscall.Credential{ - Gid: s.GID, - Uid: s.UID, - }, - }, - } -} diff --git a/pkg/initial/service/sshd.go b/pkg/initial/service/sshd.go deleted file mode 100644 index 4af18f2..0000000 --- a/pkg/initial/service/sshd.go +++ /dev/null @@ -1,134 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "syscall" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/cloudboss/easyto/pkg/initial/aws" - "github.com/cloudboss/easyto/pkg/login" - "github.com/spf13/afero" -) - -type SSHDService struct { - svc -} - -func NewSSHDService() Service { - svc := newSvc() - svc.Args = []string{ - filepath.Join(constants.DirETSbin, "sshd"), - "-D", - "-f", - filepath.Join(constants.DirETEtc, "ssh", "sshd_config"), - "-e", - } - svc.Init = sshdInit - svc.optional = true - - return &SSHDService{svc: svc} -} - -func sshdInit() error { - slog.Info("Initializing sshd") - - oldmask := syscall.Umask(0) - defer syscall.Umask(oldmask) - - loginUser, err := getLoginUser(fs) - if err != nil { - return fmt.Errorf("unable to get login user: %w", err) - } - - _, userByName, _, err := login.ParsePasswd(fs, constants.FileEtcPasswd) - if err != nil { - return fmt.Errorf("unable to parse %s: %s\n", constants.FileEtcPasswd, err) - } - user, ok := userByName[loginUser] - if !ok { - return fmt.Errorf("login user %s not found in %s", loginUser, - constants.FileEtcPasswd) - } - - slog.Debug("Writing ssh public key", "user", loginUser) - sshDir := filepath.Join(user.HomeDir, ".ssh") - err = sshWritePubKey(fs, sshDir, user.UID, user.GID) - if err != nil { - return fmt.Errorf("unable to write SSH public key: %w", err) - } - - slog.Debug("Creating RSA host key") - rsaKeyPath := filepath.Join(constants.DirETEtc, "ssh", "ssh_host_rsa_key") - if _, err := fs.Stat(rsaKeyPath); os.IsNotExist(err) { - if err := sshKeygen("rsa", rsaKeyPath); err != nil { - return fmt.Errorf("unable to create RSA host key: %w", err) - } - } - - slog.Debug("Creating ED25519 host key") - ed25519KeyPath := filepath.Join(constants.DirETEtc, "ssh", "ssh_host_ed25519_key") - if _, err := fs.Stat(ed25519KeyPath); os.IsNotExist(err) { - if err := sshKeygen("ed25519", ed25519KeyPath); err != nil { - return fmt.Errorf("unable to create ED25519 host key: %w", err) - } - } - - return nil -} - -func sshKeygen(keyType, keyPath string) error { - keygen := filepath.Join(constants.DirETBin, "ssh-keygen") - cmd := exec.Command(keygen, "-t", keyType, "-f", keyPath, "-N", "") - return cmd.Run() -} - -func sshWritePubKey(fs afero.Fs, dir string, uid, gid uint16) error { - pubKey, err := aws.GetSSHPubKey() - if err != nil { - return fmt.Errorf("unable to get SSH key from instance metadata: %w", err) - } - - keyPath := filepath.Join(dir, "authorized_keys") - - f, err := fs.Create(keyPath) - if err != nil { - return fmt.Errorf("unable to create %s: %w", keyPath, err) - } - defer f.Close() - - err = fs.Chown(keyPath, int(uid), int(gid)) - if err != nil { - return fmt.Errorf("unable to change ownership of %s: %w", keyPath, err) - } - - err = fs.Chmod(keyPath, 0640) - if err != nil { - return fmt.Errorf("unable to change permissions of %s: %w", keyPath, err) - } - - _, err = f.Write([]byte(pubKey)) - if err != nil { - return fmt.Errorf("unable to write key to %s: %w", keyPath, err) - } - - return nil -} - -// getLoginUser returns the login username for the system. If the image -// was built with easyto and sshd is enabled, this should be the name of -// the one directory under the easyto home directory. -func getLoginUser(fs afero.Fs) (string, error) { - entries, err := afero.ReadDir(fs, constants.DirETHome) - if err != nil { - return "", err - } - if len(entries) != 1 { - return "", fmt.Errorf("expected one entry in %s", constants.DirETHome) - } - - return entries[0].Name(), nil -} diff --git a/pkg/initial/service/supervisor.go b/pkg/initial/service/supervisor.go deleted file mode 100644 index 0f7fe5e..0000000 --- a/pkg/initial/service/supervisor.go +++ /dev/null @@ -1,247 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "log/slog" - "os" - "os/signal" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/cloudboss/easyto/pkg/constants" - "github.com/spf13/afero" - "golang.org/x/sys/unix" -) - -const ( - // Signal sent by the "ACPI tiny power button" kernel driver. - // It is assumed the kernel will be compiled to use it. - SIGPWRBTN = syscall.Signal(0x26) - - // Flag indicating process is a kernel thread, from include/linux/sched.h. - PF_KTHREAD = 0x00200000 -) - -type Supervisor struct { - Main Service - ReadonlyRootFS bool - Services []Service - Timeout time.Duration -} - -func (s *Supervisor) Start() error { - entries, err := afero.ReadDir(fs, constants.DirETServices) - if !(err == nil || errors.Is(err, os.ErrNotExist)) { - return fmt.Errorf("unable to read directory %s: %w", constants.DirETServices, err) - } - - for _, entry := range entries { - svc := entry.Name() - switch svc { - case "chrony": - s.Services = append(s.Services, NewChronyService()) - case "ssh": - s.Services = append(s.Services, NewSSHDService()) - default: - slog.Warn("Unknown service", "service", svc) - } - } - - for _, service := range s.Services { - err := service.Start() - if err != nil { - if service.Optional() { - slog.Warn("Optional service failed to start", "service", service, "error", err) - continue - } - return err - } - } - - if s.ReadonlyRootFS { - // This needs to be done after services have initialized so that e.g. ssh-keygen can run. - s.waitServicesInit() - err = unix.Mount("", constants.DirRoot, "", syscall.MS_REMOUNT|syscall.MS_RDONLY, "") - if err != nil { - return fmt.Errorf("unable to remount root filesystem read-only: %w", err) - } - } - - return s.Main.Start() -} - -func (s *Supervisor) Stop() { - s.signal(syscall.SIGTERM) -} - -func (s *Supervisor) Kill() { - s.signal(syscall.SIGKILL) -} - -func (s *Supervisor) signal(signal syscall.Signal) { - // Ensure services know it is shutdown time so they don't restart. - for _, service := range s.Services { - service.Stop() - } - pids := s.pids() - for _, pid := range pids { - if pid == 1 { - continue - } - unix.Kill(pid, signal) - } -} - -func (s *Supervisor) Wait() { - poweroffC := make(chan os.Signal, 1) - signal.Notify(poweroffC, SIGPWRBTN) - - doneC := make(chan struct{}, 1) - - // Create a timeout with an unreachable duration - // to be adjusted when it's time to shut down. - forever := time.Duration(1<<63 - 1) - timeout := time.NewTimer(forever) - - didShutdownAll := false - shutdownAll := func() { - if didShutdownAll { - return - } else { - didShutdownAll = true - } - - slog.Info("Shutting down all processes") - - // Set the timer in case processes do not exit. - timeout.Reset(s.Timeout) - - // Send a SIGTERM to all running processes. - s.Stop() - } - - go func() { - err := s.Main.WaitStop() - if !(err == nil || errors.Is(err, syscall.ECHILD)) { - slog.Error("Main process exited", "error", err) - } else { - slog.Info("Main process exited") - } - shutdownAll() - }() - - go func() { - // Don't start reaping processes until the main process has started, - // otherwise the system may shut down before it starts, especially - // in cases where there are no services besides the main process. - s.Main.WaitStart() - for { - pid, err := syscall.Wait4(-1, nil, 0, nil) - slog.Debug("Reaped process", "pid", pid, "error", err) - if err != nil && errors.Is(err, syscall.ECHILD) { - // All processes have exited. - break - } - } - doneC <- struct{}{} - }() - - stopped := false - - for !stopped { - select { - case <-poweroffC: - slog.Info("Got poweroff signal") - go shutdownAll() - case <-doneC: - slog.Info("All processes have exited") - stopped = true - case <-timeout.C: - slog.Warn("Timeout waiting for graceful shutdown") - s.Kill() - stopped = true - } - } -} - -func (s *Supervisor) waitServicesInit() { - wg := sync.WaitGroup{} - wg.Add(len(s.Services)) - for _, svc := range s.Services { - go func() { svc.WaitInit(); wg.Done() }() - } - wg.Wait() -} - -// pids returns all current userspace PIDs. If there is an error reading /proc, the -// PIDs of the known services are returned so a best effort shutdown can be done. -func (s *Supervisor) pids() []int { - pids := []int{} - dirEntries, err := os.ReadDir(constants.DirProc) - if err != nil { - slog.Error("Unable to read directory", "directory", constants.DirProc, "error", err) - return s.svcPIDs() - } - for _, dirEntry := range dirEntries { - if !dirEntry.IsDir() { - continue - } - pid, err := strconv.Atoi(dirEntry.Name()) - if err != nil { - continue - } - statFile := filepath.Join(constants.DirProc, dirEntry.Name(), "stat") - kt, err := isKernelThread(statFile) - if err != nil && errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - slog.Error("Unable to filter kernel thread", "pid", pid, "error", err) - return s.svcPIDs() - } - if !kt { - pids = append(pids, pid) - } - } - return pids -} - -// svcPIDs returns the PIDs of known services. -func (s *Supervisor) svcPIDs() []int { - pids := []int{} - for _, svc := range s.Services { - pid := svc.PID() - if pid != 0 { - pids = append(pids, pid) - } - } - return pids -} - -func isKernelThread(statFile string) (bool, error) { - const ( - flagsField = 8 - nStatFields = 52 - ) - st, err := os.ReadFile(statFile) - if err != nil { - return false, fmt.Errorf("unable to read %s: %w", statFile, err) - } - fields := strings.Fields(string(st)) - if len(fields) != nStatFields { - err = fmt.Errorf("expected %d fields in %s, got %d", nStatFields, - statFile, len(fields)) - return false, err - } - statField := fields[flagsField] - flags, err := strconv.Atoi(statField) - if err != nil { - return false, fmt.Errorf("unable to parse %s: %w", statFile, err) - } - return flags&PF_KTHREAD != 0, nil -} diff --git a/pkg/initial/vmspec/vmspec.go b/pkg/initial/vmspec/vmspec.go deleted file mode 100644 index bc5ac34..0000000 --- a/pkg/initial/vmspec/vmspec.go +++ /dev/null @@ -1,360 +0,0 @@ -package vmspec - -import ( - "errors" - "fmt" - "reflect" - "sort" - "strings" - - "dario.cat/mergo" -) - -const ( - pathEnvDefault = "/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin" -) - -var DefaultServices = []string{"chrony", "ssh"} - -type VMSpec struct { - Args []string `json:"args,omitempty"` - Command []string `json:"command,omitempty"` - Debug bool `json:"debug,omitempty"` - DisableServices []string `json:"disable-services,omitempty"` - Env NameValueSource `json:"env,omitempty"` - EnvFrom EnvFromSource `json:"env-from,omitempty"` - InitScripts []string `json:"init-scripts,omitempty"` - ReplaceInit bool `json:"replace-init,omitempty"` - Security SecurityContext `json:"security,omitempty"` - ShutdownGracePeriod int `json:"shutdown-grace-period,omitempty"` - Sysctls NameValueSource `json:"sysctls,omitempty"` - Volumes Volumes `json:"volumes,omitempty"` - WorkingDir string `json:"working-dir,omitempty"` -} - -func (v *VMSpec) Merge(other *VMSpec) error { - err := mergo.Merge(v, other, mergo.WithOverride, mergo.WithoutDereference, - mergo.WithTransformers(nameValueTransformer{})) - if err != nil { - return err - } - if other.Command != nil { - // Override args if command is set, even if zero value. - v.Args = other.Args - } - v.SetDefaults() - return nil -} - -func (v *VMSpec) SetDefaults() { - _, i := v.Env.Find("PATH") - if i < 0 { - pathEnv := NameValue{Name: "PATH", Value: pathEnvDefault} - v.Env = append(v.Env, pathEnv) - } - if v.Security.RunAsGroupID == nil { - v.Security.RunAsGroupID = p(0) - } - if v.Security.RunAsUserID == nil { - v.Security.RunAsUserID = p(0) - } - for _, volume := range v.Volumes { - if volume.EBS != nil { - if volume.EBS.Mount.GroupID == nil { - volume.EBS.Mount.GroupID = v.Security.RunAsGroupID - } - if volume.EBS.Mount.UserID == nil { - volume.EBS.Mount.UserID = v.Security.RunAsUserID - } - } - if volume.SecretsManager != nil { - if volume.SecretsManager.Mount.GroupID == nil { - volume.SecretsManager.Mount.GroupID = v.Security.RunAsGroupID - } - if volume.SecretsManager.Mount.UserID == nil { - volume.SecretsManager.Mount.UserID = v.Security.RunAsUserID - } - } - if volume.SSM != nil { - if volume.SSM.Mount.GroupID == nil { - volume.SSM.Mount.GroupID = v.Security.RunAsGroupID - } - if volume.SSM.Mount.UserID == nil { - volume.SSM.Mount.UserID = v.Security.RunAsUserID - } - } - if volume.S3 != nil { - if volume.S3.Mount.GroupID == nil { - volume.S3.Mount.GroupID = v.Security.RunAsGroupID - } - if volume.S3.Mount.UserID == nil { - volume.S3.Mount.UserID = v.Security.RunAsUserID - } - } - } -} - -func (v *VMSpec) Validate() error { - var errs error - errs = errors.Join(errs, ValidateServices(v.DisableServices)) - for _, ef := range v.EnvFrom { - errs = errors.Join(errs, ef.Validate()) - } - for _, ef := range v.Volumes { - errs = errors.Join(errs, ef.Validate()) - } - return errs -} - -type NameValue struct { - Name string `json:"name,omitempty"` - Value string `json:"value,omitempty"` -} - -type NameValueSource []NameValue - -// Find returns the value of the item at key with its index, or -1 if not found. -func (n NameValueSource) Find(key string) (string, int) { - for i, item := range n { - if item.Name == key { - return item.Value, i - } - } - return "", -1 -} - -// ToMap converts a NameValueSource to a map[string]string. -func (n NameValueSource) ToMap() map[string]string { - m := map[string]string{} - for _, item := range n { - m[item.Name] = item.Value - } - return m -} - -type nameValueTransformer struct{} - -// Transformer merges NameValueSource types. Values from src override values from dst if -// both have the same Name. Items in src with Name not existing in dst are appended to dst. -func (n nameValueTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { - nvType := reflect.TypeOf(NameValueSource{}) - if typ != nvType { - return nil - } - - return func(dst, src reflect.Value) error { - if !src.CanSet() { - return nil - } - if !(src.Type() == nvType && dst.Type() == nvType) { - return fmt.Errorf("expected to merge %s types, got %s and %s", - nvType, src.Type(), dst.Type()) - } - for i := 0; i < src.Len(); i++ { - srcNV := src.Index(i) - srcName := srcNV.FieldByName("Name") - var overrideValue reflect.Value - var dstValue reflect.Value - for j := 0; j < dst.Len(); j++ { - dstName := dst.Index(j).FieldByName("Name") - if srcName.Equal(dstName) { - dstValue = dst.Index(j).FieldByName("Value") - overrideValue = srcNV.FieldByName("Value") - break - } - } - if overrideValue.IsValid() { - dstValue.Set(overrideValue) - continue - } - dst.Set(reflect.Append(dst, srcNV)) - } - return nil - } -} - -func (n NameValueSource) ToStrings() []string { - stringItems := make([]string, len(n)) - for i, item := range n { - stringItems[i] = item.Name + "=" + item.Value - } - return stringItems -} - -type EnvFromSource []EnvFrom - -type EnvFrom struct { - Prefix string `json:"prefix,omitempty"` - IMDS *IMDSEnvSource `json:"imds,omitempty"` - S3 *S3EnvSource `json:"s3,omitempty"` - SecretsManager *SecretsManagerEnvSource `json:"secrets-manager,omitempty"` - SSM *SSMEnvSource `json:"ssm,omitempty"` -} - -func (e *EnvFrom) Validate() error { - var ( - envNames []string - errs error - ) - if e.IMDS != nil { - if len(e.IMDS.Name) == 0 { - err := fmt.Errorf("imds name is required") - errs = errors.Join(errs, err) - } - envNames = append(envNames, "imds") - } - if e.S3 != nil { - envNames = append(envNames, "s3-object") - } - if e.SecretsManager != nil { - envNames = append(envNames, "secrets-manager") - } - if e.SSM != nil { - envNames = append(envNames, "ssm") - } - lenEnvNames := len(envNames) - if lenEnvNames > 1 { - err := fmt.Errorf("expected 1 environment source, got %d: %s", - lenEnvNames, strings.Join(envNames, ", ")) - errs = errors.Join(errs, err) - } - if errs != nil { - return fmt.Errorf("env-from: %w", errs) - } - return nil -} - -type IMDSEnvSource struct { - Name string `json:"name,omitempty"` - Path string `json:"path,omitempty"` - Optional bool `json:"optional,omitempty"` -} - -type S3EnvSource struct { - Base64Encode bool `json:"base64-encode,omitempty"` - Bucket string `json:"bucket,omitempty"` - Key string `json:"key,omitempty"` - Name string `json:"name,omitempty"` - Optional bool `json:"optional,omitempty"` -} - -type SecretsManagerEnvSource struct { - Base64Encode bool `json:"base64-encode,omitempty"` - Name string `json:"name,omitempty"` - Optional bool `json:"optional,omitempty"` - SecretID string `json:"secret-id,omitempty"` -} - -type SSMEnvSource struct { - Base64Encode bool `json:"base64-encode,omitempty"` - Name string `json:"name,omitempty"` - Optional bool `json:"optional,omitempty"` - Path string `json:"path,omitempty"` -} - -type Volume struct { - EBS *EBSVolumeSource `json:"ebs,omitempty"` - SecretsManager *SecretsManagerVolumeSource `json:"secrets-manager,omitempty"` - SSM *SSMVolumeSource `json:"ssm,omitempty"` - S3 *S3VolumeSource `json:"s3,omitempty"` -} - -func (v *Volume) Validate() error { - volumeNames := []string{} - if v.EBS != nil { - volumeNames = append(volumeNames, "ebs") - } - if v.SecretsManager != nil { - volumeNames = append(volumeNames, "secrets-manager") - } - if v.SSM != nil { - volumeNames = append(volumeNames, "ssm") - } - if v.S3 != nil { - volumeNames = append(volumeNames, "s3") - } - lenVolumeNames := len(volumeNames) - if lenVolumeNames > 1 { - return fmt.Errorf("expected 1 volume source, got %d: %s", lenVolumeNames, - strings.Join(volumeNames, ", ")) - } - return nil -} - -type Volumes []Volume - -func (v Volumes) MountPoints() []string { - mountPoints := []string{} - for _, v := range v { - // Only EBS volumes have actual mount points, so ignore the rest. - if v.EBS != nil { - mountPoints = append(mountPoints, v.EBS.Mount.Destination) - } - } - // Reverse sort the mountpoints so children are listed before their - // parents, to make it easier to unmount them in the correct order. - sort.Sort(sort.Reverse(sort.StringSlice(mountPoints))) - return mountPoints -} - -type EBSVolumeSource struct { - Attach bool `json:"attach,omitempty"` - Device string `json:"device,omitempty"` - FSType string `json:"fs-type,omitempty"` - MakeFS bool `json:"make-fs,omitempty"` - Mount Mount `json:"mount,omitempty"` -} - -type SecretsManagerVolumeSource struct { - Mount Mount `json:"mount,omitempty"` - SecretID string `json:"secret-id,omitempty"` - Optional bool `json:"optional,omitempty"` -} - -type SSMVolumeSource struct { - Mount Mount `json:"mount,omitempty"` - Optional bool `json:"optional,omitempty"` - Path string `json:"path,omitempty"` -} - -type S3VolumeSource struct { - Bucket string `json:"bucket,omitempty"` - KeyPrefix string `json:"key-prefix,omitempty"` - Mount Mount `json:"mount,omitempty"` - Optional bool `json:"optional,omitempty"` -} - -type Mount struct { - Destination string `json:"destination,omitempty"` - GroupID *int `json:"group-id,omitempty"` - Mode string `json:"mode,omitempty"` - Options []string `json:"options,omitempty"` - UserID *int `json:"user-id,omitempty"` -} - -type SecurityContext struct { - ReadonlyRootFS bool `json:"readonly-root-fs,omitempty"` - RunAsGroupID *int `json:"run-as-group-id,omitempty"` - RunAsUserID *int `json:"run-as-user-id,omitempty"` - SSHD SSHD `json:"sshd,omitempty"` -} - -type SSHD struct { - Enable bool `json:"enable,omitempty"` -} - -func ValidateServices(services []string) error { - for _, svc := range services { - switch svc { - case "chrony", "ssh": - continue - default: - return fmt.Errorf("invalid service %s", svc) - } - } - return nil -} - -func p[T any](v T) *T { - return &v -} diff --git a/pkg/initial/vmspec/vmspec_test.go b/pkg/initial/vmspec/vmspec_test.go deleted file mode 100644 index 783fda6..0000000 --- a/pkg/initial/vmspec/vmspec_test.go +++ /dev/null @@ -1,706 +0,0 @@ -package vmspec - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_VMSpec_Merge(t *testing.T) { - testCases := []struct { - description string - orig *VMSpec - other *VMSpec - expected *VMSpec - }{ - { - description: "Null test case", - orig: &VMSpec{}, - other: &VMSpec{}, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - - description: "Debug enabled", - orig: &VMSpec{}, - other: &VMSpec{ - Debug: true, - }, - expected: &VMSpec{ - Debug: true, - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "ReplaceInit enabled", - orig: &VMSpec{}, - other: &VMSpec{ - ReplaceInit: true, - }, - expected: &VMSpec{ - ReplaceInit: true, - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Args and command overridden", - orig: &VMSpec{ - Args: []string{"abc"}, - Command: []string{"/usr/bin/xyz"}, - }, - other: &VMSpec{ - Args: []string{"xyz"}, - Command: []string{"/usr/bin/abc"}, - }, - expected: &VMSpec{ - Args: []string{"xyz"}, - Command: []string{"/usr/bin/abc"}, - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Args removed if command overridden", - orig: &VMSpec{ - Args: []string{"abc"}, - Command: []string{"/usr/bin/xyz"}, - }, - other: &VMSpec{ - Command: []string{"/usr/bin/abc"}, - }, - expected: &VMSpec{ - Args: nil, - Command: []string{"/usr/bin/abc"}, - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Security merged", - orig: &VMSpec{ - Security: SecurityContext{ - ReadonlyRootFS: true, - }, - }, - other: &VMSpec{ - Security: SecurityContext{ - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - SSHD: SSHD{ - Enable: true, - }, - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - ReadonlyRootFS: true, - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - SSHD: SSHD{ - Enable: true, - }, - }, - }, - }, - { - description: "Security overriding with zero values", - orig: &VMSpec{ - Security: SecurityContext{ - ReadonlyRootFS: true, - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - }, - }, - other: &VMSpec{ - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - ReadonlyRootFS: true, - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - - description: "Override disabled services", - orig: &VMSpec{ - DisableServices: []string{"chrony", "ssh"}, - }, - other: &VMSpec{ - DisableServices: []string{"ssh"}, - }, - expected: &VMSpec{ - DisableServices: []string{"ssh"}, - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Mount overriding with zero values", - orig: &VMSpec{ - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/abc", - GroupID: p(1234), - UserID: p(1234), - }, - }, - }, - }, - }, - other: &VMSpec{ - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/xyz", - GroupID: p(0), - UserID: p(0), - }, - }, - }, - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/xyz", - GroupID: p(0), - UserID: p(0), - }, - }, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Mount ownership defaults to command user and group", - orig: &VMSpec{ - Security: SecurityContext{ - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/abc", - }, - }, - }, - }, - }, - other: &VMSpec{}, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/abc", - GroupID: p(1234), - UserID: p(1234), - }, - }, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - }, - }, - }, - { - description: "Mount ownership can be explicitly set", - orig: &VMSpec{ - Security: SecurityContext{ - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/abc", - GroupID: p(4321), - UserID: p(4321), - }, - }, - }, - }, - }, - other: &VMSpec{}, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/abc", - GroupID: p(4321), - UserID: p(4321), - }, - }, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(1234), - RunAsUserID: p(1234), - }, - }, - }, - { - description: "NameValue null test case", - orig: &VMSpec{ - Env: NameValueSource{}, - }, - other: &VMSpec{ - Env: NameValueSource{}, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "NameValue overridden", - orig: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - }, - }, - other: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "yxz", - }, - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "yxz", - }, - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "NameValue empty merged into original", - orig: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - }, - }, - other: &VMSpec{ - Env: NameValueSource{}, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "NameValue PATH exists so not merged", - orig: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: "/bin:/usr/bin", - }, - }, - }, - other: &VMSpec{ - Env: NameValueSource{}, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "PATH", - Value: "/bin:/usr/bin", - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "NameValue original merged into empty", - orig: &VMSpec{ - Env: NameValueSource{}, - }, - other: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "NameValue overridden and appended", - orig: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - { - Name: "foo", - Value: "bar", - }, - }, - }, - other: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "yxz", - }, - { - Name: "bar", - Value: "foo", - }, - }, - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "yxz", - }, - { - Name: "foo", - Value: "bar", - }, - { - Name: "bar", - Value: "foo", - }, - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(0), - RunAsUserID: p(0), - }, - }, - }, - { - description: "Overall merge", - orig: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "xyz", - }, - }, - Security: SecurityContext{ - ReadonlyRootFS: true, - }, - Volumes: Volumes{ - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/secret", - }, - Path: "/ssm/path", - }, - }, - }, - }, - other: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "zyx", - }, - { - Name: "xyz", - Value: "123", - }, - }, - Security: SecurityContext{ - RunAsGroupID: p(4321), - RunAsUserID: p(1234), - SSHD: SSHD{ - Enable: true, - }, - }, - Volumes: Volumes{ - { - EBS: &EBSVolumeSource{ - Device: "/dev/sda1", - FSType: "ext4", - }, - }, - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/secret", - }, - Path: "/ssm/path", - }, - }, - }, - WorkingDir: "/tmp", - }, - expected: &VMSpec{ - Env: NameValueSource{ - { - Name: "abc", - Value: "zyx", - }, - { - Name: "xyz", - Value: "123", - }, - { - Name: "PATH", - Value: pathEnvDefault, - }, - }, - Security: SecurityContext{ - ReadonlyRootFS: true, - RunAsGroupID: p(4321), - RunAsUserID: p(1234), - SSHD: SSHD{ - Enable: true, - }, - }, - Volumes: Volumes{ - { - EBS: &EBSVolumeSource{ - Device: "/dev/sda1", - FSType: "ext4", - Mount: Mount{ - GroupID: p(4321), - UserID: p(1234), - }, - }, - }, - { - SSM: &SSMVolumeSource{ - Mount: Mount{ - Destination: "/secret", - GroupID: p(4321), - UserID: p(1234), - }, - Path: "/ssm/path", - }, - }, - }, - WorkingDir: "/tmp", - }, - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - err := tc.orig.Merge(tc.other) - assert.NoError(t, err) - assert.Equal(t, tc.expected, tc.orig) - }) - } -} - -func Test_VMSpec_Validate(t *testing.T) { - testCases := []struct { - description string - orig *VMSpec - other *VMSpec - errMsg *string - }{ - { - description: "Null test case", - orig: &VMSpec{}, - other: &VMSpec{}, - }, - { - description: "IMDS name required", - orig: &VMSpec{}, - other: &VMSpec{ - EnvFrom: EnvFromSource{ - { - IMDS: &IMDSEnvSource{}, - }, - }, - }, - errMsg: p("env-from: imds name is required"), - }, - { - description: "Multiple sources and errors", - orig: &VMSpec{}, - other: &VMSpec{ - EnvFrom: EnvFromSource{ - { - IMDS: &IMDSEnvSource{}, - S3: &S3EnvSource{}, - }, - }, - }, - errMsg: p("env-from: imds name is required\nexpected 1 environment source, got 2: imds, s3-object"), - }, - } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - err := tc.orig.Merge(tc.other) - assert.NoError(t, err) - err = tc.orig.Validate() - if tc.errMsg != nil { - assert.EqualError(t, err, *tc.errMsg) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/third_party/forked/golang/LICENSE b/third_party/forked/golang/LICENSE deleted file mode 100644 index 6a66aea..0000000 --- a/third_party/forked/golang/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/forked/golang/PATENTS b/third_party/forked/golang/PATENTS deleted file mode 100644 index 7330990..0000000 --- a/third_party/forked/golang/PATENTS +++ /dev/null @@ -1,22 +0,0 @@ -Additional IP Rights Grant (Patents) - -"This implementation" means the copyrightable works distributed by -Google as part of the Go project. - -Google hereby grants to You a perpetual, worldwide, non-exclusive, -no-charge, royalty-free, irrevocable (except as stated in this section) -patent license to make, have made, use, offer to sell, sell, import, -transfer and otherwise run, modify and propagate the contents of this -implementation of Go, where such license applies only to those patent -claims, both currently owned or controlled by Google and acquired in -the future, licensable by Google that are necessarily infringed by this -implementation of Go. This grant does not include claims that would be -infringed only as a consequence of further modification of this -implementation. If you or your agent or exclusive licensee institute or -order or agree to the institution of patent litigation against any -entity (including a cross-claim or counterclaim in a lawsuit) alleging -that this implementation of Go or any code incorporated within this -implementation of Go constitutes direct or contributory patent -infringement, or inducement of patent infringement, then any patent -rights granted to you under this License for this implementation of Go -shall terminate as of the date such litigation is filed. diff --git a/third_party/forked/golang/expansion/README.md b/third_party/forked/golang/expansion/README.md deleted file mode 100644 index c345d32..0000000 --- a/third_party/forked/golang/expansion/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# expansion - -This package is forked from k8s.io/kubernetes, which is in turn forked from the os package in go.googlesource.com/go. diff --git a/third_party/forked/golang/expansion/expand.go b/third_party/forked/golang/expansion/expand.go deleted file mode 100644 index 6bf0ea8..0000000 --- a/third_party/forked/golang/expansion/expand.go +++ /dev/null @@ -1,102 +0,0 @@ -package expansion - -import ( - "bytes" -) - -const ( - operator = '$' - referenceOpener = '(' - referenceCloser = ')' -) - -// syntaxWrap returns the input string wrapped by the expansion syntax. -func syntaxWrap(input string) string { - return string(operator) + string(referenceOpener) + input + string(referenceCloser) -} - -// MappingFuncFor returns a mapping function for use with Expand that -// implements the expansion semantics defined in the expansion spec; it -// returns the input string wrapped in the expansion syntax if no mapping -// for the input is found. -func MappingFuncFor(context ...map[string]string) func(string) string { - return func(input string) string { - for _, vars := range context { - val, ok := vars[input] - if ok { - return val - } - } - - return syntaxWrap(input) - } -} - -// Expand replaces variable references in the input string according to -// the expansion spec using the given mapping function to resolve the -// values of variables. -func Expand(input string, mapping func(string) string) string { - var buf bytes.Buffer - checkpoint := 0 - for cursor := 0; cursor < len(input); cursor++ { - if input[cursor] == operator && cursor+1 < len(input) { - // Copy the portion of the input string since the last - // checkpoint into the buffer - buf.WriteString(input[checkpoint:cursor]) - - // Attempt to read the variable name as defined by the - // syntax from the input string - read, isVar, advance := tryReadVariableName(input[cursor+1:]) - - if isVar { - // We were able to read a variable name correctly; - // apply the mapping to the variable name and copy the - // bytes into the buffer - buf.WriteString(mapping(read)) - } else { - // Not a variable name; copy the read bytes into the buffer - buf.WriteString(read) - } - - // Advance the cursor in the input string to account for - // bytes consumed to read the variable name expression - cursor += advance - - // Advance the checkpoint in the input string - checkpoint = cursor + 1 - } - } - - // Return the buffer and any remaining unwritten bytes in the - // input string. - return buf.String() + input[checkpoint:] -} - -// tryReadVariableName attempts to read a variable name from the input -// string and returns the content read from the input, whether that content -// represents a variable name to perform mapping on, and the number of bytes -// consumed in the input string. -// -// The input string is assumed not to contain the initial operator. -func tryReadVariableName(input string) (string, bool, int) { - switch input[0] { - case operator: - // Escaped operator; return it. - return input[0:1], false, 1 - case referenceOpener: - // Scan to expression closer - for i := 1; i < len(input); i++ { - if input[i] == referenceCloser { - return input[1:i], true, i + 1 - } - } - - // Incomplete reference; return it. - return string(operator) + string(referenceOpener), false, 1 - default: - // Not the beginning of an expression, ie, an operator - // that doesn't begin an expression. Return the operator - // and the first rune in the string. - return (string(operator) + string(input[0])), false, 1 - } -} diff --git a/third_party/forked/golang/expansion/expand_test.go b/third_party/forked/golang/expansion/expand_test.go deleted file mode 100644 index 6740704..0000000 --- a/third_party/forked/golang/expansion/expand_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package expansion - -import ( - "testing" - - "github.com/cloudboss/easyto/pkg/initial/vmspec" -) - -func TestMapReference(t *testing.T) { - envs := vmspec.NameValueSource{ - { - Name: "FOO", - Value: "bar", - }, - { - Name: "ZOO", - Value: "$(FOO)-1", - }, - { - Name: "BLU", - Value: "$(ZOO)-2", - }, - } - - declaredEnv := map[string]string{ - "FOO": "bar", - "ZOO": "$(FOO)-1", - "BLU": "$(ZOO)-2", - } - - serviceEnv := map[string]string{} - - mapping := MappingFuncFor(declaredEnv, serviceEnv) - - for _, env := range envs { - declaredEnv[env.Name] = Expand(env.Value, mapping) - } - - expectedEnv := map[string]string{ - "FOO": "bar", - "ZOO": "bar-1", - "BLU": "bar-1-2", - } - - for k, v := range expectedEnv { - if e, a := v, declaredEnv[k]; e != a { - t.Errorf("Expected %v, got %v", e, a) - } else { - delete(declaredEnv, k) - } - } - - if len(declaredEnv) != 0 { - t.Errorf("Unexpected keys in declared env: %v", declaredEnv) - } -} - -func TestMapping(t *testing.T) { - context := map[string]string{ - "VAR_A": "A", - "VAR_B": "B", - "VAR_C": "C", - "VAR_REF": "$(VAR_A)", - "VAR_EMPTY": "", - } - mapping := MappingFuncFor(context) - - doExpansionTest(t, mapping) -} - -func TestMappingDual(t *testing.T) { - context := map[string]string{ - "VAR_A": "A", - "VAR_EMPTY": "", - } - context2 := map[string]string{ - "VAR_B": "B", - "VAR_C": "C", - "VAR_REF": "$(VAR_A)", - } - mapping := MappingFuncFor(context, context2) - - doExpansionTest(t, mapping) -} - -func doExpansionTest(t *testing.T, mapping func(string) string) { - cases := []struct { - name string - input string - expected string - }{ - { - name: "whole string", - input: "$(VAR_A)", - expected: "A", - }, - { - name: "repeat", - input: "$(VAR_A)-$(VAR_A)", - expected: "A-A", - }, - { - name: "beginning", - input: "$(VAR_A)-1", - expected: "A-1", - }, - { - name: "middle", - input: "___$(VAR_B)___", - expected: "___B___", - }, - { - name: "end", - input: "___$(VAR_C)", - expected: "___C", - }, - { - name: "compound", - input: "$(VAR_A)_$(VAR_B)_$(VAR_C)", - expected: "A_B_C", - }, - { - name: "escape & expand", - input: "$$(VAR_B)_$(VAR_A)", - expected: "$(VAR_B)_A", - }, - { - name: "compound escape", - input: "$$(VAR_A)_$$(VAR_B)", - expected: "$(VAR_A)_$(VAR_B)", - }, - { - name: "mixed in escapes", - input: "f000-$$VAR_A", - expected: "f000-$VAR_A", - }, - { - name: "backslash escape ignored", - input: "foo\\$(VAR_C)bar", - expected: "foo\\Cbar", - }, - { - name: "backslash escape ignored", - input: "foo\\\\$(VAR_C)bar", - expected: "foo\\\\Cbar", - }, - { - name: "lots of backslashes", - input: "foo\\\\\\\\$(VAR_A)bar", - expected: "foo\\\\\\\\Abar", - }, - { - name: "nested var references", - input: "$(VAR_A$(VAR_B))", - expected: "$(VAR_A$(VAR_B))", - }, - { - name: "nested var references second type", - input: "$(VAR_A$(VAR_B)", - expected: "$(VAR_A$(VAR_B)", - }, - { - name: "value is a reference", - input: "$(VAR_REF)", - expected: "$(VAR_A)", - }, - { - name: "value is a reference x 2", - input: "%%$(VAR_REF)--$(VAR_REF)%%", - expected: "%%$(VAR_A)--$(VAR_A)%%", - }, - { - name: "empty var", - input: "foo$(VAR_EMPTY)bar", - expected: "foobar", - }, - { - name: "unterminated expression", - input: "foo$(VAR_Awhoops!", - expected: "foo$(VAR_Awhoops!", - }, - { - name: "expression without operator", - input: "f00__(VAR_A)__", - expected: "f00__(VAR_A)__", - }, - { - name: "shell special vars pass through", - input: "$?_boo_$!", - expected: "$?_boo_$!", - }, - { - name: "bare operators are ignored", - input: "$VAR_A", - expected: "$VAR_A", - }, - { - name: "undefined vars are passed through", - input: "$(VAR_DNE)", - expected: "$(VAR_DNE)", - }, - { - name: "multiple (even) operators, var undefined", - input: "$$$$$$(BIG_MONEY)", - expected: "$$$(BIG_MONEY)", - }, - { - name: "multiple (even) operators, var defined", - input: "$$$$$$(VAR_A)", - expected: "$$$(VAR_A)", - }, - { - name: "multiple (odd) operators, var undefined", - input: "$$$$$$$(GOOD_ODDS)", - expected: "$$$$(GOOD_ODDS)", - }, - { - name: "multiple (odd) operators, var defined", - input: "$$$$$$$(VAR_A)", - expected: "$$$A", - }, - { - name: "missing open expression", - input: "$VAR_A)", - expected: "$VAR_A)", - }, - { - name: "shell syntax ignored", - input: "${VAR_A}", - expected: "${VAR_A}", - }, - { - name: "trailing incomplete expression not consumed", - input: "$(VAR_B)_______$(A", - expected: "B_______$(A", - }, - { - name: "trailing incomplete expression, no content, is not consumed", - input: "$(VAR_C)_______$(", - expected: "C_______$(", - }, - { - name: "operator at end of input string is preserved", - input: "$(VAR_A)foobarzab$", - expected: "Afoobarzab$", - }, - { - name: "shell escaped incomplete expr", - input: "foo-\\$(VAR_A", - expected: "foo-\\$(VAR_A", - }, - { - name: "lots of $( in middle", - input: "--$($($($($--", - expected: "--$($($($($--", - }, - { - name: "lots of $( in beginning", - input: "$($($($($--foo$(", - expected: "$($($($($--foo$(", - }, - { - name: "lots of $( at end", - input: "foo0--$($($($(", - expected: "foo0--$($($($(", - }, - { - name: "escaped operators in variable names are not escaped", - input: "$(foo$$var)", - expected: "$(foo$$var)", - }, - { - name: "newline not expanded", - input: "\n", - expected: "\n", - }, - } - - for _, tc := range cases { - expanded := Expand(tc.input, mapping) - if e, a := tc.expected, expanded; e != a { - t.Errorf("%v: expected %q, got %q", tc.name, e, a) - } - } -}