summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDan Duvall <dduvall@wikimedia.org>2018-02-21 16:18:47 -0800
committerDan Duvall <dduvall@wikimedia.org>2018-03-05 13:22:10 -0800
commit47526283fea7df1734ef5b9a5da5c810bf76a29a (patch)
tree7d6ed2530acdaded093f410939aa9df250130c0a
parentf606d212fd94769294b1ebdaa6ec224458281d22 (diff)
downloadblubber-47526283fea7df1734ef5b9a5da5c810bf76a29a.tar.gz
Fix application files/runtime permissions scheme
Summary: Introduces new `lives` configuration that provides the name/UID/GID of the user that will own application files and installed dependencies. This new configuration is distinct from `runs` in that the former determines application file location ownership and the latter now only determines runtime process ownership. Default configuration has also been introduced for both config sections. In addition to the new configuration, a new `build.CopyAs` instruction has been introduced that ensures correct UID/GID ownership of files copied into the container image, and all unqualified `build.Copy` instructions are wrapped by the new `build.CopyAs` instruction using the UID/GID appropriate for the current build phase. A new `build.User` instruction is also introduced and injected into the build at the start of certain phases to enforce ownership of `build.Run` processes. This effective process/file ownership model is: PhasePrivileged - "root" PhasePrivilegedDropped - lives.as PhasePreInstall - lives.as PhaseInstall - lives.as PhasePostInstall - runs.as Fixes T187372 Test Plan: Run `go test ./...`. Reviewers: thcipriani, hashar, demon, #release-engineering-team Reviewed By: thcipriani, #release-engineering-team Subscribers: mmodell Tags: #release-engineering-team Maniphest Tasks: T187372 Differential Revision: https://phabricator.wikimedia.org/D984
-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"}