Skip to content

Commit 7c16d6a

Browse files
aledbfclaude
andcommitted
test(e2e): validate 8 upstream bug fixes against real Docker
Add e2e tests that provision real containers/images to prove the upstream-bug fixes actually work end-to-end and to guard against regressions: - #331 Feature install sees a non-empty _REMOTE_USER_HOME - #308 Feature containerEnv ${localEnv:...} is substituted - #844 --remove-existing-container removes before initializeCommand - #930 compose `build --label` applies without --image-name - #969 sibling <Dockerfile>.dockerignore survives the stage rename - #109 updateRemoteUserUID remaps even when the home dir is absent The #331 test caught a real regression in the fix itself: `$(` with no trailing space made the shell parse `$((` as arithmetic expansion and abort the Feature build. Fixed by inserting a space after `$(` in generateFeatureBuildEnvVars. The #109 test asserts the UID/GID remap actually took effect (not just that `up` succeeds): pre-fix the chown aborted the remap build, the wrapper swallowed it, and the remap silently no-op'd. The fixture uses a base image without a uid/gid-1000 user so the remap proceeds to the chown path being validated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 90a060c commit 7c16d6a

2 files changed

Lines changed: 228 additions & 2 deletions

File tree

internal/cli/e2e_fixes_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strconv"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// writeFixture materializes a self-contained workspace from a path->content map
13+
// under a temp dir and returns the workspace root.
14+
func writeFixture(t *testing.T, files map[string]string) string {
15+
t.Helper()
16+
root := t.TempDir()
17+
for rel, content := range files {
18+
p := filepath.Join(root, rel)
19+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
20+
t.Fatal(err)
21+
}
22+
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
23+
t.Fatal(err)
24+
}
25+
}
26+
return root
27+
}
28+
29+
// dockerExec runs `docker exec <id> sh -lc <cmd>` and returns trimmed combined output.
30+
func dockerExec(t *testing.T, id, cmd string) string {
31+
t.Helper()
32+
out, err := exec.Command("docker", "exec", id, "sh", "-c", cmd).CombinedOutput()
33+
if err != nil {
34+
t.Fatalf("docker exec %q failed: %v\n%s", cmd, err, out)
35+
}
36+
return strings.TrimSpace(string(out))
37+
}
38+
39+
func removeByID(id string) {
40+
if id != "" {
41+
exec.Command("docker", "rm", "-f", id).Run()
42+
}
43+
}
44+
45+
// TestE2E_FeatureUserHomeAndLocalEnv validates two upstream fixes on a real
46+
// build+up with a local Feature:
47+
// - #331: the Feature install script sees a non-empty _REMOTE_USER_HOME.
48+
// - #308: a Feature's containerEnv ${localEnv:VAR} is resolved (not literal).
49+
func TestE2E_FeatureUserHomeAndLocalEnv(t *testing.T) {
50+
if !dockerAvailable() {
51+
t.Skip("Docker not available")
52+
}
53+
t.Setenv("E2E_LOCALENV_MARKER", "resolved-localenv-1234")
54+
55+
ws := writeFixture(t, map[string]string{
56+
".devcontainer/devcontainer.json": `{
57+
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
58+
"remoteUser": "vscode",
59+
"features": { "./localfeat": {} }
60+
}`,
61+
".devcontainer/localfeat/devcontainer-feature.json": `{
62+
"id": "localfeat",
63+
"version": "1.0.0",
64+
"name": "localfeat",
65+
"containerEnv": { "MY_LOCAL_ENV": "${localEnv:E2E_LOCALENV_MARKER}" }
66+
}`,
67+
".devcontainer/localfeat/install.sh": "#!/bin/sh\nset -e\n" +
68+
`echo "REMOTE_USER_HOME=[$_REMOTE_USER_HOME]" > /e2e-feature-marker` + "\n",
69+
})
70+
71+
upOut := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create")
72+
assertOutcome(t, upOut, "success")
73+
id, _ := parseJSON(t, upOut)["containerId"].(string)
74+
if id == "" {
75+
t.Fatal("no containerId")
76+
}
77+
defer removeByID(id)
78+
79+
// #331: install.sh captured the remote user's home at build time.
80+
if got := dockerExec(t, id, "cat /e2e-feature-marker"); !strings.Contains(got, "REMOTE_USER_HOME=[/home/vscode]") {
81+
t.Errorf("#331 _REMOTE_USER_HOME not resolved in Feature build: %q", got)
82+
}
83+
// #308: the Feature's containerEnv ${localEnv:...} was substituted.
84+
if got := dockerExec(t, id, "printenv MY_LOCAL_ENV"); got != "resolved-localenv-1234" {
85+
t.Errorf("#308 Feature containerEnv ${localEnv} = %q, want resolved-localenv-1234", got)
86+
}
87+
}
88+
89+
// TestE2E_RemoveExistingBeforeInitialize validates #844: with
90+
// --remove-existing-container, the old container is removed BEFORE
91+
// initializeCommand, so a failing initializeCommand does not leave it running.
92+
func TestE2E_RemoveExistingBeforeInitialize(t *testing.T) {
93+
if !dockerAvailable() {
94+
t.Skip("Docker not available")
95+
}
96+
ws := writeFixture(t, map[string]string{
97+
".devcontainer/devcontainer.json": `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu","overrideCommand":true}`,
98+
})
99+
100+
// 1) Provision the container.
101+
upOut := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create")
102+
assertOutcome(t, upOut, "success")
103+
oldID, _ := parseJSON(t, upOut)["containerId"].(string)
104+
if oldID == "" {
105+
t.Fatal("no containerId")
106+
}
107+
defer removeByID(oldID)
108+
109+
// 2) Add a failing initializeCommand, then up --remove-existing-container.
110+
// The up must fail (init exits 1) — but the old container must be gone.
111+
if err := os.WriteFile(filepath.Join(ws, ".devcontainer", "devcontainer.json"),
112+
[]byte(`{"image":"mcr.microsoft.com/devcontainers/base:ubuntu","overrideCommand":true,"initializeCommand":"exit 1"}`), 0o644); err != nil {
113+
t.Fatal(err)
114+
}
115+
upOut2 := runCLI(t, "up", "--workspace-folder", ws, "--remove-existing-container", "--skip-post-create")
116+
if oc := parseJSON(t, upOut2)["outcome"]; oc != "error" {
117+
t.Fatalf("second up outcome = %v, want error (initializeCommand fails)", oc)
118+
}
119+
120+
// The old container must have been removed before initializeCommand ran.
121+
if exec.Command("docker", "inspect", oldID).Run() == nil {
122+
t.Errorf("#844 old container %s still exists after a failed rebuild; removal did not precede initializeCommand", oldID[:12])
123+
}
124+
}
125+
126+
// TestE2E_ComposeBuildLabel validates #930: `build --label` on a Compose config
127+
// applies the label even without --image-name.
128+
func TestE2E_ComposeBuildLabel(t *testing.T) {
129+
if !dockerAvailable() {
130+
t.Skip("Docker not available")
131+
}
132+
ws := writeFixture(t, map[string]string{
133+
"docker-compose.yml": "services:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n command: sleep infinity\n",
134+
"Dockerfile": "FROM mcr.microsoft.com/devcontainers/base:ubuntu\n",
135+
".devcontainer/devcontainer.json": `{
136+
"dockerComposeFile": "../docker-compose.yml",
137+
"service": "app",
138+
"workspaceFolder": "/workspace"
139+
}`,
140+
})
141+
142+
buildOut := runCLI(t, "build", "--workspace-folder", ws, "--label", "e2e.compose.label=applied")
143+
assertOutcome(t, buildOut, "success")
144+
names, _ := parseJSON(t, buildOut)["imageName"].([]interface{})
145+
if len(names) == 0 {
146+
t.Fatal("no imageName in build output")
147+
}
148+
img, _ := names[0].(string)
149+
defer exec.Command("docker", "rmi", "-f", img).Run()
150+
151+
out, err := exec.Command("docker", "inspect", "--format", `{{index .Config.Labels "e2e.compose.label"}}`, img).CombinedOutput()
152+
if err != nil {
153+
t.Fatalf("inspect %s failed: %v\n%s", img, err, out)
154+
}
155+
if got := strings.TrimSpace(string(out)); got != "applied" {
156+
t.Errorf("#930 image %s label e2e.compose.label = %q, want applied (dropped without --image-name)", img, got)
157+
}
158+
}
159+
160+
// TestE2E_SiblingDockerignore validates #969: when the Dockerfile is renamed to
161+
// inject a final stage name, the user's sibling <Dockerfile>.dockerignore still
162+
// applies, so excluded files do not leak into the build context.
163+
func TestE2E_SiblingDockerignore(t *testing.T) {
164+
if !dockerAvailable() {
165+
t.Skip("Docker not available")
166+
}
167+
// The Dockerfile has an UNNAMED final stage, so the CLI rewrites it to a temp
168+
// file (the path that used to drop the sibling .dockerignore).
169+
ws := writeFixture(t, map[string]string{
170+
".devcontainer/devcontainer.json": `{"build":{"dockerfile":"Dockerfile"}}`,
171+
".devcontainer/Dockerfile": "FROM mcr.microsoft.com/devcontainers/base:ubuntu\nCOPY . /ctx-check/\nRUN if [ -e /ctx-check/secret.txt ]; then echo LEAKED > /dockerignore-result; else echo IGNORED_OK > /dockerignore-result; fi\n",
172+
".devcontainer/Dockerfile.dockerignore": "secret.txt\n",
173+
".devcontainer/secret.txt": "SUPERSECRET\n",
174+
})
175+
176+
buildOut := runCLI(t, "build", "--workspace-folder", ws, "--image-name", "e2e-dockerignore:test")
177+
assertOutcome(t, buildOut, "success")
178+
defer exec.Command("docker", "rmi", "-f", "e2e-dockerignore:test").Run()
179+
180+
out, err := exec.Command("docker", "run", "--rm", "e2e-dockerignore:test", "cat", "/dockerignore-result").CombinedOutput()
181+
if err != nil {
182+
t.Fatalf("docker run failed: %v\n%s", err, out)
183+
}
184+
if got := strings.TrimSpace(string(out)); got != "IGNORED_OK" {
185+
t.Errorf("#969 sibling .dockerignore not applied on rename: got %q (secret.txt leaked into the context)", got)
186+
}
187+
}
188+
189+
// TestE2E_UpdateUIDSystemUser validates #109: updateRemoteUserUID's chown no
190+
// longer aborts the remap build when the remote user has no home dir on disk
191+
// (system user). Pre-fix the chown failed, the wrapper swallowed it, and the
192+
// UID/GID remap silently did NOT happen; post-fix the remap succeeds. We assert
193+
// the remap actually took effect (svcuser's uid == the host uid).
194+
func TestE2E_UpdateUIDSystemUser(t *testing.T) {
195+
if !dockerAvailable() {
196+
t.Skip("Docker not available")
197+
}
198+
if os.Getuid() == 0 {
199+
t.Skip("updateRemoteUserUID is a no-op when the host runs as root")
200+
}
201+
ws := writeFixture(t, map[string]string{
202+
".devcontainer/devcontainer.json": `{"build":{"dockerfile":"Dockerfile"},"remoteUser":"svcuser","updateRemoteUserUID":true,"overrideCommand":true}`,
203+
// A system user (uid 999) with a home path in /etc/passwd that does NOT
204+
// exist on disk — the case where the pre-fix chown -R aborted the build.
205+
// The base MUST NOT already own uid/gid 1000 (the host uid/gid), or the
206+
// remap short-circuits on "user with UID exists" and never reaches the
207+
// chown. Plain ubuntu:22.04 has neither, so the remap proceeds.
208+
".devcontainer/Dockerfile": "FROM ubuntu:22.04\n" +
209+
"RUN useradd --system --no-create-home --home-dir /home/svcuser --shell /bin/sh svcuser\n",
210+
})
211+
212+
upOut := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create")
213+
assertOutcome(t, upOut, "success")
214+
id, _ := parseJSON(t, upOut)["containerId"].(string)
215+
if id == "" {
216+
t.Fatal("no containerId")
217+
}
218+
defer removeByID(id)
219+
220+
wantUID := strconv.Itoa(os.Getuid())
221+
if got := dockerExec(t, id, "getent passwd svcuser | cut -d: -f3"); got != wantUID {
222+
t.Errorf("#109 svcuser uid = %q, want %q — the UID/GID remap silently no-op'd (chown aborted the build)", got, wantUID)
223+
}
224+
}

internal/imagemeta/extend.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,10 @@ func generateFeatureBuildEnvVars(feat features.Feature, containerUser, remoteUse
236236
// common-utils may have just created the user, so a later feature must see
237237
// its home) — matching the TS CLI's _REMOTE_USER_HOME / _CONTAINER_USER_HOME
238238
// builtins. Command substitution, so these must not be single-quoted.
239-
fmt.Sprintf(`_CONTAINER_USER_HOME="$(%s | cut -d: -f6)"`, getEntPasswdExpr(containerUser)),
240-
fmt.Sprintf(`_REMOTE_USER_HOME="$(%s | cut -d: -f6)"`, getEntPasswdExpr(remoteUser)),
239+
// Note the space after `$(`: without it the leading `(` makes the shell
240+
// parse `$((` as arithmetic expansion and fail.
241+
fmt.Sprintf(`_CONTAINER_USER_HOME="$( %s | cut -d: -f6)"`, getEntPasswdExpr(containerUser)),
242+
fmt.Sprintf(`_REMOTE_USER_HOME="$( %s | cut -d: -f6)"`, getEntPasswdExpr(remoteUser)),
241243
)
242244

243245
// Main value — TS normalizes option objects to true and only emits the

0 commit comments

Comments
 (0)