Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions agent/agentcontainers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}

var (
featureOptionsAsEnvs []string
appsWithPossibleDuplicates []SubAgentApp
workspaceFolder = DevcontainerDefaultContainerWorkspaceFolder
)
Expand All @@ -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{
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),
})
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 {
Expand All @@ -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
Expand Down
116 changes: 116 additions & 0 deletions agent/agentcontainers/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
46 changes: 46 additions & 0 deletions agent/agentcontainers/devcontainercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"

"golang.org/x/xerrors"

Expand All @@ -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"`
}
Expand Down
106 changes: 106 additions & 0 deletions agent/agentcontainers/devcontainercli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agentcontainers_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
}
})
}
}
Loading