10000 feat(agent/agentcontainers): add feature options as envs (#18576) · coder/coder@3c4d920 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3c4d920

Browse files
authored
feat(agent/agentcontainers): add feature options as envs (#18576)
1 parent 688d2ee commit 3c4d920

File tree

4 files changed

+282
-6
lines changed

4 files changed

+282
-6
lines changed

agent/agentcontainers/api.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
13021302
}
13031303

13041304
var (
1305+
featureOptionsAsEnvs []string
13051306
appsWithPossibleDuplicates []SubAgentApp
13061307
workspaceFolder = DevcontainerDefaultContainerWorkspaceFolder
13071308
)
@@ -1313,12 +1314,14 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
13131314
)
13141315

13151316
readConfig := func() (DevcontainerConfig, error) {
1316-
return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, []string{
1317-
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", subAgentConfig.Name),
1318-
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
1319-
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
1320-
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
1321-
})
1317+
return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath,
1318+
append(featureOptionsAsEnvs, []string{
1319+
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", subAgentConfig.Name),
1320+
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
1321+
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
1322+
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
1323+
}...),
1324+
)
13221325
}
13231326

13241327
if config, err = readConfig(); err != nil {
@@ -1334,6 +1337,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
13341337

13351338
workspaceFolder = config.Workspace.WorkspaceFolder
13361339

1340+
featureOptionsAsEnvs = config.MergedConfiguration.Features.OptionsAsEnvs()
1341+
if len(featureOptionsAsEnvs) > 0 {
1342+
configOutdated = true
1343+
}
1344+
13371345
// NOTE(DanielleMaywood):
13381346
// We only want to take an agent name specified in the root customization layer.
13391347
// This restricts the ability for a feature to specify the agent name. We may revisit

agent/agentcontainers/api_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,6 +2060,122 @@ func TestAPI(t *testing.T) {
20602060
require.Len(t, fSAC.created, 1)
20612061
})
20622062

