From 6a6f0f15efc04c7afdda071123a9add467ba6222 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 15:25:24 -0500 Subject: [PATCH 1/3] feat(cli): add --id-label flag for custom container identification Implements the --id-label flag on `up` and `exec` commands, allowing users to override the default `dev.containers.id` label used for container identification. When specified, custom labels replace the default label for both container creation and lookup. --- cmd/exec.go | 16 +++- cmd/up.go | 6 ++ pkg/devcontainer/compose.go | 10 ++- pkg/devcontainer/config/build.go | 20 +++++ pkg/devcontainer/config/build_test.go | 111 ++++++++++++++++++++++++++ pkg/devcontainer/run.go | 4 +- pkg/driver/docker/docker.go | 10 ++- pkg/provider/workspace.go | 1 + 8 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 pkg/devcontainer/config/build_test.go diff --git a/cmd/exec.go b/cmd/exec.go index 3d061e0c9..14d9ecf3f 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -30,6 +30,7 @@ type ExecCmd struct { WorkspaceFolder string RemoteEnv []string DefaultUserEnvProbe string + IDLabels []string } func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { @@ -66,6 +67,13 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { "", "Override userEnvProbe from config (loginInteractiveShell, loginShell, interactiveShell, none)", ) + execCmd.Flags(). + StringArrayVar( + &cmd.IDLabels, + "id-label", + []string{}, + "Override the default container identification labels (format: key=value, can be specified multiple times)", + ) return execCmd } @@ -74,6 +82,9 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { if err := cmd.validateRemoteEnv(); err != nil { return err } + if err := devcconfig.ValidateIDLabels(cmd.IDLabels); err != nil { + return err + } devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { @@ -93,7 +104,7 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { dockerCommand := resolveDockerCommand(workspaceConfig) containerDetails, err := findRunningContainer( - ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), + ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, ) if err != nil { return err @@ -158,12 +169,13 @@ func findRunningContainer( ctx context.Context, dockerCommand string, workspaceID string, + idLabels []string, ) (*devcconfig.ContainerDetails, error) { dockerHelper := &docker.DockerHelper{ DockerCommand: dockerCommand, } - labels := devcconfig.GetDockerLabelForID(workspaceID) + labels := devcconfig.GetIDLabels(workspaceID, idLabels) container, err := dockerHelper.FindDevContainer(ctx, labels) if err != nil { return nil, fmt.Errorf("find container: %w", err) diff --git a/cmd/up.go b/cmd/up.go index b130d17e6..88a5cefd8 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -99,6 +99,9 @@ func (cmd *UpCmd) validate() error { if err := validatePodmanFlags(cmd); err != nil { return err } + if err := config2.ValidateIDLabels(cmd.IDLabels); err != nil { + return err + } if cmd.ExtraDevContainerPath != "" { absPath, err := filepath.Abs(cmd.ExtraDevContainerPath) if err != nil { @@ -165,6 +168,9 @@ func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { upCmd.Flags(). StringVar(&cmd.AdditionalFeatures, "additional-features", "", `Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)`) + upCmd.Flags(). + StringArrayVar(&cmd.IDLabels, "id-label", []string{}, + "Override the default container identification labels (format: key=value, can be specified multiple times)") } func (cmd *UpCmd) registerIDEFlags(upCmd *cobra.Command) { diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index b64bdc99e..2a6006fe4 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -1091,8 +1091,14 @@ exec "$$@" } entrypoint = append(entrypoint, userEntrypoint...) - labels := composetypes.Labels{ - config.DockerIDLabel: r.ID, + labels := composetypes.Labels{} + if len(r.IDLabels) > 0 { + for _, l := range r.IDLabels { + k, v, _ := strings.Cut(l, "=") + labels[k] = v + } + } else { + labels[config.DockerIDLabel] = r.ID } for k, v := range additionalLabels { // Escape $ and ' to prevent substituting local environment variables! diff --git a/pkg/devcontainer/config/build.go b/pkg/devcontainer/config/build.go index f70c15e10..076687747 100644 --- a/pkg/devcontainer/config/build.go +++ b/pkg/devcontainer/config/build.go @@ -1,6 +1,9 @@ package config import ( + "fmt" + "strings" + pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/dockerfile" ) @@ -17,6 +20,23 @@ func GetDockerLabelForID(id string) []string { return []string{DockerIDLabel + "=" + id} } +func GetIDLabels(id string, idLabels []string) []string { + if len(idLabels) > 0 { + return idLabels + } + return GetDockerLabelForID(id) +} + +func ValidateIDLabels(labels []string) error { + for _, label := range labels { + k, _, ok := strings.Cut(label, "=") + if !ok || k == "" { + return fmt.Errorf("invalid --id-label %q: must be in key=value format", label) + } + } + return nil +} + type BuildInfo struct { ImageDetails *ImageDetails ImageMetadata *ImageMetadataConfig diff --git a/pkg/devcontainer/config/build_test.go b/pkg/devcontainer/config/build_test.go new file mode 100644 index 000000000..b1041769f --- /dev/null +++ b/pkg/devcontainer/config/build_test.go @@ -0,0 +1,111 @@ +package config + +import ( + "testing" +) + +const ( + testWorkspaceID = "workspace-123" + testLabelApp = "app=myapp" +) + +func TestGetIDLabels(t *testing.T) { + tests := []struct { + name string + id string + idLabels []string + want []string + }{ + { + name: "no custom labels uses default", + id: testWorkspaceID, + idLabels: nil, + want: []string{"dev.containers.id=" + testWorkspaceID}, + }, + { + name: "empty slice uses default", + id: testWorkspaceID, + idLabels: []string{}, + want: []string{"dev.containers.id=" + testWorkspaceID}, + }, + { + name: "custom labels replace default", + id: testWorkspaceID, + idLabels: []string{"myapp.id=custom"}, + want: []string{"myapp.id=custom"}, + }, + { + name: "multiple custom labels", + id: testWorkspaceID, + idLabels: []string{testLabelApp, "env=dev"}, + want: []string{testLabelApp, "env=dev"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetIDLabels(tt.id, tt.idLabels) + if len(got) != len(tt.want) { + t.Fatalf("GetIDLabels() returned %d labels, want %d", len(got), len(tt.want)) + } + for i, label := range got { + if label != tt.want[i] { + t.Errorf("GetIDLabels()[%d] = %q, want %q", i, label, tt.want[i]) + } + } + }) + } +} + +func TestValidateIDLabels(t *testing.T) { + tests := []struct { + name string + labels []string + wantErr bool + }{ + { + name: "valid single label", + labels: []string{"key=value"}, + wantErr: false, + }, + { + name: "valid multiple labels", + labels: []string{testLabelApp, "env=prod"}, + wantErr: false, + }, + { + name: "empty value is valid", + labels: []string{"key="}, + wantErr: false, + }, + { + name: "empty slice is valid", + labels: []string{}, + wantErr: false, + }, + { + name: "missing equals sign", + labels: []string{"noequalssign"}, + wantErr: true, + }, + { + name: "empty key", + labels: []string{"=value"}, + wantErr: true, + }, + { + name: "one valid one invalid", + labels: []string{"good=label", "bad"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateIDLabels(tt.labels) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateIDLabels() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index e75dda2c4..1e959f055 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -61,6 +61,7 @@ func NewRunner( AgentDownloadURL: agentDownloadURL, LocalWorkspaceFolder: workspaceConfig.ContentFolder, ID: GetRunnerIDFromWorkspace(workspaceConfig.Workspace), + IDLabels: workspaceConfig.CLIOptions.IDLabels, WorkspaceConfig: workspaceConfig, }, nil } @@ -74,7 +75,8 @@ type runner struct { LocalWorkspaceFolder string - ID string + ID string + IDLabels []string } type UpOptions struct { diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index c26e0c060..1fcd08cc8 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -57,12 +57,14 @@ func NewDockerDriver( ContainerID: workspaceInfo.Workspace.Source.Container, Builder: builder, }, + IDLabels: workspaceInfo.CLIOptions.IDLabels, }, nil } type dockerDriver struct { - Docker *docker.DockerHelper - Compose *compose.ComposeHelper + Docker *docker.DockerHelper + Compose *compose.ComposeHelper + IDLabels []string } func (d *dockerDriver) TargetArchitecture(ctx context.Context, workspaceId string) (string, error) { @@ -243,7 +245,7 @@ func (d *dockerDriver) FindDevContainer( } else { containerDetails, err = d.Docker.FindDevContainer( ctx, - []string{config.DockerIDLabel + "=" + workspaceId}, + config.GetIDLabels(workspaceId, d.IDLabels), ) } if err != nil { @@ -735,7 +737,7 @@ func (d *dockerDriver) addLabelArgs( workspaceId string, options *driver.RunOptions, ) []string { - labels := append(config.GetDockerLabelForID(workspaceId), options.Labels...) + labels := append(config.GetIDLabels(workspaceId, d.IDLabels), options.Labels...) for _, label := range labels { args = append(args, "-l", label) } diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index ea59f815a..a8ec2a361 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -234,6 +234,7 @@ type CLIOptions struct { Userns string `json:"userns,omitempty"` UidMap []string `json:"uidMap,omitempty"` GidMap []string `json:"gidMap,omitempty"` + IDLabels []string `json:"idLabels,omitempty"` // dotfiles options DotfilesRepo string `json:"dotfilesRepo,omitempty"` From 55bf180d56c33bd4e28492f0c086e64bf94fb7bd Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 15:34:12 -0500 Subject: [PATCH 2/3] test(e2e): add E2E tests for --id-label flag Adds two E2E tests verifying --id-label behavior: - up: custom labels replace default dev.containers.id label on container - exec: finds and executes in container identified by custom labels --- e2e/tests/exec/exec.go | 19 +++++++++++++++++++ e2e/tests/up/provider_docker.go | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/e2e/tests/exec/exec.go b/e2e/tests/exec/exec.go index 1fbb1bde1..1615aed07 100644 --- a/e2e/tests/exec/exec.go +++ b/e2e/tests/exec/exec.go @@ -159,4 +159,23 @@ var _ = ginkgo.Describe("devsy exec test suite", ginkgo.Label("exec"), ginkgo.Or }) framework.ExpectError(err) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should find container by custom id-label", + func(ctx context.Context) { + tempDir, f, err := setupWorkspace("tests/exec/testdata/exec", initialDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir, + "--id-label", "devsy.exec.test=idlabel") + framework.ExpectNoError(err) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + execCommand, + workspaceFolderFlag, tempDir, + "--id-label", "devsy.exec.test=idlabel", + "--", echoCommand, "-n", "found", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.Equal("found")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) }) diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 6969d01a5..932b3a621 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -536,5 +536,29 @@ var _ = ginkgo.Describe( err = dtc.f.DevsyWorkspaceDelete(ctx, tempDir) framework.ExpectNoError(err) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("id-label replaces default container label", func(ctx context.Context) { + _, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker", + "--id-label", "devsy.test.app=myproject", + "--id-label", "devsy.test.env=ci") + framework.ExpectNoError(err) + + ids, err := dtc.dockerHelper.FindContainer( + ctx, + []string{"devsy.test.app=myproject", "devsy.test.env=ci"}, + ) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1)) + + var containerDetails []container.InspectResponse + err = dtc.dockerHelper.Inspect(ctx, ids, "container", &containerDetails) + framework.ExpectNoError(err) + gomega.Expect(containerDetails[0].Config.Labels).To(gomega.HaveKeyWithValue( + "devsy.test.app", "myproject")) + gomega.Expect(containerDetails[0].Config.Labels).To(gomega.HaveKeyWithValue( + "devsy.test.env", "ci")) + gomega.Expect(containerDetails[0].Config.Labels).NotTo(gomega.HaveKey( + "dev.containers.id")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) }, ) From 4e48c23783e7ad765f0cf400e452864073cc7e13 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 16:19:20 -0500 Subject: [PATCH 3/3] fix(e2e): adjust id-label assertion for image-inherited labels The base image (go:1) has dev.containers.id=go baked in as an image label. Assert that the workspace UID is not used as the label value instead of asserting the key is absent entirely. --- e2e/tests/up/provider_docker.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 9b94d31c4..4deedbb4e 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -726,7 +726,7 @@ var _ = ginkgo.Describe( }, ginkgo.SpecTimeout(framework.TimeoutModerate())) ginkgo.It("id-label replaces default container label", func(ctx context.Context) { - _, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker", + tempDir, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker", "--id-label", "devsy.test.app=myproject", "--id-label", "devsy.test.env=ci") framework.ExpectNoError(err) @@ -745,8 +745,12 @@ var _ = ginkgo.Describe( "devsy.test.app", "myproject")) gomega.Expect(containerDetails[0].Config.Labels).To(gomega.HaveKeyWithValue( "devsy.test.env", "ci")) - gomega.Expect(containerDetails[0].Config.Labels).NotTo(gomega.HaveKey( - "dev.containers.id")) + + workspace, err := dtc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + gomega.Expect(containerDetails[0].Config.Labels["dev.containers.id"]). + NotTo(gomega.Equal(workspace.UID), + "workspace UID should not be used as label when --id-label is specified") }, ginkgo.SpecTimeout(framework.TimeoutShort())) }, )