Skip to content

Commit 90281bd

Browse files
committed
feat: add agentexec pkg
1 parent c3c23ed commit 90281bd

File tree

6 files changed

+305
-0
lines changed

6 files changed

+305
-0
lines changed

agent/agentexec/cli.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package agentexec
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"runtime"
9+
"slices"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
14+
"golang.org/x/xerrors"
15+
)
16+
17+
const (
18+
EnvProcOOMScore = "CODER_PROC_OOM_SCORE"
19+
EnvProcNiceScore = "CODER_PROC_NICE_SCORE"
20+
)
21+
22+
// CLI runs the agent-exec command. It should only be called by the cli package.
23+
func CLI(ctx context.Context, args []string, environ []string) error {
24+
if runtime.GOOS != "linux" {
25+
return xerrors.Errorf("agent-exec is only supported on Linux")
26+
}
27+
28+
pid := os.Getpid()
29+
30+
oomScore, ok := envVal(environ, EnvProcOOMScore)
31+
if !ok {
32+
return xerrors.Errorf("missing %q", EnvProcOOMScore)
33+
}
34+
35+
niceScore, ok := envVal(environ, EnvProcNiceScore)
36+
if !ok {
37+
return xerrors.Errorf("missing %q", EnvProcNiceScore)
38+
}
39+
40+
score, err := strconv.Atoi(niceScore)
41+
if err != nil {
42+
return xerrors.Errorf("invalid nice score: %w", err)
43+
}
44+
45+
err = syscall.Setpriority(syscall.PRIO_PROCESS, pid, score)
46+
if err != nil {
47+
return xerrors.Errorf("set nice score: %w", err)
48+
}
49+
50+
oomPath := fmt.Sprintf("/proc/%d/oom_score_adj", pid)
51+
err = os.WriteFile(oomPath, []byte(oomScore), 0o600)
52+
if err != nil {
53+
return xerrors.Errorf("set oom score: %w", err)
54+
}
55+
56+
path, err := exec.LookPath(args[0])
57+
if err != nil {
58+
return xerrors.Errorf("look path: %w", err)
59+
}
60+
61+
env := slices.DeleteFunc(environ, func(env string) bool {
62+
return strings.HasPrefix(env, EnvProcOOMScore) || strings.HasPrefix(env, EnvProcNiceScore)
63+
})
64+
65+
return syscall.Exec(path, args, env)
66+
}
67+
68+
func envVal(environ []string, key string) (string, bool) {
69+
for _, env := range environ {
70+
if strings.HasPrefix(env, key+"=") {
71+
return strings.TrimPrefix(env, key+"="), true
72+
}
73+
}
74+
return "", false
75+
}

agent/agentexec/cmdtest/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/coder/coder/v2/agent/agentexec"
9+
)
10+
11+
func main() {
12+
err := agentexec.CLI(context.Background(), os.Args, os.Environ())
13+
if err != nil {
14+
_, _ = fmt.Fprintln(os.Stderr, err)
15+
os.Exit(1)
16+
}
17+
}

agent/agentexec/exec.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package agentexec
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
10+
"golang.org/x/xerrors"
11+
)
12+
13+
const (
14+
// EnvProcPrioMgmt is the environment variable that determines whether
15+
// we attempt to manage process CPU and OOM Killer priority.
16+
EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT"
17+
)
18+
19+
// CommandContext returns an exec.Cmd that calls "coder agent-exec" prior to exec'ing
20+
// the provided command if CODER_PROC_PRIO_MGMT is set, otherwise a normal exec.Cmd
21+
// is returned. All instances of exec.Cmd should flow through this function to ensure
22+
// proper resource constraints are applied to the child process.
23+
func CommandContext(ctx context.Context, cmd string, args []string) (*exec.Cmd, error) {
24+
_, enabled := envVal(os.Environ(), EnvProcPrioMgmt)
25+
if runtime.GOOS != "linux" || !enabled {
26+
return exec.CommandContext(ctx, cmd, args...), nil
27+
}
28+
29+
executable, err := os.Executable()
30+
if err != nil {
31+
return nil, xerrors.Errorf("get executable: %w", err)
32+
}
33+
34+
bin, err := filepath.EvalSymlinks(executable)
35+
if err != nil {
36+
return nil, xerrors.Errorf("eval symlinks: %w", err)
37+
}
38+
39+
args = append([]string{"agent-exec", cmd}, args...)
40+
return exec.CommandContext(ctx, bin, args...), nil
41+
}

