diff --git a/pkg/build/build.go b/pkg/build/build.go index 6bbe2716..a7df8187 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -39,6 +39,11 @@ func (b *Builder) Build() error { return fmt.Errorf("configuring custom scripts: %w", err) } + err = b.configureUsers() + if err != nil { + return fmt.Errorf("configuring users: %w", err) + } + err = b.generateCombustionScript() if err != nil { return fmt.Errorf("generating combustion script: %w", err) diff --git a/pkg/build/scripts/users/add-users.sh.tpl b/pkg/build/scripts/users/add-users.sh.tpl new file mode 100644 index 00000000..5f2225a4 --- /dev/null +++ b/pkg/build/scripts/users/add-users.sh.tpl @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +# Without this, the script will run successfully during combustion, but when /home +# is mounted it will hide the /home used during these user creations. +mount /home + +{{ range . }} + +{{/* Non-root users */}} +{{ if (ne .Username "root") }} + useradd -m {{.Username}} + {{ if .Password }} + echo '{{.Username}}:{{.Password}}' | chpasswd -e + {{ end }} + {{ if .SSHKey }} + mkdir -pm700 /home/{{.Username}}/.ssh/ + echo '{{.SSHKey}}' >> /home/{{.Username}}/.ssh/authorized_keys + chown -R {{.Username}} /home/{{.Username}}/.ssh + {{ end }} +{{ end }} + +{{/* Root */}} +{{ if (eq .Username "root") }} + {{ if .Password }} + echo '{{.Username}}:{{.Password}}' | chpasswd -e + {{ end }} + {{ if .SSHKey }} + mkdir -pm700 /{{.Username}}/.ssh/ + echo '{{.SSHKey}}' >> /{{.Username}}/.ssh/authorized_keys + {{ end }} +{{ end }} + +{{ end }} \ No newline at end of file diff --git a/pkg/config/testdata/valid_example.yaml b/pkg/build/testdata/minimal-test-definition.yaml similarity index 61% rename from pkg/config/testdata/valid_example.yaml rename to pkg/build/testdata/minimal-test-definition.yaml index 7cc89d92..3f59a8f8 100644 --- a/pkg/config/testdata/valid_example.yaml +++ b/pkg/build/testdata/minimal-test-definition.yaml @@ -3,7 +3,3 @@ image: imageType: iso baseImage: slemicro5.5.iso outputImageName: eibimage.iso -operatingSystem: - kernelArgs: - - alpha=foo - - beta=bar diff --git a/pkg/build/testdata/users-test-definition.yaml b/pkg/build/testdata/users-test-definition.yaml new file mode 100644 index 00000000..5c06a171 --- /dev/null +++ b/pkg/build/testdata/users-test-definition.yaml @@ -0,0 +1,12 @@ +operatingSystem: + users: + - username: alpha + password: alpha123 + sshKey: alphakey + - username: beta + password: beta123 + - username: gamma + sshKey: gammakey + - username: root + password: root123 + sshKey: rootkey diff --git a/pkg/build/users.go b/pkg/build/users.go new file mode 100644 index 00000000..7294c5b0 --- /dev/null +++ b/pkg/build/users.go @@ -0,0 +1,35 @@ +package build + +import ( + _ "embed" + "fmt" + "os" +) + +const ( + usersScriptName = "add-users.sh" + userScriptMode = 0o744 +) + +//go:embed scripts/users/add-users.sh.tpl +var usersScript string + +func (b *Builder) configureUsers() error { + // Punch out early if there are no users + if len(b.imageConfig.OperatingSystem.Users) == 0 { + return nil + } + + filename, err := b.writeCombustionFile(usersScriptName, usersScript, b.imageConfig.OperatingSystem.Users) + if err != nil { + return fmt.Errorf("writing %s to the combustion directory: %w", usersScriptName, err) + } + err = os.Chmod(filename, userScriptMode) + if err != nil { + return fmt.Errorf("modifying permissions for script %s: %w", filename, err) + } + + b.registerCombustionScript(usersScriptName) + + return nil +} diff --git a/pkg/build/users_test.go b/pkg/build/users_test.go new file mode 100644 index 00000000..16ecf1f0 --- /dev/null +++ b/pkg/build/users_test.go @@ -0,0 +1,104 @@ +package build + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/suse-edge/edge-image-builder/pkg/config" +) + +func TestConfigureUsers(t *testing.T) { + // Setup + configData, err := os.ReadFile("./testdata/users-test-definition.yaml") + require.NoError(t, err) + imageConfig, err := config.Parse(configData) + require.NoError(t, err) + + context, err := NewContext("", "", true) + require.NoError(t, err) + defer func() { + assert.NoError(t, CleanUpBuildDir(context)) + }() + + builder := &Builder{ + imageConfig: imageConfig, + context: context, + } + + // Test + err = builder.configureUsers() + + // Verify + require.NoError(t, err) + + expectedFilename := filepath.Join(context.CombustionDir, usersScriptName) + foundBytes, err := os.ReadFile(expectedFilename) + require.NoError(t, err) + + stats, err := os.Stat(expectedFilename) + require.NoError(t, err) + assert.Equal(t, fs.FileMode(userScriptMode), stats.Mode()) + + foundContents := string(foundBytes) + + // - All fields specified + assert.Contains(t, foundContents, "useradd -m alpha") + assert.Contains(t, foundContents, "echo 'alpha:alpha123' | chpasswd -e\n") + assert.Contains(t, foundContents, "mkdir -pm700 /home/alpha/.ssh/") + assert.Contains(t, foundContents, "echo 'alphakey' >> /home/alpha/.ssh/authorized_keys") + assert.Contains(t, foundContents, "chown -R alpha /home/alpha/.ssh") + + // - Only a password set + assert.Contains(t, foundContents, "useradd -m beta") + assert.Contains(t, foundContents, "echo 'beta:beta123' | chpasswd -e\n") + assert.NotContains(t, foundContents, "mkdir -pm700 /home/beta/.ssh/") + assert.NotContains(t, foundContents, "/home/beta/.ssh/authorized_keys") + assert.NotContains(t, foundContents, "chown -R beta /home/beta/.ssh") + + // - Only an SSH key specified + assert.Contains(t, foundContents, "useradd -m gamma") + assert.NotContains(t, foundContents, "echo 'gamma:") + assert.Contains(t, foundContents, "mkdir -pm700 /home/gamma/.ssh/") + assert.Contains(t, foundContents, "echo 'gammakey' >> /home/gamma/.ssh/authorized_keys") + assert.Contains(t, foundContents, "chown -R gamma /home/gamma/.ssh") + + // - Special handling for root + assert.NotContains(t, foundContents, "useradd -m root") + assert.Contains(t, foundContents, "echo 'root:root123' | chpasswd -e\n") + assert.Contains(t, foundContents, "mkdir -pm700 /root/.ssh/") + assert.Contains(t, foundContents, "echo 'rootkey' >> /root/.ssh/authorized_keys") + assert.NotContains(t, foundContents, "chown -R root") +} + +func TestConfigureUsers_NoUsers(t *testing.T) { + // Setup + configData, err := os.ReadFile("./testdata/minimal-test-definition.yaml") + require.NoError(t, err) + imageConfig, err := config.Parse(configData) + require.NoError(t, err) + + context, err := NewContext("", "", true) + require.NoError(t, err) + defer func() { + assert.NoError(t, CleanUpBuildDir(context)) + }() + + builder := &Builder{ + imageConfig: imageConfig, + context: context, + } + + // Test + err = builder.configureUsers() + + // Verify + require.NoError(t, err) + + expectedFilename := filepath.Join(context.CombustionDir, usersScriptName) + _, err = os.ReadFile(expectedFilename) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/pkg/config/image.go b/pkg/config/image.go index 1c618590..1cf2f078 100644 --- a/pkg/config/image.go +++ b/pkg/config/image.go @@ -24,7 +24,14 @@ type Image struct { } type OperatingSystem struct { - KernelArgs []string `yaml:"kernelArgs"` + KernelArgs []string `yaml:"kernelArgs"` + Users []OperatingSystemUser `yaml:"users"` +} + +type OperatingSystemUser struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + SSHKey string `yaml:"sshKey"` } func Parse(data []byte) (*ImageConfig, error) { diff --git a/pkg/config/image_test.go b/pkg/config/image_test.go index b7488b20..ea4e8145 100644 --- a/pkg/config/image_test.go +++ b/pkg/config/image_test.go @@ -10,7 +10,7 @@ import ( func TestParse(t *testing.T) { // Setup - filename := "./testdata/valid_example.yaml" + filename := "./testdata/full-valid-example.yaml" configData, err := os.ReadFile(filename) require.NoError(t, err) @@ -29,6 +29,18 @@ func TestParse(t *testing.T) { "beta=bar", } assert.Equal(t, expectedKernelArgs, imageConfig.OperatingSystem.KernelArgs) + + userConfigs := imageConfig.OperatingSystem.Users + assert.Len(t, userConfigs, 3) + assert.Equal(t, "alpha", userConfigs[0].Username) + assert.Equal(t, "$6$bZfTI3Wj05fdxQcB$W1HJQTKw/MaGTCwK75ic9putEquJvYO7vMnDBVAfuAMFW58/79abky4mx9.8znK0UZwSKng9dVosnYQR1toH71", userConfigs[0].Password) + assert.Contains(t, userConfigs[0].SSHKey, "ssh-rsa AAAAB3") + assert.Equal(t, "beta", userConfigs[1].Username) + assert.Equal(t, "$6$GHjiVHm2AT.Qxznz$1CwDuEBM1546E/sVE1Gn1y4JoGzW58wrckyx3jj2QnphFmceS6b/qFtkjw1cp7LSJNW1OcLe/EeIxDDHqZU6o1", userConfigs[1].Password) + assert.Equal(t, "", userConfigs[1].SSHKey) + assert.Equal(t, "gamma", userConfigs[2].Username) + assert.Equal(t, "", userConfigs[2].Password) + assert.Contains(t, userConfigs[2].SSHKey, "ssh-rsa BBBBB3") } func TestParseBadConfig(t *testing.T) { diff --git a/pkg/config/testdata/full-valid-example.yaml b/pkg/config/testdata/full-valid-example.yaml new file mode 100644 index 00000000..c194230b --- /dev/null +++ b/pkg/config/testdata/full-valid-example.yaml @@ -0,0 +1,17 @@ +apiVersion: 1.0 +image: + imageType: iso + baseImage: slemicro5.5.iso + outputImageName: eibimage.iso +operatingSystem: + kernelArgs: + - alpha=foo + - beta=bar + users: + - username: alpha + password: $6$bZfTI3Wj05fdxQcB$W1HJQTKw/MaGTCwK75ic9putEquJvYO7vMnDBVAfuAMFW58/79abky4mx9.8znK0UZwSKng9dVosnYQR1toH71 + sshKey: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain + - username: beta + password: $6$GHjiVHm2AT.Qxznz$1CwDuEBM1546E/sVE1Gn1y4JoGzW58wrckyx3jj2QnphFmceS6b/qFtkjw1cp7LSJNW1OcLe/EeIxDDHqZU6o1 + - username: gamma + sshKey: ssh-rsa BBBBB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain