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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy