feat(agent/agentcontainers): add feature options as envs (#18576)

This commit is contained in:
Mathias Fredriksson
2025-06-25 17:41:36 +03:00
committed by GitHub
parent 688d2ee3eb
commit 3c4d9206bc
4 changed files with 282 additions and 6 deletions

View File

@ -1302,6 +1302,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}
var (
featureOptionsAsEnvs []string
appsWithPossibleDuplicates []SubAgentApp
workspaceFolder = DevcontainerDefaultContainerWorkspaceFolder
)
@ -1313,12 +1314,14 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
)
readConfig := func() (DevcontainerConfig, error) {
return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, []string{
return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath,
append(featureOptionsAsEnvs, []string{
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", subAgentConfig.Name),
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
})
}...),
)
}
if config, err = readConfig(); err != nil {
@ -1334,6 +1337,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
workspaceFolder = config.Workspace.WorkspaceFolder
featureOptionsAsEnvs = config.MergedConfiguration.Features.OptionsAsEnvs()
if len(featureOptionsAsEnvs) > 0 {
configOutdated = true
}
// NOTE(DanielleMaywood):
// We only want to take an agent name specified in the root customization layer.
// This restricts the ability for a feature to specify the agent name. We may revisit

View File

@ -2060,6 +2060,122 @@ func TestAPI(t *testing.T) {
require.Len(t, fSAC.created, 1)
})
t.Run("ReadConfigWithFeatureOptions", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
createErrC: make(chan error, 1),
}
fDCCLI = &fakeDevcontainerCLI{
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
},
},
},
Workspace: agentcontainers.DevcontainerWorkspace{
WorkspaceFolder: "/workspaces/coder",
},
},
readConfigErrC: make(chan func(envs []string) error, 2),
}
testContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder",
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json",
},
}
)
coderBin, err := os.Executable()
require.NoError(t, err)
// Mock the `List` function to always return our test container.
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
}, nil).AnyTimes()
// Mock the steps used for injecting the coder agent.
gomock.InOrder(
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
)
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithManifestInfo("test-user", "test-workspace"),
)
api.Init()
defer api.Close()
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
defer close(fDCCLI.readConfigErrC)
// Allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
// First call should not have feature envs.
assert.NotContains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
assert.NotContains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
return nil
})
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
// Second call should have feature envs from the first config read.
assert.Contains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090")
assert.Contains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false")
return nil
})
// Wait until the ticker has been registered.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Verify agent was created successfully
require.Len(t, fSAC.created, 1)
})
t.Run("CommandEnv", func(t *testing.T) {
t.Parallel()

View File

@ -6,7 +6,10 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"golang.org/x/xerrors"
@ -26,12 +29,55 @@ type DevcontainerConfig struct {
type DevcontainerMergedConfiguration struct {
Customizations DevcontainerMergedCustomizations `json:"customizations,omitempty"`
Features DevcontainerFeatures `json:"features,omitempty"`
}
type DevcontainerMergedCustomizations struct {
Coder []CoderCustomization `json:"coder,omitempty"`
}
type DevcontainerFeatures map[string]any
// OptionsAsEnvs converts the DevcontainerFeatures into a list of
// environment variables that can be used to set feature options.
// The format is FEATURE_<FEATURE_NAME>_OPTION_<OPTION_NAME>=<value>.
// For example, if the feature is:
//
// "ghcr.io/coder/devcontainer-features/code-server:1": {
// "port": 9090,
// }
//
// It will produce:
//
// FEATURE_CODE_SERVER_OPTION_PORT=9090
//
// Note that the feature name is derived from the last part of the key,
// so "ghcr.io/coder/devcontainer-features/code-server:1" becomes
// "CODE_SERVER". The version part (e.g. ":1") is removed, and dashes in
// the feature and option names are replaced with underscores.
func (f DevcontainerFeatures) OptionsAsEnvs() []string {
var env []string
for k, v := range f {
vv, ok := v.(map[string]any)
if !ok {
continue
}
// Take the last part of the key as the feature name/path.
k = k[strings.LastIndex(k, "/")+1:]
// Remove ":" and anything following it.
if idx := strings.Index(k, ":"); idx != -1 {
k = k[:idx]
}
k = strings.ReplaceAll(k, "-", "_")
for k2, v2 := range vv {
k2 = strings.ReplaceAll(k2, "-", "_")
env = append(env, fmt.Sprintf("FEATURE_%s_OPTION_%s=%s", strings.ToUpper(k), strings.ToUpper(k2), fmt.Sprintf("%v", v2)))
}
}
slices.Sort(env)
return env
}
type DevcontainerConfiguration struct {
Customizations DevcontainerCustomizations `json:"customizations,omitempty"`
}

View File

@ -3,6 +3,7 @@ package agentcontainers_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
@ -13,6 +14,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
@ -637,3 +639,107 @@ func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
assert.NoError(t, err, "remove container failed")
}
}
func TestDevcontainerFeatures_OptionsAsEnvs(t *testing.T) {
t.Parallel()
realConfigJSON := `{
"mergedConfiguration": {
"features": {
"./code-server": {
"port": 9090
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
}
}
}`
var realConfig agentcontainers.DevcontainerConfig
err := json.Unmarshal([]byte(realConfigJSON), &realConfig)
require.NoError(t, err, "unmarshal JSON payload")
tests := []struct {
name string
features agentcontainers.DevcontainerFeatures
want []string
}{
{
name: "code-server feature",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
},
},
{
name: "docker-in-docker feature",
features: agentcontainers.DevcontainerFeatures{
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
},
},
want: []string{
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "multiple features with multiple options",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
"password": "secret",
},
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
"docker-dash-compose-version": "v2",
},
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PASSWORD=secret",
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
"FEATURE_DOCKER_IN_DOCKER_OPTION_DOCKER_DASH_COMPOSE_VERSION=v2",
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "feature with non-map value (should be ignored)",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
"./invalid-feature": "not-a-map",
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
},
},
{
name: "real config example",
features: realConfig.MergedConfiguration.Features,
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "empty features",
features: agentcontainers.DevcontainerFeatures{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.features.OptionsAsEnvs()
if diff := cmp.Diff(tt.want, got); diff != "" {
require.Failf(t, "OptionsAsEnvs() mismatch (-want +got):\n%s", diff)
}
})
}
}