summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--blubber.example.yaml4
-rw-r--r--build/instructions.go29
-rw-r--r--build/instructions_test.go12
-rw-r--r--build/macros.go60
-rw-r--r--build/macros_test.go61
-rw-r--r--config/common.go16
-rw-r--r--config/lives.go54
-rw-r--r--config/lives_test.go194
-rw-r--r--config/reader.go18
-rw-r--r--config/runs.go84
-rw-r--r--config/runs_test.go88
-rw-r--r--config/user.go25
-rw-r--r--config/variant.go33
-rw-r--r--config/variant_test.go10
-rw-r--r--docker/compiler.go11
-rw-r--r--docker/instructions.go33
-rw-r--r--docker/instructions_test.go24
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"}