diff options
-rw-r--r-- | blubber.example.yaml | 4 | ||||
-rw-r--r-- | build/instructions.go | 29 | ||||
-rw-r--r-- | build/instructions_test.go | 12 | ||||
-rw-r--r-- | build/macros.go | 60 | ||||
-rw-r--r-- | build/macros_test.go | 61 | ||||
-rw-r--r-- | config/common.go | 16 | ||||
-rw-r--r-- | config/lives.go | 54 | ||||
-rw-r--r-- | config/lives_test.go | 194 | ||||
-rw-r--r-- | config/reader.go | 18 | ||||
-rw-r--r-- | config/runs.go | 84 | ||||
-rw-r--r-- | config/runs_test.go | 88 | ||||
-rw-r--r-- | config/user.go | 25 | ||||
-rw-r--r-- | config/variant.go | 33 | ||||
-rw-r--r-- | config/variant_test.go | 10 | ||||
-rw-r--r-- | docker/compiler.go | 11 | ||||
-rw-r--r-- | docker/instructions.go | 33 | ||||
-rw-r--r-- | docker/instructions_test.go | 24 |
17 files changed, 574 insertions, 182 deletions
diff --git a/blubber.example.yaml b/blubber.example.yaml index 894472f..07e1a86 100644 --- a/blubber.example.yaml +++ b/blubber.example.yaml @@ -3,10 +3,6 @@ base: debian:jessie apt: packages: [libjpeg, libyaml] runs: - in: /srv/service - as: runuser - uid: 666 - gid: 666 environment: FOO: bar BAR: baz diff --git a/build/instructions.go b/build/instructions.go index 3035b9f..91caa6b 100644 --- a/build/instructions.go +++ b/build/instructions.go @@ -78,6 +78,22 @@ func (copy Copy) Compile() []string { return append(quoteAll(copy.Sources), quote(copy.Destination)) } +// CopyAs is a concrete build instruction for copying source +// files/directories and setting their ownership to the given UID/GID. +// +type CopyAs struct { + UID uint // owner UID + GID uint // owner GID + Copy +} + +// Compile returns the variant name unquoted and all quoted CopyAs instruction +// fields. +// +func (ca CopyAs) Compile() []string { + return append([]string{fmt.Sprintf("%d:%d", ca.UID, ca.GID)}, ca.Copy.Compile()...) +} + // CopyFrom is a concrete build instruction for copying source // files/directories from one variant image to another. // @@ -121,6 +137,19 @@ func (label Label) Compile() []string { return compileSortedKeyValues(label.Definitions) } +// User is a build instruction for setting which user will run future +// commands. +// +type User struct { + Name string // user name +} + +// Compile returns the quoted user name. +// +func (user User) Compile() []string { + return []string{quote(user.Name)} +} + // Volume is a concrete build instruction for defining a volume mount point // within the container. // diff --git a/build/instructions_test.go b/build/instructions_test.go index 4098711..f6e3628 100644 --- a/build/instructions_test.go +++ b/build/instructions_test.go @@ -36,6 +36,12 @@ func TestCopy(t *testing.T) { assert.Equal(t, []string{`"source1"`, `"source2"`, `"dest"`}, i.Compile()) } +func TestCopyAs(t *testing.T) { + i := build.CopyAs{123, 124, build.Copy{[]string{"source1", "source2"}, "dest"}} + + assert.Equal(t, []string{"123:124", `"source1"`, `"source2"`, `"dest"`}, i.Compile()) +} + func TestCopyFrom(t *testing.T) { i := build.CopyFrom{"foo", build.Copy{[]string{"source1", "source2"}, "dest"}} @@ -70,6 +76,12 @@ func TestLabel(t *testing.T) { }, i.Compile()) } +func TestUser(t *testing.T) { + i := build.User{"foo"} + + assert.Equal(t, []string{`"foo"`}, i.Compile()) +} + func TestVolume(t *testing.T) { i := build.Volume{"/foo/dir"} diff --git a/build/macros.go b/build/macros.go new file mode 100644 index 0000000..5d3422e --- /dev/null +++ b/build/macros.go @@ -0,0 +1,60 @@ +package build + +import ( + "fmt" +) + +// ApplyUser wraps any build.Copy instructions as build.CopyAs using the given +// UID/GID. +// +func ApplyUser(uid uint, gid uint, instructions []Instruction) []Instruction { + applied := make([]Instruction, len(instructions)) + + for i, instruction := range instructions { + if copy, iscopy := instruction.(Copy); iscopy { + applied[i] = CopyAs{uid, gid, copy} + } else { + applied[i] = instruction + } + } + + return applied +} + +// Chown returns a build.Run instruction for setting ownership on the given +// path. +// +func Chown(uid uint, gid uint, path string) Run { + return Run{"chown %s:%s", []string{fmt.Sprint(uid), fmt.Sprint(gid), path}} +} + +// CreateDirectory returns a build.Run instruction for creating the given +// directory. +// +func CreateDirectory(path string) Run { + return Run{"mkdir -p", []string{path}} +} + +// CreateUser returns build.Run instructions for creating the given user +// account and group. +// +func CreateUser(name string, uid uint, gid uint) []Run { + return []Run{ + {"groupadd -o -g %s -r", []string{fmt.Sprint(gid), name}}, + {"useradd -o -m -d %s -r -g %s -u %s", []string{homeDir(name), name, fmt.Sprint(uid), name}}, + } +} + +// Home returns a build.Env instruction for setting the user's home directory. +// +func Home(name string) Env { + return Env{map[string]string{"HOME": homeDir(name)}} +} + +func homeDir(name string) string { + if name == "root" { + return "/root" + } + + return "/home/" + name +} diff --git a/build/macros_test.go b/build/macros_test.go new file mode 100644 index 0000000..e47cf8d --- /dev/null +++ b/build/macros_test.go @@ -0,0 +1,61 @@ +package build_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "phabricator.wikimedia.org/source/blubber/build" +) + +func TestApplyUser(t *testing.T) { + instructions := []build.Instruction{ + build.Copy{[]string{"foo"}, "bar"}, + build.Copy{[]string{"baz"}, "qux"}, + } + + assert.Equal(t, + []build.Instruction{ + build.CopyAs{123, 223, build.Copy{[]string{"foo"}, "bar"}}, + build.CopyAs{123, 223, build.Copy{[]string{"baz"}, "qux"}}, + }, + build.ApplyUser(123, 223, instructions), + ) +} + +func TestChown(t *testing.T) { + i := build.Chown(123, 124, "/foo") + + assert.Equal(t, []string{`chown "123":"124" "/foo"`}, i.Compile()) +} + +func TestCreateDirectory(t *testing.T) { + i := build.CreateDirectory("/foo") + + assert.Equal(t, []string{`mkdir -p "/foo"`}, i.Compile()) +} + +func TestCreateUser(t *testing.T) { + i := build.CreateUser("foo", 123, 124) + + if assert.Len(t, i, 2) { + assert.Equal(t, []string{`groupadd -o -g "124" -r "foo"`}, i[0].Compile()) + assert.Equal(t, []string{`useradd -o -m -d "/home/foo" -r -g "foo" -u "123" "foo"`}, i[1].Compile()) + } +} + +func TestHome(t *testing.T) { + t.Run("root", func(t *testing.T) { + assert.Equal(t, + build.Env{map[string]string{"HOME": "/root"}}, + build.Home("root"), + ) + }) + + t.Run("non-root", func(t *testing.T) { + assert.Equal(t, + build.Env{map[string]string{"HOME": "/home/foo"}}, + build.Home("foo"), + ) + }) +} diff --git a/config/common.go b/config/common.go index 668b543..fa100b8 100644 --- a/config/common.go +++ b/config/common.go @@ -8,12 +8,13 @@ import ( // and each configured variant. // type CommonConfig struct { - Base string `yaml:"base" validate:"omitempty,baseimage"` // name/path to base image - Apt AptConfig `yaml:"apt"` // APT related configuration - Node NodeConfig `yaml:"node"` // Node related configuration - Runs RunsConfig `yaml:"runs"` // runtime environment configuration - SharedVolume Flag `yaml:"sharedvolume"` // define a volume for application - EntryPoint []string `yaml:"entrypoint"` // entry-point executable + Base string `yaml:"base" validate:"omitempty,baseimage"` // name/path to base image + Apt AptConfig `yaml:"apt"` // APT related configuration + Node NodeConfig `yaml:"node"` // Node related configuration + Lives LivesConfig `yaml:"lives"` // application owner/dir configuration + Runs RunsConfig `yaml:"runs"` // runtime environment configuration + SharedVolume Flag `yaml:"sharedvolume"` // define a volume for application + EntryPoint []string `yaml:"entrypoint"` // entry-point executable } // Merge takes another CommonConfig and merges its fields this one's. @@ -25,6 +26,7 @@ func (cc *CommonConfig) Merge(cc2 CommonConfig) { cc.Apt.Merge(cc2.Apt) cc.Node.Merge(cc2.Node) + cc.Lives.Merge(cc2.Lives) cc.Runs.Merge(cc2.Runs) cc.SharedVolume.Merge(cc2.SharedVolume) @@ -38,7 +40,7 @@ func (cc *CommonConfig) Merge(cc2 CommonConfig) { // injected. // func (cc *CommonConfig) PhaseCompileableConfig() []build.PhaseCompileable { - return []build.PhaseCompileable{cc.Apt, cc.Node, cc.Runs} + return []build.PhaseCompileable{cc.Apt, cc.Node, cc.Lives, cc.Runs} } // InstructionsForPhase injects instructions into the given build phase for diff --git a/config/lives.go b/config/lives.go new file mode 100644 index 0000000..8d705e7 --- /dev/null +++ b/config/lives.go @@ -0,0 +1,54 @@ +package config + +import ( + "phabricator.wikimedia.org/source/blubber/build" +) + +// LocalLibPrefix declares the shared directory into which application level +// dependencies will be installed. +// +const LocalLibPrefix = "/opt/lib" + +// LivesConfig holds configuration fields related to the livesship of +// installed dependencies and application files. +// +type LivesConfig struct { + In string `yaml:"in" validate:"omitempty,abspath"` // application directory + UserConfig `yaml:",inline"` +} + +// Merge takes another LivesConfig and overwrites this struct's fields. +// +func (lives *LivesConfig) Merge(lives2 LivesConfig) { + if lives2.In != "" { + lives.In = lives2.In + } + + lives.UserConfig.Merge(lives2.UserConfig) +} + +// InstructionsForPhase injects build instructions related to creation of the +// application lives. +// +// PhasePrivileged +// +// Creates LocalLibPrefix directory and application lives's user home +// directory, creates the lives user and its group, and sets up directory +// permissions. +// +func (lives LivesConfig) InstructionsForPhase(phase build.Phase) []build.Instruction { + switch phase { + case build.PhasePrivileged: + return []build.Instruction{build.RunAll{ + append( + build.CreateUser(lives.As, lives.UID, lives.GID), + build.CreateDirectory(lives.In), + build.Chown(lives.UID, lives.GID, lives.In), + build.CreateDirectory(LocalLibPrefix), + build.Chown(lives.UID, lives.GID, LocalLibPrefix), + ), + }} + } + + return []build.Instruction{} +} diff --git a/config/lives_test.go b/config/lives_test.go new file mode 100644 index 0000000..eeb91ef --- /dev/null +++ b/config/lives_test.go @@ -0,0 +1,194 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "phabricator.wikimedia.org/source/blubber/build" + "phabricator.wikimedia.org/source/blubber/config" +) + +func TestLivesConfig(t *testing.T) { + cfg, err := config.ReadConfig([]byte(`--- + base: foo + lives: + in: /some/directory + as: foouser + uid: 123 + gid: 223 + variants: + development: {}`)) + + if assert.NoError(t, err) { + assert.Equal(t, "/some/directory", cfg.Lives.In) + assert.Equal(t, "foouser", cfg.Lives.As) + assert.Equal(t, uint(123), cfg.Lives.UID) + assert.Equal(t, uint(223), cfg.Lives.GID) + + variant, err := config.ExpandVariant(cfg, "development") + + if assert.NoError(t, err) { + assert.Equal(t, "/some/directory", variant.Lives.In) + assert.Equal(t, "foouser", variant.Lives.As) + assert.Equal(t, uint(123), variant.Lives.UID) + assert.Equal(t, uint(223), variant.Lives.GID) + } + } +} + +func TestLivesConfigDefaults(t *testing.T) { + cfg, err := config.ReadConfig([]byte(`--- + base: foo`)) + + if assert.NoError(t, err) { + assert.Equal(t, "somebody", cfg.Lives.As) + assert.Equal(t, uint(65533), cfg.Lives.UID) + assert.Equal(t, uint(65533), cfg.Lives.GID) + } +} + +func TestLivesConfigInstructions(t *testing.T) { + cfg := config.LivesConfig{ + In: "/some/directory", + UserConfig: config.UserConfig{ + As: "foouser", + UID: 123, + GID: 223, + }, + } + + t.Run("PhasePrivileged", func(t *testing.T) { + assert.Equal(t, + []build.Instruction{build.RunAll{[]build.Run{ + {"groupadd -o -g %s -r", []string{"223", "foouser"}}, + {"useradd -o -m -d %s -r -g %s -u %s", []string{"/home/foouser", "foouser", "123", "foouser"}}, + {"mkdir -p", []string{"/some/directory"}}, + {"chown %s:%s", []string{"123", "223", "/some/directory"}}, + {"mkdir -p", []string{"/opt/lib"}}, + {"chown %s:%s", []string{"123", "223", "/opt/lib"}}, + }}}, + cfg.InstructionsForPhase(build.PhasePrivileged), + ) + }) + + t.Run("PhasePrivilegeDropped", func(t *testing.T) { + assert.Empty(t, cfg.InstructionsForPhase(build.PhasePreInstall)) + }) + + t.Run("PhasePreInstall", func(t *testing.T) { + assert.Empty(t, cfg.InstructionsForPhase(build.PhasePreInstall)) + }) + + t.Run("PhasePostInstall", func(t *testing.T) { + assert.Empty(t, cfg.InstructionsForPhase(build.PhasePostInstall)) + }) +} + +func TestLivesConfigValidation(t *testing.T) { + t.Run("in", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + in: /foo`)) + + assert.False(t, config.IsValidationError(err)) + }) + + t.Run("optional", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: {}`)) + + assert.False(t, config.IsValidationError(err)) + }) + + t.Run("non-root", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + in: /`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `in: "/" is not a valid absolute non-root path`, msg) + } + }) + + t.Run("non-root tricky", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + in: /foo/..`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `in: "/foo/.." is not a valid absolute non-root path`, msg) + } + }) + + t.Run("absolute", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + in: foo/bar`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `in: "foo/bar" is not a valid absolute non-root path`, msg) + } + }) + }) + + t.Run("as", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + as: foo-bar.baz`)) + + assert.False(t, config.IsValidationError(err)) + }) + + t.Run("optional", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: {}`)) + + assert.False(t, config.IsValidationError(err)) + }) + + t.Run("no spaces", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + as: foo bar`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `as: "foo bar" is not a valid user name`, msg) + } + }) + + t.Run("long enough", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + as: fo`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `as: "fo" is not a valid user name`, msg) + } + }) + + t.Run("not root", func(t *testing.T) { + _, err := config.ReadConfig([]byte(`--- + lives: + as: root`)) + + if assert.True(t, config.IsValidationError(err)) { + msg := config.HumanizeValidationError(err) + + assert.Equal(t, `as: "root" is not a valid user name`, msg) + } + }) + }) +} diff --git a/config/reader.go b/config/reader.go index bb28603..378d523 100644 --- a/config/reader.go +++ b/config/reader.go @@ -8,6 +8,20 @@ import ( "gopkg.in/yaml.v2" ) +// DefaultConfig contains YAML that is applied before the user's +// configuration. +// +const DefaultConfig = `--- +lives: + in: /srv/app + as: somebody + uid: 65533 + gid: 65533 +runs: + as: runuser + uid: 900 + gid: 900` + // ResolveIncludes iterates over and recurses through a given variant's // includes to build a flat slice of variant names in the correct order by // which they should be expanded/merged. It checks for both the existence of @@ -74,11 +88,13 @@ func ExpandVariant(config *Config, name string) (*VariantConfig, error) { return expanded, nil } -// ReadConfig unmarshals the given YAML bytes into a Config struct. +// ReadConfig unmarshals the given YAML bytes into a new Config struct. // func ReadConfig(data []byte) (*Config, error) { var config Config + yaml.Unmarshal([]byte(DefaultConfig), &config) + err := yaml.Unmarshal(data, &config) if err != nil { diff --git a/config/runs.go b/config/runs.go index 6769de2..a4147e5 100644 --- a/config/runs.go +++ b/config/runs.go @@ -1,25 +1,15 @@ package config import ( - "fmt" - "phabricator.wikimedia.org/source/blubber/build" ) -// LocalLibPrefix declares the shared directory into which application level -// dependencies will be installed. -// -const LocalLibPrefix = "/opt/lib" - // RunsConfig holds configuration fields related to the application's // runtime environment. // type RunsConfig struct { - In string `yaml:"in" validate:"omitempty,abspath"` // working directory - As string `yaml:"as" validate:"omitempty,username"` // unprivileged user - UID uint `yaml:"uid"` // unprivileged user ID - GID uint `yaml:"gid"` // unprivileged group ID - Environment map[string]string `yaml:"environment" validate:"envvars"` // environment variables + UserConfig `yaml:",inline"` + Environment map[string]string `yaml:"environment" validate:"envvars"` // environment variables } // Merge takes another RunsConfig and overwrites this struct's fields. All @@ -27,18 +17,7 @@ type RunsConfig struct { // merge. // func (run *RunsConfig) Merge(run2 RunsConfig) { - if run2.In != "" { - run.In = run2.In - } - if run2.As != "" { - run.As = run2.As - } - if run2.UID != 0 { - run.UID = run2.UID - } - if run2.GID != 0 { - run.GID = run2.GID - } + run.UserConfig.Merge(run2.UserConfig) if run.Environment == nil { run.Environment = make(map[string]string) @@ -49,17 +28,6 @@ func (run *RunsConfig) Merge(run2 RunsConfig) { } } -// Home returns the home directory for the configured user, or /root if no -// user is set. -// -func (run RunsConfig) Home() string { - if run.As == "" { - return "/root" - } - - return "/home/" + run.As -} - // InstructionsForPhase injects build instructions related to the runtime // configuration. // @@ -71,52 +39,22 @@ func (run RunsConfig) Home() string { // // PhasePrivilegeDropped // -// Injects build.Env instructions for the user home directory and all -// names/values defined by RunsConfig.Environment. +// Injects build.Env instructions for all names/values defined by +// RunsConfig.Environment. // func (run RunsConfig) InstructionsForPhase(phase build.Phase) []build.Instruction { - ins := []build.Instruction{} - switch phase { case build.PhasePrivileged: - runAll := build.RunAll{[]build.Run{ - {"mkdir -p", []string{LocalLibPrefix}}, + return []build.Instruction{build.RunAll{ + build.CreateUser(run.As, run.UID, run.GID), }} - - if run.In != "" { - runAll.Runs = append(runAll.Runs, - build.Run{"mkdir -p", []string{run.In}}, - ) - } - - if run.As != "" { - runAll.Runs = append(runAll.Runs, - build.Run{"groupadd -o -g %s -r", - []string{fmt.Sprint(run.GID), run.As}}, - build.Run{"useradd -o -m -d %s -r -g %s -u %s", - []string{run.Home(), run.As, fmt.Sprint(run.UID), run.As}}, - build.Run{"chown %s:%s", - []string{run.As, run.As, LocalLibPrefix}}, - ) - - if run.In != "" { - runAll.Runs = append(runAll.Runs, - build.Run{"chown %s:%s", - []string{run.As, run.As, run.In}}, - ) - } - } - - if len(runAll.Runs) > 0 { - ins = append(ins, runAll) - } case build.PhasePrivilegeDropped: - ins = append(ins, build.Env{map[string]string{"HOME": run.Home()}}) - if len(run.Environment) > 0 { - ins = append(ins, build.Env{run.Environment}) + return []build.Instruction{ + build.Env{run.Environment}, + } } } - return ins + return []build.Instruction{} } diff --git a/config/runs_test.go b/config/runs_test.go index 0b7d26a..4d46eb4 100644 --- a/config/runs_test.go +++ b/config/runs_test.go @@ -14,7 +14,6 @@ func TestRunsConfig(t *testing.T) { base: foo runs: as: someuser - in: /some/directory uid: 666 gid: 777 environment: { FOO: bar } @@ -28,30 +27,18 @@ func TestRunsConfig(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "someuser", variant.Runs.As) - assert.Equal(t, "/some/directory", variant.Runs.In) assert.Equal(t, uint(666), variant.Runs.UID) assert.Equal(t, uint(777), variant.Runs.GID) assert.Equal(t, map[string]string{"FOO": "bar"}, variant.Runs.Environment) } -func TestRunsHomeWithUser(t *testing.T) { - runs := config.RunsConfig{As: "someuser"} - - assert.Equal(t, "/home/someuser", runs.Home()) -} - -func TestRunsHomeWithoutUser(t *testing.T) { - runs := config.RunsConfig{} - - assert.Equal(t, "/root", runs.Home()) -} - func TestRunsConfigInstructions(t *testing.T) { cfg := config.RunsConfig{ - As: "someuser", - In: "/some/directory", - UID: 666, - GID: 777, + UserConfig: config.UserConfig{ + As: "someuser", + UID: 666, + GID: 777, + }, Environment: map[string]string{ "fooname": "foovalue", "barname": "barvalue", @@ -61,12 +48,8 @@ func TestRunsConfigInstructions(t *testing.T) { t.Run("PhasePrivileged", func(t *testing.T) { assert.Equal(t, []build.Instruction{build.RunAll{[]build.Run{ - {"mkdir -p", []string{"/opt/lib"}}, - {"mkdir -p", []string{"/some/directory"}}, {"groupadd -o -g %s -r", []string{"777", "someuser"}}, {"useradd -o -m -d %s -r -g %s -u %s", []string{"/home/someuser", "someuser", "666", "someuser"}}, - {"chown %s:%s", []string{"someuser", "someuser", "/opt/lib"}}, - {"chown %s:%s", []string{"someuser", "someuser", "/some/directory"}}, }}}, cfg.InstructionsForPhase(build.PhasePrivileged), ) @@ -75,7 +58,6 @@ func TestRunsConfigInstructions(t *testing.T) { t.Run("PhasePrivilegeDropped", func(t *testing.T) { assert.Equal(t, []build.Instruction{ - build.Env{map[string]string{"HOME": "/home/someuser"}}, build.Env{map[string]string{"barname": "barvalue", "fooname": "foovalue"}}, }, cfg.InstructionsForPhase(build.PhasePrivilegeDropped), @@ -84,12 +66,7 @@ func TestRunsConfigInstructions(t *testing.T) { t.Run("with empty Environment", func(t *testing.T) { cfg.Environment = map[string]string{} - assert.Equal(t, - []build.Instruction{ - build.Env{map[string]string{"HOME": "/home/someuser"}}, - }, - cfg.InstructionsForPhase(build.PhasePrivilegeDropped), - ) + assert.Empty(t, cfg.InstructionsForPhase(build.PhasePrivilegeDropped)) }) }) @@ -103,59 +80,6 @@ func TestRunsConfigInstructions(t *testing.T) { } func TestRunsConfigValidation(t *testing.T) { - t.Run("in", func(t *testing.T) { - t.Run("ok", func(t *testing.T) { - _, err := config.ReadConfig([]byte(`--- - runs: - in: /foo`)) - - assert.False(t, config.IsValidationError(err)) - }) - - t.Run("optional", func(t *testing.T) { - _, err := config.ReadConfig([]byte(`--- - runs: {}`)) - - assert.False(t, config.IsValidationError(err)) - }) - - t.Run("non-root", func(t *testing.T) { - _, err := config.ReadConfig([]byte(`--- - runs: - in: /`)) - - if assert.True(t, config.IsValidationError(err)) { - msg := config.HumanizeValidationError(err) - - assert.Equal(t, `in: "/" is not a valid absolute non-root path`, msg) - } - }) - - t.Run("non-root tricky", func(t *testing.T) { - _, err := config.ReadConfig([]byte(`--- - runs: - in: /foo/..`)) - - if assert.True(t, config.IsValidationError(err)) { - msg := config.HumanizeValidationError(err) - - assert.Equal(t, `in: "/foo/.." is not a valid absolute non-root path`, msg) - } - }) - - t.Run("absolute", func(t *testing.T) { - _, err := config.ReadConfig([]byte(`--- - runs: - in: foo/bar`)) - - if assert.True(t, config.IsValidationError(err)) { - msg := config.HumanizeValidationError(err) - - assert.Equal(t, `in: "foo/bar" is not a valid absolute non-root path`, msg) - } - }) - }) - t.Run("as", func(t *testing.T) { t.Run("ok", func(t *testing.T) { _, err := config.ReadConfig([]byte(`--- diff --git a/config/user.go b/config/user.go new file mode 100644 index 0000000..f42fbf7 --- /dev/null +++ b/config/user.go @@ -0,0 +1,25 @@ +package config + +// UserConfig holds configuration fields related to a user account. +// +type UserConfig struct { + As string `yaml:"as" validate:"omitempty,username"` // user name + UID uint `yaml:"uid"` // user ID + GID uint `yaml:"gid"` // group ID +} + +// Merge takes another UserConfig and overwrites this struct's fields. +// +func (user *UserConfig) Merge(user2 UserConfig) { + if user2.As != "" { + user.As = user2.As + } + + if user2.UID != 0 { + user.UID = user2.UID + } + + if user2.GID != 0 { + user.GID = user2.GID + } +} diff --git a/config/variant.go b/config/variant.go index 2f7be21..0cb3f09 100644 --- a/config/variant.go +++ b/config/variant.go @@ -41,16 +41,43 @@ func (vc *VariantConfig) InstructionsForPhase(phase build.Phase) []build.Instruc } instructions = append(ainstructions, instructions...) + var switchUser string switch phase { + case build.PhasePrivileged: + switchUser = "root" + + case build.PhasePrivilegeDropped: + switchUser = vc.Lives.As + instructions = build.ApplyUser(vc.Lives.UID, vc.Lives.GID, instructions) + + case build.PhasePreInstall: + instructions = build.ApplyUser(vc.Lives.UID, vc.Lives.GID, instructions) + case build.PhaseInstall: if vc.Copies == "" { if vc.SharedVolume.True { - instructions = append(instructions, build.Volume{vc.Runs.In}) + instructions = append(instructions, build.Volume{vc.Lives.In}) } else { instructions = append(instructions, build.Copy{[]string{"."}, "."}) } } + + instructions = build.ApplyUser(vc.Lives.UID, vc.Lives.GID, instructions) + + case build.PhasePostInstall: + switchUser = vc.Runs.As + instructions = build.ApplyUser(vc.Runs.UID, vc.Runs.GID, instructions) + } + + if switchUser != "" { + instructions = append( + []build.Instruction{ + build.User{switchUser}, + build.Home(switchUser), + }, + instructions..., + ) } return instructions @@ -83,8 +110,8 @@ func (vc *VariantConfig) defaultArtifacts() []ArtifactsConfig { return []ArtifactsConfig{ { From: vc.Copies, - Source: vc.Runs.In, - Destination: vc.Runs.In, + Source: vc.Lives.In, + Destination: vc.Lives.In, }, { From: vc.Copies, diff --git a/config/variant_test.go b/config/variant_test.go index 3f58c39..6890f6d 100644 --- a/config/variant_test.go +++ b/config/variant_test.go @@ -75,7 +75,7 @@ func TestVariantConfigInstructions(t *testing.T) { t.Run("shared volume", func(t *testing.T) { cfg := config.VariantConfig{} - cfg.Runs.In = "/srv/service" + cfg.Lives.In = "/srv/service" cfg.SharedVolume.True = true assert.Equal(t, @@ -88,10 +88,12 @@ func TestVariantConfigInstructions(t *testing.T) { t.Run("standard source copy", func(t *testing.T) { cfg := config.VariantConfig{} + cfg.Lives.UID = 123 + cfg.Lives.GID = 223 assert.Equal(t, []build.Instruction{ - build.Copy{[]string{"."}, "."}, + build.CopyAs{123, 223, build.Copy{[]string{"."}, "."}}, }, cfg.InstructionsForPhase(build.PhaseInstall), ) @@ -105,7 +107,7 @@ func TestVariantConfigInstructions(t *testing.T) { Artifacts: []config.ArtifactsConfig{ {From: "build", Source: "/foo/src", Destination: "/foo/dst"}, }, - CommonConfig: config.CommonConfig{Runs: config.RunsConfig{In: "/srv/service"}}, + CommonConfig: config.CommonConfig{Lives: config.LivesConfig{In: "/srv/service"}}, } assert.Equal(t, @@ -123,7 +125,7 @@ func TestVariantConfigInstructions(t *testing.T) { Artifacts: []config.ArtifactsConfig{ {From: "build", Source: "/foo/src", Destination: "/foo/dst"}, }, - CommonConfig: config.CommonConfig{Runs: config.RunsConfig{In: "/srv/service"}}, + CommonConfig: config.CommonConfig{Lives: config.LivesConfig{In: "/srv/service"}}, } assert.Equal(t, diff --git a/docker/compiler.go b/docker/compiler.go index 68f9384..5929692 100644 --- a/docker/compiler.go +++ b/docker/compiler.go @@ -36,6 +36,7 @@ func Compile(cfg *config.Config, variant string) (*bytes.Buffer, error) { if err != nil { return nil, err } + compileStage(buffer, stage, dependency) mainStage = variant } @@ -60,18 +61,12 @@ func compileStage(buffer *bytes.Buffer, stage string, vcfg *config.VariantConfig writeln(buffer, "FROM ", baseAndStage) - writeln(buffer, "USER root") - compilePhase(buffer, vcfg, build.PhasePrivileged) - if vcfg.Runs.As != "" { - writeln(buffer, "USER ", vcfg.Runs.As) - } - compilePhase(buffer, vcfg, build.PhasePrivilegeDropped) - if vcfg.Runs.In != "" { - writeln(buffer, "WORKDIR ", vcfg.Runs.In) + if vcfg.Lives.In != "" { + writeln(buffer, "WORKDIR ", vcfg.Lives.In) } compilePhase(buffer, vcfg, build.PhasePreInstall) diff --git a/docker/instructions.go b/docker/instructions.go index 52c281a..d6e560f 100644 --- a/docker/instructions.go +++ b/docker/instructions.go @@ -23,6 +23,10 @@ func NewInstruction(instruction build.Instruction) (Instruction, error) { var dockerInstruction Copy dockerInstruction.arguments = instruction.Compile() return dockerInstruction, nil + case build.CopyAs: + var dockerInstruction CopyAs + dockerInstruction.arguments = instruction.Compile() + return dockerInstruction, nil case build.CopyFrom: var dockerInstruction CopyFrom dockerInstruction.arguments = instruction.Compile() @@ -35,6 +39,10 @@ func NewInstruction(instruction build.Instruction) (Instruction, error) { var dockerInstruction Label dockerInstruction.arguments = instruction.Compile() return dockerInstruction, nil + case build.User: + var dockerInstruction User + dockerInstruction.arguments = instruction.Compile() + return dockerInstruction, nil case build.Volume: var dockerInstruction Volume dockerInstruction.arguments = instruction.Compile() @@ -83,6 +91,19 @@ func (dc Copy) Compile() string { join(dc.arguments, ", ")) } +// CopyAs compiles into a COPY --chown instruction. +// +type CopyAs struct{ abstractInstruction } + +// Compile compiles COPY --chown instructions. +// +func (dca CopyAs) Compile() string { + return fmt.Sprintf( + "COPY --chown=%s [%s]\n", + dca.arguments[0], + join(dca.arguments[1:], ", ")) +} + // CopyFrom compiles into a COPY --from instruction. // type CopyFrom struct{ abstractInstruction } @@ -121,6 +142,18 @@ func (dl Label) Compile() string { join(dl.arguments, " ")) } +// User compiles into a USER instruction. +// +type User struct{ abstractInstruction } + +// Compile compiles USER instructions. +// +func (du User) Compile() string { + return fmt.Sprintf( + "USER %s\n", + join(du.arguments, ", ")) +} + // Volume compiles into a VOLUME instruction. // type Volume struct{ abstractInstruction } diff --git a/docker/instructions_test.go b/docker/instructions_test.go index 181ab71..651e0bd 100644 --- a/docker/instructions_test.go +++ b/docker/instructions_test.go @@ -47,6 +47,18 @@ func TestCopy(t *testing.T) { assert.Equal(t, "COPY [\"foo1\", \"foo2\", \"bar\"]\n", di.Compile()) } +func TestCopyAs(t *testing.T) { + i := build.CopyAs{123, 124, build.Copy{[]string{"foo1", "foo2"}, "bar"}} + + di, err := docker.NewInstruction(i) + + var dockerCopyAs docker.CopyAs + + assert.Nil(t, err) + assert.IsType(t, dockerCopyAs, di) + assert.Equal(t, "COPY --chown=123:124 [\"foo1\", \"foo2\", \"bar\"]\n", di.Compile()) +} + func TestCopyFrom(t *testing.T) { i := build.CopyFrom{"foo", build.Copy{[]string{"foo1", "foo2"}, "bar"}} @@ -83,6 +95,18 @@ func TestLabel(t *testing.T) { assert.Equal(t, "LABEL bar=\"foo\" foo=\"bar\"\n", di.Compile()) } +func TestUser(t *testing.T) { + i := build.User{"foo"} + + di, err := docker.NewInstruction(i) + + var dockerUser docker.User + + assert.Nil(t, err) + assert.IsType(t, dockerUser, di) + assert.Equal(t, "USER \"foo\"\n", di.Compile()) +} + func TestVolume(t *testing.T) { i := build.Volume{"/foo/dir"} |