Skip to content

Commit

Permalink
Add user property to run configurations (#2055)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
un-def authored Dec 5, 2024
1 parent 5c928ab commit 8acd95e
Show file tree
Hide file tree
Showing 23 changed files with 872 additions and 72 deletions.
5 changes: 4 additions & 1 deletion runner/cmd/runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ func start(tempDir string, homeDir string, workingDir string, httpPort int, logL
log.DefaultEntry.Logger.SetOutput(io.MultiWriter(os.Stdout, defaultLogFile))
log.DefaultEntry.Logger.SetLevel(logrus.Level(logLevel))

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

log.Trace(context.TODO(), "Starting API server", "port", httpPort)
if err := server.Run(); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions runner/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.23
require (
github.com/alexellis/go-execute/v2 v2.2.1
github.com/bluekeyes/go-gitdiff v0.7.2
github.com/creack/pty v1.1.21
github.com/creack/pty v1.1.24
github.com/docker/docker v26.0.0+incompatible
github.com/docker/go-connections v0.5.0
github.com/docker/go-units v0.5.0
Expand All @@ -15,7 +15,7 @@ require (
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf
github.com/shirou/gopsutil/v3 v3.24.3
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.1
github.com/ztrue/tracerr v0.4.0
golang.org/x/crypto v0.22.0
Expand Down
7 changes: 4 additions & 3 deletions runner/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -178,8 +178,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
Expand Down
134 changes: 134 additions & 0 deletions runner/internal/executor/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package executor

import (
"fmt"
"strings"
)

type EnvMap map[string]string

func (em EnvMap) Get(key string) string {
return em[key]
}

func (em EnvMap) Update(src map[string]string, interpolate bool) {
for key, value := range src {
if interpolate {
value = interpolateVariables(value, em.Get)
}
em[key] = value
}
}

func (em EnvMap) Render() []string {
var list []string
for key, value := range em {
list = append(list, fmt.Sprintf("%s=%s", key, value))
}
return list
}

func NewEnvMap(sources ...map[string]string) EnvMap {
em := make(EnvMap)
for _, src := range sources {
em.Update(src, false)
}
return em
}

func ParseEnvList(list []string) EnvMap {
em := make(EnvMap)
for _, item := range list {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 {
em[parts[0]] = parts[1]
}
}
return em
}

// interpolateVariables expands variables as follows:
// `$VARNAME` -> literal `$VARNAME` (curly brackets are mandatory, bare $ means nothing)
// `${VARNAME}` -> getter("VARNAME") return value
// `$${VARNAME}` -> literal `${VARNAME}`
// `$$${VARNAME}` -> literal `$` + getter("VARNAME") return value
// `$$$${VARNAME}` -> literal `$${VARNAME}`
// `${no_closing_bracket`, `${0nonalphafirstchar}`, `${non-alphanum char}`, `${}` ->
// -> corresponding literal as is (only valid placeholder is treated specially requiring
// doubling $ to avoid interpolation, any non-valid syntax with `${` sequence is passed as is)
// See test cases for more examples
func interpolateVariables(s string, getter func(string) string) string {
// assuming that most strings don't contain vars,
// allocate the buffer the same size as input string
buf := make([]byte, 0, len(s))
dollarCount := 0
for i := 0; i < len(s); i++ {
switch char := s[i]; char {
case '$':
dollarCount += 1
case '{':
name, w := getVariableName(s[i+1:])
if name != "" {
// valid variable name, unescaping $
for range dollarCount / 2 {
buf = append(buf, '$')
}
if dollarCount%2 != 0 {
// ${var} -> var_value, $$${var} -> $var_value
buf = append(buf, getter(name)...)
} else {
// $${var} -> ${var}, $$$${var} -> $${var}
buf = append(buf, s[i:i+w+1]...)
}
} else {
// not a valid variable name or unclosed ${}, keeping all $ as is
for range dollarCount {
buf = append(buf, '$')
}
buf = append(buf, s[i:i+w+1]...)
}
i += w
dollarCount = 0
default:
// flush accumulated $, if any
for range dollarCount {
buf = append(buf, '$')
}
dollarCount = 0
buf = append(buf, char)
}
}
// flush trailing $, if any
for range dollarCount {
buf = append(buf, '$')
}
return string(buf)
}

func getVariableName(s string) (string, int) {
if len(s) < 2 {
return "", len(s)
}
if !isAlpha(s[0]) {
return "", 1
}
var i int
for i = 1; i < len(s); i++ {
char := s[i]
if char == '}' {
return s[:i], i + 1
}
if !isAlphaNum(char) {
return "", i
}
}
return "", i
}

func isAlpha(c uint8) bool {
return c == '_' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z'
}

func isAlphaNum(c uint8) bool {
return isAlpha(c) || '0' <= c && c <= '9'
}
115 changes: 115 additions & 0 deletions runner/internal/executor/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package executor

import (
"testing"

"github.com/stretchr/testify/assert"
)

func dummyGetter(s string) string {
return "<dummy>"
}

func TestInterpolateVariables_DollarEscape(t *testing.T) {
testCases := []struct {
input, expected string
}{
{"", ""},
{"just a string", "just a string"},
{"$ $$ $$$", "$ $$ $$$"},
{"foo $notavar", "foo $notavar"},
{"foo $$notavar", "foo $$notavar"},
{"trailing$", "trailing$"},
{"trailing$$", "trailing$$"},
{"trailing${", "trailing${"},
{"trailing$${", "trailing$${"},
{"empty${}", "empty${}"},
{"empty${}empty", "empty${}empty"},
{"empty$${}empty", "empty$${}empty"},
{"foo${notavar", "foo${notavar"},
{"foo${notavar bar", "foo${notavar bar"},
{"foo$${notavar", "foo$${notavar"},
{"foo$${notavar bar", "foo$${notavar bar"},
{"foo${!notavar}", "foo${!notavar}"},
{"foo${!notavar}bar", "foo${!notavar}bar"},
{"foo${not!a!var}", "foo${not!a!var}"},
{"foo$${not!a!var}", "foo$${not!a!var}"},
{"foo${not!a!var}bar", "foo${not!a!var}bar"},
{"foo$${not!a!var}bar", "foo$${not!a!var}bar"},
{"${0notavar}", "${0notavar}"},
{"foo ${0notavar}bar", "foo ${0notavar}bar"},
{"foo $$${0notavar}bar", "foo $$${0notavar}bar"},
{"foo$${escaped}", "foo${escaped}"},
{"foo$$$${escaped}bar", "foo$${escaped}bar"},
{"${var}", "<dummy>"},
{"$$${var}", "$<dummy>"},
{"$$${var}$", "$<dummy>$"},
{"$$${var}$$", "$<dummy>$$"},
{"foo${var}bar", "foo<dummy>bar"},
{"hi ${var_WITH_all_allowed_char_types_013}", "hi <dummy>"},
}
for _, tc := range testCases {
interpolated := interpolateVariables(tc.input, dummyGetter)
assert.Equal(t, tc.expected, interpolated)
}
}

func TestEnvMapUpdate_Expand(t *testing.T) {
envMap := EnvMap{"PATH": "/bin:/sbin"}
envMap.Update(EnvMap{"PATH": "/opt/bin:${PATH}"}, true)
assert.Equal(t, EnvMap{"PATH": "/opt/bin:/bin:/sbin"}, envMap)
}

func TestEnvMapUpdate_Expand_NoCurlyBrackets(t *testing.T) {
envMap := EnvMap{"PATH": "/bin:/sbin"}
envMap.Update(EnvMap{"PATH": "/opt/bin:$PATH"}, true)
assert.Equal(t, EnvMap{"PATH": "/opt/bin:$PATH"}, envMap)
}

func TestEnvMapUpdate_Expand_MissingVar(t *testing.T) {
envMap := EnvMap{}
envMap.Update(EnvMap{"PATH": "/opt/bin:${PATH}"}, true)
assert.Equal(t, EnvMap{"PATH": "/opt/bin:"}, envMap)
}

func TestEnvMapUpdate_Expand_VarLike(t *testing.T) {
envMap := EnvMap{}
envMap.Update(EnvMap{"TOKEN": "deadf00d${notavar ${$NOTaVAR}"}, true)
assert.Equal(t, EnvMap{"TOKEN": "deadf00d${notavar ${$NOTaVAR}"}, envMap)
}

func TestEnvMapUpdate_Merge_NoExpand(t *testing.T) {
envMap := EnvMap{
"VAR1": "var1_oldvalue",
"VAR2": "var2_value",
}
envMap.Update(map[string]string{
"VAR1": "var1_newvalue",
"VAR3": "var3_${VAR2}",
}, false)

expected := EnvMap{
"VAR1": "var1_newvalue",
"VAR2": "var2_value",
"VAR3": "var3_${VAR2}",
}
assert.Equal(t, expected, envMap)
}

func TestEnvMapUpdate_Merge_Expand(t *testing.T) {
envMap := EnvMap{
"VAR1": "var1_oldvalue",
"VAR2": "var2_value",
}
envMap.Update(map[string]string{
"VAR1": "var1_newvalue",
"VAR3": "var3_${VAR2}",
}, true)

expected := EnvMap{
"VAR1": "var1_newvalue",
"VAR2": "var2_value",
"VAR3": "var3_var2_value",
}
assert.Equal(t, expected, envMap)
}
13 changes: 0 additions & 13 deletions runner/internal/executor/exec.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
package executor

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/dstackai/dstack/runner/internal/gerrors"
)

func makeEnv(homeDir string, mappings ...map[string]string) []string {
list := os.Environ()
for _, mapping := range mappings {
for key, value := range mapping {
list = append(list, fmt.Sprintf("%s=%s", key, value))
}
}
list = append(list, fmt.Sprintf("HOME=%s", homeDir))
return list
}

func joinRelPath(rootDir string, path string) (string, error) {
if filepath.IsAbs(path) {
return "", gerrors.New("path must be relative")
Expand Down
Loading

0 comments on commit 8acd95e

Please sign in to comment.