Skip to content

Commit 8acd95e

Browse files
authored
Add user property to run configurations (#2055)
* If not set, use the default user from the image (if it, in turn, is not set either, Docker uses `root` as a default value) * The container user is still set to `root`, as we need root privileges, at least to install sshd, but the runner executes the job (shell script with `commands` from the run configuration) as `user`. * If the `user` is not root, it gets its own copy of `~/.ssh/authorized_keys` and `~/.ssh/environment`, making it possible to `ssh user@run-name` (the default user is still `root`, that is, `ssh run-name` logs in as root) * `~/.ssh/environment` is now generated by the runner, not the outer shell script (container entrypoint), and includes all the same variables as the job env (including `DSTACK_*` vars and vars from the `env` property of the run configuration) Part-of: #1535
1 parent 5c928ab commit 8acd95e

File tree

23 files changed

+872
-72
lines changed

23 files changed

+872
-72
lines changed

runner/cmd/runner/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ func start(tempDir string, homeDir string, workingDir string, httpPort int, logL
3838
log.DefaultEntry.Logger.SetOutput(io.MultiWriter(os.Stdout, defaultLogFile))
3939
log.DefaultEntry.Logger.SetLevel(logrus.Level(logLevel))
4040

41-
server := api.NewServer(tempDir, homeDir, workingDir, fmt.Sprintf(":%d", httpPort), version)
41+
server, err := api.NewServer(tempDir, homeDir, workingDir, fmt.Sprintf(":%d", httpPort), version)
42+
if err != nil {
43+
return tracerr.Errorf("Failed to create server: %w", err)
44+
}
4245

4346
log.Trace(context.TODO(), "Starting API server", "port", httpPort)
4447
if err := server.Run(); err != nil {

runner/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.23
55
require (
66
github.com/alexellis/go-execute/v2 v2.2.1
77
github.com/bluekeyes/go-gitdiff v0.7.2
8-
github.com/creack/pty v1.1.21
8+
github.com/creack/pty v1.1.24
99
github.com/docker/docker v26.0.0+incompatible
1010
github.com/docker/go-connections v0.5.0
1111
github.com/docker/go-units v0.5.0
@@ -15,7 +15,7 @@ require (
1515
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf
1616
github.com/shirou/gopsutil/v3 v3.24.3
1717
github.com/sirupsen/logrus v1.9.3
18-
github.com/stretchr/testify v1.9.0
18+
github.com/stretchr/testify v1.10.0
1919
github.com/urfave/cli/v2 v2.27.1
2020
github.com/ztrue/tracerr v0.4.0
2121
golang.org/x/crypto v0.22.0

runner/go.sum

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
3232
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
3333
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
3434
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
35-
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
36-
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
35+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
36+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
3737
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
3838
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
3939
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -178,8 +178,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
178178
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
179179
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
180180
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
181-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
182181
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
182+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
183+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
183184
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
184185
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
185186
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=

runner/internal/executor/env.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package executor
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type EnvMap map[string]string
9+
10+
func (em EnvMap) Get(key string) string {
11+
return em[key]
12+
}
13+
14+
func (em EnvMap) Update(src map[string]string, interpolate bool) {
15+
for key, value := range src {
16+
if interpolate {
17+
value = interpolateVariables(value, em.Get)
18+
}
19+
em[key] = value
20+
}
21+
}
22+
23+
func (em EnvMap) Render() []string {
24+
var list []string
25+
for key, value := range em {
26+
list = append(list, fmt.Sprintf("%s=%s", key, value))
27+
}
28+
return list
29+
}
30+
31+
func NewEnvMap(sources ...map[string]string) EnvMap {
32+
em := make(EnvMap)
33+
for _, src := range sources {
34+
em.Update(src, false)
35+
}
36+
return em
37+
}
38+
39+
func ParseEnvList(list []string) EnvMap {
40+
em := make(EnvMap)
41+
for _, item := range list {
42+
parts := strings.SplitN(item, "=", 2)
43+
if len(parts) == 2 {
44+
em[parts[0]] = parts[1]
45+
}
46+
}
47+
return em
48+
}
49+
50+
// interpolateVariables expands variables as follows:
51+
// `$VARNAME` -> literal `$VARNAME` (curly brackets are mandatory, bare $ means nothing)
52+
// `${VARNAME}` -> getter("VARNAME") return value
53+
// `$${VARNAME}` -> literal `${VARNAME}`
54+
// `$$${VARNAME}` -> literal `$` + getter("VARNAME") return value
55+
// `$$$${VARNAME}` -> literal `$${VARNAME}`
56+
// `${no_closing_bracket`, `${0nonalphafirstchar}`, `${non-alphanum char}`, `${}` ->
57+
// -> corresponding literal as is (only valid placeholder is treated specially requiring
58+
// doubling $ to avoid interpolation, any non-valid syntax with `${` sequence is passed as is)
59+
// See test cases for more examples
60+
func interpolateVariables(s string, getter func(string) string) string {
61+
// assuming that most strings don't contain vars,
62+
// allocate the buffer the same size as input string
63+
buf := make([]byte, 0, len(s))
64+
dollarCount := 0
65+
for i := 0; i < len(s); i++ {
66+
switch char := s[i]; char {
67+
case '$':
68+
dollarCount += 1
69+
case '{':
70+
name, w := getVariableName(s[i+1:])
71+
if name != "" {
72+
// valid variable name, unescaping $
73+
for range dollarCount / 2 {
74+
buf = append(buf, '$')
75+
}
76+
if dollarCount%2 != 0 {
77+
// ${var} -> var_value, $$${var} -> $var_value
78+
buf = append(buf, getter(name)...)
79+
} else {
80+
// $${var} -> ${var}, $$$${var} -> $${var}
81+
buf = append(buf, s[i:i+w+1]...)
82+
}
83+
} else {
84+
// not a valid variable name or unclosed ${}, keeping all $ as is
85+
for range dollarCount {
86+
buf = append(buf, '$')
87+
}
88+
buf = append(buf, s[i:i+w+1]...)
89+
}
90+
i += w
91+
dollarCount = 0
92+
default:
93+
// flush accumulated $, if any
94+
for range dollarCount {
95+
buf = append(buf, '$')
96+
}
97+
dollarCount = 0
98+
buf = append(buf, char)
99+
}
100+
}
101+
// flush trailing $, if any
102+
for range dollarCount {
103+
buf = append(buf, '$')
104+
}
105+
return string(buf)
106+
}
107+
108+
func getVariableName(s string) (string, int) {
109+
if len(s) < 2 {
110+
return "", len(s)
111+
}
112+
if !isAlpha(s[0]) {
113+
return "", 1
114+
}
115+
var i int
116+
for i = 1; i < len(s); i++ {
117+
char := s[i]
118+
if char == '}' {
119+
return s[:i], i + 1
120+
}
121+
if !isAlphaNum(char) {
122+
return "", i
123+
}
124+
}
125+
return "", i
126+
}
127+
128+
func isAlpha(c uint8) bool {
129+
return c == '_' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z'
130+
}
131+
132+
func isAlphaNum(c uint8) bool {
133+
return isAlpha(c) || '0' <= c && c <= '9'
134+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package executor
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func dummyGetter(s string) string {
10+
return "<dummy>"
11+
}
12+
13+
func TestInterpolateVariables_DollarEscape(t *testing.T) {
14+
testCases := []struct {
15+
input, expected string
16+
}{
17+
{"", ""},
18+
{"just a string", "just a string"},
19+
{"$ $$ $$$", "$ $$ $$$"},
20+
{"foo $notavar", "foo $notavar"},
21+
{"foo $$notavar", "foo $$notavar"},
22+
{"trailing$", "trailing$"},
23+
{"trailing$$", "trailing$$"},
24+
{"trailing${", "trailing${"},
25+
{"trailing$${", "trailing$${"},
26+
{"empty${}", "empty${}"},
27+
{"empty${}empty", "empty${}empty"},
28+
{"empty$${}empty", "empty$${}empty"},
29+
{"foo${notavar", "foo${notavar"},
30+
{"foo${notavar bar", "foo${notavar bar"},
31+
{"foo$${notavar", "foo$${notavar"},
32+
{"foo$${notavar bar", "foo$${notavar bar"},
33+
{"foo${!notavar}", "foo${!notavar}"},
34+
{"foo${!notavar}bar", "foo${!notavar}bar"},
35+
{"foo${not!a!var}", "foo${not!a!var}"},
36+
{"foo$${not!a!var}", "foo$${not!a!var}"},
37+
{"foo${not!a!var}bar", "foo${not!a!var}bar"},
38+
{"foo$${not!a!var}bar", "foo$${not!a!var}bar"},
39+
{"${0notavar}", "${0notavar}"},
40+
{"foo ${0notavar}bar", "foo ${0notavar}bar"},
41+
{"foo $$${0notavar}bar", "foo $$${0notavar}bar"},
42+
{"foo$${escaped}", "foo${escaped}"},
43+
{"foo$$$${escaped}bar", "foo$${escaped}bar"},
44+
{"${var}", "<dummy>"},
45+
{"$$${var}", "$<dummy>"},
46+
{"$$${var}$", "$<dummy>$"},
47+
{"$$${var}$$", "$<dummy>$$"},
48+
{"foo${var}bar", "foo<dummy>bar"},
49+
{"hi ${var_WITH_all_allowed_char_types_013}", "hi <dummy>"},
50+
}
51+
for _, tc := range testCases {
52+
interpolated := interpolateVariables(tc.input, dummyGetter)
53+
assert.Equal(t, tc.expected, interpolated)
54+
}
55+
}
56+
57+
func TestEnvMapUpdate_Expand(t *testing.T) {
58+
envMap := EnvMap{"PATH": "/bin:/sbin"}
59+
envMap.Update(EnvMap{"PATH": "/opt/bin:${PATH}"}, true)
60+
assert.Equal(t, EnvMap{"PATH": "/opt/bin:/bin:/sbin"}, envMap)
61+
}
62+
63+
func TestEnvMapUpdate_Expand_NoCurlyBrackets(t *testing.T) {
64+
envMap := EnvMap{"PATH": "/bin:/sbin"}
65+
envMap.Update(EnvMap{"PATH": "/opt/bin:$PATH"}, true)
66+
assert.Equal(t, EnvMap{"PATH": "/opt/bin:$PATH"}, envMap)
67+
}
68+
69+
func TestEnvMapUpdate_Expand_MissingVar(t *testing.T) {
70+
envMap := EnvMap{}
71+
envMap.Update(EnvMap{"PATH": "/opt/bin:${PATH}"}, true)
72+
assert.Equal(t, EnvMap{"PATH": "/opt/bin:"}, envMap)
73+
}
74+
75+
func TestEnvMapUpdate_Expand_VarLike(t *testing.T) {
76+
envMap := EnvMap{}
77+
envMap.Update(EnvMap{"TOKEN": "deadf00d${notavar ${$NOTaVAR}"}, true)
78+
assert.Equal(t, EnvMap{"TOKEN": "deadf00d${notavar ${$NOTaVAR}"}, envMap)
79+
}
80+
81+
func TestEnvMapUpdate_Merge_NoExpand(t *testing.T) {
82+
envMap := EnvMap{
83+
"VAR1": "var1_oldvalue",
84+
"VAR2": "var2_value",
85+
}
86+
envMap.Update(map[string]string{
87+
"VAR1": "var1_newvalue",
88+
"VAR3": "var3_${VAR2}",
89+
}, false)
90+
91+
expected := EnvMap{
92+
"VAR1": "var1_newvalue",
93+
"VAR2": "var2_value",
94+
"VAR3": "var3_${VAR2}",
95+
}
96+
assert.Equal(t, expected, envMap)
97+
}
98+
99+
func TestEnvMapUpdate_Merge_Expand(t *testing.T) {
100+
envMap := EnvMap{
101+
"VAR1": "var1_oldvalue",
102+
"VAR2": "var2_value",
103+
}
104+
envMap.Update(map[string]string{
105+
"VAR1": "var1_newvalue",
106+
"VAR3": "var3_${VAR2}",
107+
}, true)
108+
109+
expected := EnvMap{
110+
"VAR1": "var1_newvalue",
111+
"VAR2": "var2_value",
112+
"VAR3": "var3_var2_value",
113+
}
114+
assert.Equal(t, expected, envMap)
115+
}

runner/internal/executor/exec.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
package executor
22

33
import (
4-
"fmt"
5-
"os"
64
"path/filepath"
75
"strings"
86

97
"github.com/dstackai/dstack/runner/internal/gerrors"
108
)
119

12-
func makeEnv(homeDir string, mappings ...map[string]string) []string {
13-
list := os.Environ()
14-
for _, mapping := range mappings {
15-
for key, value := range mapping {
16-
list = append(list, fmt.Sprintf("%s=%s", key, value))
17-
}
18-
}
19-
list = append(list, fmt.Sprintf("HOME=%s", homeDir))
20-
return list
21-
}
22-
2310
func joinRelPath(rootDir string, path string) (string, error) {
2411
if filepath.IsAbs(path) {
2512
return "", gerrors.New("path must be relative")

0 commit comments

Comments
 (0)