Skip to content

chore: pull in cherry picks for v2.24 #18674

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 33 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
73ffb71
chore: remove chats experiment (#18535)
dannykopping Jun 25, 2025
cc51308
feat: allow new immutable parameters for existing workspaces (#18579)
Emyrk Jun 25, 2025
fac6263
chore: upgrade aisdk-go lib, remove vestigial code (#18577)
dannykopping Jun 25, 2025
4c938c0
refactor: remove beta label from 'select a preset' menu (#18538)
BrunoQuaresma Jun 25, 2025
19d8a40
fix: hide the preset parameter visibility switch when it has no effec…
SasSwart Jun 26, 2025
a4a5892
feat: graduate prebuilds to general availability (#18607)
SasSwart Jun 26, 2025
8e6417a
fix(agent): start devcontainers through agentcontainers package (#18471)
DanielleMaywood Jun 25, 2025
63b619c
fix(agent/agentcontainers): filter out "is test run" devcontainers (#…
mafredri Jun 25, 2025
03d1570
feat(agent/agentcontainers): add feature options as envs (#18576)
mafredri Jun 25, 2025
5be5bf3
fix(coderd/agentapi): make sub agent slugs more unique (#18581)
DanielleMaywood Jun 25, 2025
cf6d208
feat(agent/agentcontainers): add more envs to readconfig for app URL …
mafredri Jun 26, 2025
226aec1
fix!: use devcontainer ID when rebuilding a devcontainer (#18604)
DanielleMaywood Jun 26, 2025
766fc4c
fix(agent/agentcontainers): chown coder binary (#18611)
DanielleMaywood Jun 26, 2025
07d749c
fix(agent/agentcontainers): stop logging empty lines (#18605)
DanielleMaywood Jun 26, 2025
aab5059
chore: parse app status link (#18439)
code-asher Jun 26, 2025
cbc97de
fix(agent): delay containerAPI init to ensure startup scripts run bef…
mafredri Jun 27, 2025
5058d1b
feat: add task link in the workspace page when it is running a task (…
BrunoQuaresma Jun 27, 2025
b7c7d1a
refactor: move required external auth buttons to the submit side (#18…
BrunoQuaresma Jun 27, 2025
b8930ec
fix(agent): fix script filtering for devcontainers (#18635)
mafredri Jun 27, 2025
1891c6f
refactor: show the apps as soon as possible (#18625)
BrunoQuaresma Jun 27, 2025
99f3664
feat: redirect to the task page after creation (#18626)
BrunoQuaresma Jun 27, 2025
19089ad
fix: use default preset when creating a workspace for task (#18623)
BrunoQuaresma Jun 27, 2025
47f813e
fix(agent/agentcontainers): ensure proper channel closure for updateT…
mafredri Jun 27, 2025
2a68b01
fix(agent/agentcontainers): split Init into Init and Start for early …
mafredri Jun 27, 2025
2daec01
feat: make task panels resizable (#18590)
BrunoQuaresma Jun 27, 2025
9e923f9
fix: use only template version ID to create task workspace (#18642)
BrunoQuaresma Jun 27, 2025
830a9ed
chore: add beta badge to tasks (#18656)
dannykopping Jun 30, 2025
da71915
fix(agent/agentcontainers): always derive devcontainer name from work…
mafredri Jun 30, 2025
9a9dd5d
fix: handle health status when displaying task apps (#18675)
hugodutka Jun 30, 2025
4bcbc96
fix: improve reliability of app statuses (#18622)
code-asher Jun 30, 2025
2d210aa
chore: fix idle state icon when disabled (#18554)
code-asher Jun 25, 2025
459b9d9
docs: add warning about prebuilds incompatibility with certain featur…
ssncferreira Jul 1, 2025
b975443
Merge branch 'release/2.24' into cherry-picks-2.24
stirby Jul 1, 2025
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
Prev Previous commit
Next Next commit
feat(agent/agentcontainers): add feature options as envs (#18576)
(cherry picked from commit 3c4d920)
  • Loading branch information
mafredri authored and stirby committed Jun 30, 2025
commit 03d1570834be1f0056f250095d0c89df86fc752b
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)
}
})
}
}