cli/agentexec.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"runtime"
9+
"slices"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
14+
"golang.org/x/xerrors"
15+
16+
"github.com/spf13/afero"
17+
18+
"github.com/coder/serpent"
19+
)
20+
21+
const EnvProcOOMScore = "CODER_PROC_OOM_SCORE"
22+
const EnvProcNiceScore = "CODER_PROC_NICE_SCORE"
23+
24+
func (*RootCmd) agentExec() *serpent.Command {
25+
return &serpent.Command{
26+
Use: "agent-exec",
27+
Hidden: true,
28+
RawArgs: true,
29+
Handler: func(inv *serpent.Invocation) error {
30+
if runtime.GOOS != "linux" {
31+
return xerrors.Errorf("agent-exec is only supported on Linux")
32+
}
33+
34+
var (
35+
pid = os.Getpid()
36+
args = inv.Args
37+
oomScore = inv.Environ.Get(EnvProcOOMScore)
38+
niceScore = inv.Environ.Get(EnvProcNiceScore)
39+
40+
fs = fsFromContext(inv.Context())
41+
syscaller = syscallerFromContext(inv.Context())
42+
)
43+
44+
score, err := strconv.Atoi(niceScore)
45+
if err != nil {
46+
return xerrors.Errorf("invalid nice score: %w", err)
47+
}
48+
49+
err = syscaller.Setpriority(syscall.PRIO_PROCESS, pid, score)
50+
if err != nil {
51+
return xerrors.Errorf("set nice score: %w", err)
52+
}
53+
54+
oomPath := fmt.Sprintf("/proc/%d/oom_score_adj", pid)
55+
err = afero.WriteFile(fs, oomPath, []byte(oomScore), 0o600)
56+
if err != nil {
57+
return xerrors.Errorf("set oom score: %w", err)
58+
}
59+
60+
path, err := exec.LookPath(args[0])
61+
if err != nil {
62+
return xerrors.Errorf("look path: %w", err)
63+
}
64+
65+
env := slices.DeleteFunc(inv.Environ.ToOS(), excludeKeys(EnvProcOOMScore, EnvProcNiceScore))
66+
67+
return syscall.Exec(path, args, env)
68+
},
69+
}
70+
}
71+
72+
func excludeKeys(keys ...string) func(env string) bool {
73+
return func(env string) bool {
74+
for _, key := range keys {
75+
if strings.HasPrefix(env, key+"=") {
76+
return true
77+
}
78+
}
79+
return false
80+
}
81+
}
82+
83+
type Syscaller interface {
84+
Setpriority(int, int, int) error
85+
Exec(string, []string, []string) error
86+
}
87+
88+
type linuxSyscaller struct{}
89+
90+
func (linuxSyscaller) Setpriority(which, pid, nice int) error {
91+
return syscall.Setpriority(which, pid, nice)
92+
}
93+
94+
func (linuxSyscaller) Exec(path string, args, env []string) error {
95+
return syscall.Exec(path, args, env)
96+
}
97+
98+
type syscallerKey struct{}
99+
100+
func WithSyscaller(ctx context.Context, syscaller Syscaller) context.Context {
101+
return context.WithValue(ctx, syscallerKey{}, syscaller)
102+
}
103+
104+
func syscallerFromContext(ctx context.Context) Syscaller {
105+
if syscaller, ok := ctx.Value(syscallerKey{}).(Syscaller); ok {
106+
return syscaller
107+
}
108+
return linuxSyscaller{}
109+
}
110+
111+
type fsKey struct{}
112+
113+
func WithFS(ctx context.Context, fs afero.Fs) context.Context {
114+
return context.WithValue(ctx, fsKey{}, fs)
115+
}
116+
117+
func fsFromContext(ctx context.Context) afero.Fs {
118+
if fs, ok := ctx.Value(fsKey{}).(afero.Fs); ok {
119+
return fs
120+
}
121+
return afero.NewOsFs()
122+
}

cli/agentexec_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"runtime"
7+
"sync"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/v2/cli"
13+
"github.com/coder/coder/v2/cli/clitest"
14+
)
15+
16+
func TestAgentExec(t *testing.T) {
17+
t.Parallel()
18+
19+
if runtime.GOOS != "linux" {
20+
t.Skip("agent-exec is only supported on Linux")
21+
}
22+
23+
t.Run("OK", func(t *testing.T) {
24+
t.Parallel()
25+
26+
inv, _ := clitest.New(t, "agent-exec", "echo", "hello")
27+
inv.Environ.Set(cli.EnvProcOOMScore, "1000")
28+
inv.Environ.Set(cli.EnvProcNiceScore, "10")
29+
var buf bytes.Buffer
30+
wr := &syncWriter{W: &buf}
31+
inv.Stdout = wr
32+
inv.Stderr = wr
33+
clitest.Start(t, inv)
34+
35+
require.Equal(t, "hello\n", buf.String())
36+
})
37+
38+
}
39+
40+
type syncWriter struct {
41+
W io.Writer
42+
mu sync.Mutex
43+
}
44+
45+
func (w *syncWriter) Write(p []byte) (n int, err error) {
46+
w.mu.Lock()
47+
defer w.mu.Unlock()
48+
return w.W.Write(p)
49+
}

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
122122
r.whoami(),
123123

124124
// Hidden
125+
r.agentExec(),
125126
r.expCmd(),
126127
r.gitssh(),
127128
r.support(),

0 commit comments

Comments
 (0)