2063+
t.Run("ReadConfigWithFeatureOptions", func(t *testing.T) {
2064+
t.Parallel()
2065+
2066+
if runtime.GOOS == "windows" {
2067+
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
2068+
}
2069+
2070+
var (
2071+
ctx = testutil.Context(t, testutil.WaitMedium)
2072+
logger = testutil.Logger(t)
2073+
mClock = quartz.NewMock(t)
2074+
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
2075+
fSAC = &fakeSubAgentClient{
2076+
logger: logger.Named("fakeSubAgentClient"),
2077+
createErrC: make(chan error, 1),
2078+
}
2079+
fDCCLI = &fakeDevcontainerCLI{
2080+
readConfig: agentcontainers.DevcontainerConfig{
2081+
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
2082+
Features: agentcontainers.DevcontainerFeatures{
2083+
"./code-server": map[string]any{
2084+
"port": 9090,
2085+
},
2086+
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
2087+
"moby": "false",
2088+
},
2089+
},
2090+
},
2091+
Workspace: agentcontainers.DevcontainerWorkspace{
2092+
WorkspaceFolder: "/workspaces/coder",
2093+
},
2094+
},
2095+
readConfigErrC: make(chan func(envs []string) error, 2),
2096+
}
2097+
2098+
testContainer = codersdk.WorkspaceAgentContainer{
2099+
ID: "test-container-id",
2100+
FriendlyName: "test-container",
2101+
Image: "test-image",
2102+
Running: true,
2103+
CreatedAt: time.Now(),
2104+
Labels: map[string]string{
2105+
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder",
2106+
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json",
2107+
},
2108+
}
2109+
)
2110+
2111+
coderBin, err := os.Executable()
2112+
require.NoError(t, err)
2113+
2114+
// Mock the `List` function to always return our test container.
2115+
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
2116+
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
2117+
}, nil).AnyTimes()
2118+
2119+
// Mock the steps used for injecting the coder agent.
2120+
gomock.InOrder(
2121+
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
2122+
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
2123+
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
2124+
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
2125+
)
2126+
2127+
mClock.Set(time.Now()).MustWait(ctx)
2128+
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
2129+
2130+
api := agentcontainers.NewAPI(logger,
2131+
agentcontainers.WithClock(mClock),
2132+
agentcontainers.WithContainerCLI(mCCLI),
2133+
agentcontainers.WithDevcontainerCLI(fDCCLI),
2134+
agentcontainers.WithSubAgentClient(fSAC),
2135+
agentcontainers.WithSubAgentURL("test-subagent-url"),
2136+
agentcontainers.WithWatcher(watcher.NewNoop()),
2137+
agentcontainers.WithManifestInfo("test-user", "test-workspace"),
2138+
)
2139+
api.Init()
2140+
defer api.Close()
2141+
2142+
// Close before api.Close() defer to avoid deadlock after test.
2143+
defer close(fSAC.createErrC)
2144+
defer close(fDCCLI.readConfigErrC)
2145+
2146+
// Allow agent creation and injection to succeed.
2147+
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
2148+
2149+
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
2150+
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
2151+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
2152+
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
2153+
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
2154+
// First call should not have feature envs.
2155+
assert.NotContains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
2156+
assert.NotContains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
2157+
return nil
2158+
})
2159+
2160+
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
2161+
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
2162+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
2163+
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
2164+
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
2165+
// Second call should have feature envs from the first config read.
2166+
assert.Contains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
2167+
assert.Contains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
2168+
return nil
2169+
})
2170+
2171+
// Wait until the ticker has been registered.
2172+
tickerTrap.MustWait(ctx).MustRelease(ctx)
2173+
tickerTrap.Close()
2174+
2175+
// Verify agent was created successfully
2176+
require.Len(t, fSAC.created, 1)
2177+
})
2178+
20632179
t.Run("CommandEnv", func(t *testing.T) {
20642180
t.Parallel()
20652181

agent/agentcontainers/devcontainercli.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import (
66
"context"
77
"encoding/json"
88
"errors"
9+
"fmt"
910
"io"
11+
"slices"
12+
"strings"
1013

1114
"golang.org/x/xerrors"
1215

@@ -26,12 +29,55 @@ type DevcontainerConfig struct {
2629

2730
type DevcontainerMergedConfiguration struct {
2831
Customizations DevcontainerMergedCustomizations `json:"customizations,omitempty"`
32+
Features DevcontainerFeatures `json:"features,omitempty"`
2933
}
3034

3135
type DevcontainerMergedCustomizations struct {
3236
Coder []CoderCustomization `json:"coder,omitempty"`
3337
}
3438

39+
type DevcontainerFeatures map[string]any
40+
41+
// OptionsAsEnvs converts the DevcontainerFeatures into a list of
42+
// environment variables that can be used to set feature options.
43+
// The format is FEATURE_<FEATURE_NAME>_OPTION_<OPTION_NAME>=<value>.
44+
// For example, if the feature is:
45+
//
46+
// "ghcr.io/coder/devcontainer-features/code-server:1": {
47+
// "port": 9090,
48+
// }
49+
//
50+
// It will produce:
51+
//
52+
// FEATURE_CODE_SERVER_OPTION_PORT=9090
53+
//
54+
// Note that the feature name is derived from the last part of the key,
55+
// so "ghcr.io/coder/devcontainer-features/code-server:1" becomes
56+
// "CODE_SERVER". The version part (e.g. ":1") is removed, and dashes in
57+
// the feature and option names are replaced with underscores.
58+
func (f DevcontainerFeatures) OptionsAsEnvs() []string {
59+
var env []string
60+
for k, v := range f {
61+
vv, ok := v.(map[string]any)
62+
if !ok {
63+
continue
64+
}
65+
// Take the last part of the key as the feature name/path.
66+
k = k[strings.LastIndex(k, "/")+1:]
67+
// Remove ":" and anything following it.
68+
if idx := strings.Index(k, ":"); idx != -1 {
69+
k = k[:idx]
70+
}
71+
k = strings.ReplaceAll(k, "-", "_")
72+
for k2, v2 := range vv {
73+
k2 = strings.ReplaceAll(k2, "-", "_")
74+
env = append(env, fmt.Sprintf("FEATURE_%s_OPTION_%s=%s", strings.ToUpper(k), strings.ToUpper(k2), fmt.Sprintf("%v", v2)))
75+
}
76+
}
77+
slices.Sort(env)
78+
return env
79+
}
80+
3581
type DevcontainerConfiguration struct {
3682
Customizations DevcontainerCustomizations `json:"customizations,omitempty"`
3783
}

agent/agentcontainers/devcontainercli_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package agentcontainers_test
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"errors"
78
"flag"
89
"fmt"
@@ -13,6 +14,7 @@ import (
1314
"strings"
1415
"testing"
1516

17+
"github.com/google/go-cmp/cmp"
1618
"github.com/ory/dockertest/v3"
1719
"github.com/ory/dockertest/v3/docker"
1820
"github.com/stretchr/testify/assert"
@@ -637,3 +639,107 @@ func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
637639
assert.NoError(t, err, "remove container failed")
638640
}
639641
}
642+
643+
func TestDevcontainerFeatures_OptionsAsEnvs(t *testing.T) {
644+
t.Parallel()
645+
646+
realConfigJSON := `{
647+
"mergedConfiguration": {
648+
"features": {
649+
"./code-server": {
650+
"port": 9090
651+
},
652+
"ghcr.io/devcontainers/features/docker-in-docker:2": {
653+
"moby": "false"
654+
}
655+
}
656+
}
657+
}`
658+
var realConfig agentcontainers.DevcontainerConfig
659+
err := json.Unmarshal([]byte(realConfigJSON), &realConfig)
660+
require.NoError(t, err, "unmarshal JSON payload")
661+
662+
tests := []struct {
663+
name string
664+
features agentcontainers.DevcontainerFeatures
665+
want []string
666+
}{
667+
{
668+
name: "code-server feature",
669+
features: agentcontainers.DevcontainerFeatures{
670+
"./code-server": map[string]any{
671+
"port": 9090,
672+
},
673+
},
674+
want: []string{
675+
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
676+
},
677+
},
678+
{
679+
name: "docker-in-docker feature",
680+
features: agentcontainers.DevcontainerFeatures{
681+
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
682+
"moby": "false",
683+
},
684+
},
685+
want: []string{
686+
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
687+
},
688+
},
689+
{
690+
name: "multiple features with multiple options",
691+
features: agentcontainers.DevcontainerFeatures{
692+
"./code-server": map[string]any{
693+
"port": 9090,
694+
"password": "secret",
695+
},
696+
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
697+
"moby": "false",
698+
"docker-dash-compose-version": "v2",
699+
},
700+
},
701+
want: []string{
702+
"FEATURE_CODE_SERVER_OPTION_PASSWORD=secret",
703+
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
704+
"FEATURE_DOCKER_IN_DOCKER_OPTION_DOCKER_DASH_COMPOSE_VERSION=v2",
705+
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
706+
},
707+
},
708+
{
709+
name: "feature with non-map value (should be ignored)",
710+
features: agentcontainers.DevcontainerFeatures{
711+
"./code-server": map[string]any{
712+
"port": 9090,
713+
},
714+
"./invalid-feature": "not-a-map",
715+
},
716+
want: []string{
717+
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
718+
},
719+
},
720+
{
721+
name: "real config example",
722+
features: realConfig.MergedConfiguration.Features,
723+
want: []string{
724+
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
725+
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
726+
},
727+
},
728+
{
729+
name: "empty features",
730+
features: agentcontainers.DevcontainerFeatures{},
731+
want: nil,
732+
},
733+
}
734+
735+
for _, tt := range tests {
736+
t.Run(tt.name, func(t *testing.T) {
737+
t.Parallel()
738+
739+
got := tt.features.OptionsAsEnvs()
740+
if diff := cmp.Diff(tt.want, got); diff != "" {
741+
require.Failf(t, "OptionsAsEnvs() mismatch (-want +got):\n%s", diff)
742+
}
743+
})
744+
}
745+
}

0 commit comments

Comments
 (0)
0