From 156dd0e3bf5847068d4da3ce224d773b6d400299 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 16:35:15 -0300 Subject: [PATCH 1/3] feat(cli): trust an extra CA bundle in builds and the container --ca-bundle on build, up and run-user-commands takes a PEM bundle (the system CAs plus a TLS-intercepting proxy's). Every Feature install RUN mounts it as the BuildKit secret devcontainer-ca-bundle at /run/devcontainer/ca-bundle.crt and runs install.sh with SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and GIT_SSL_CAINFO pointing at it, for that RUN only. A user Dockerfile is given the same secret id and opts in with RUN --mount. up binds the file read-only at the same path and sets the variables in the container's environment; run-user-commands sets them for the lifecycle commands and refuses a container created without the mount. The bundle is mounted, never copied: no layer, config or history holds it, and its content is outside the cache key, so a rotated CA reuses the cache and builds the same layers. Without buildx it is left out of builds with a warning; a Compose configuration's build takes no secrets, so only its container gets the bundle. up --secrets-file now reaches the Dockerfile's own RUNs too, as build does; it reached only the Features' before. Tests hold a DOCKER_CONFIG off $HOME behind a symlink, and the bridged build auth under TMPDIR, which the docs now say. Closes #50 Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 4 +- docs/go-only-features.md | 55 +++++ docs/parity/cli-flags-inventory.yaml | 14 ++ internal/cli/build.go | 13 +- internal/cli/ca_bundle.go | 81 +++++++ internal/cli/ca_bundle_test.go | 200 +++++++++++++++++ internal/cli/compose_up.go | 10 + internal/cli/compose_up_held_test.go | 36 +++ internal/cli/e2e_ca_bundle_test.go | 319 +++++++++++++++++++++++++++ internal/cli/feature_install.go | 4 + internal/cli/run_user_commands.go | 14 +- internal/cli/up.go | 24 +- internal/docker/client.go | 6 + internal/docker/inline_cache_test.go | 13 ++ internal/imagemeta/extend.go | 28 ++- internal/imagemeta/extend_test.go | 37 ++++ internal/oci/buildauth_test.go | 50 +++++ 17 files changed, 895 insertions(+), 13 deletions(-) create mode 100644 internal/cli/ca_bundle.go create mode 100644 internal/cli/ca_bundle_test.go create mode 100644 internal/cli/e2e_ca_bundle_test.go diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index 6df3000..d512757 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -15,7 +15,9 @@ touches a compared surface, reflected in the parity matrix. `check` and `setup` (host preflight/remediation), `up --cache-image` (boot from a prebuilt image, skip build + feature install), `read-configuration --cache-key` (deterministic content hash; additive — default output is byte-identical to TS), - `build --secrets-file` (BuildKit build secrets; TS `build` has no such flag), `open` + `build --secrets-file` (BuildKit build secrets; TS `build` has no such flag), + `--ca-bundle` on `build`, `up` and `run-user-commands` (extra CAs trusted in Feature + installs, opted-in Dockerfile `RUN`s and the container, mounted and never copied), `open` (launch VS Code attached to the workspace's dev container via a vscode-remote:// URI), `stop`/`down` (pause, or stop+remove, a workspace's container — Compose-aware; the upstream CLI leaves teardown to the editor), and the automatic credential bridge that diff --git a/docs/go-only-features.md b/docs/go-only-features.md index c76dfda..d22ddc5 100644 --- a/docs/go-only-features.md +++ b/docs/go-only-features.md @@ -12,6 +12,7 @@ divergences, each covered by tests. - [`up --cache-image`](#up---cache-image) — boot from a prebuilt image - [`read-configuration --cache-key`](#read-configuration---cache-key) — deterministic cache key - [`build --secrets-file`](#build---secrets-file) — BuildKit build secrets +- [`--ca-bundle`](#--ca-bundle) — trust extra CAs in builds and in the container - [Automatic credential bridge](#automatic-credential-bridge) — private registries in `docker build` - [`--override-config` deep-merge](#--override-config-deep-merge) — partial overrides - [`config.build.cacheFrom`](#configbuildcachefrom) — honored from devcontainer.json @@ -197,6 +198,54 @@ A Docker Compose configuration is built by `docker compose build`, as the refere builds it, which takes no build secrets from the command line: there the flag is ignored with a warning. +`up` takes `--secrets-file` too: the secrets reach the Dockerfile's and the Features' +`RUN --mount=type=secret` the same way, and the lifecycle commands as environment +variables. Neither leaves the value in a layer, the image config or a container label. + +## `--ca-bundle` + +Trust extra certificate authorities, such as a TLS-intercepting proxy's, in image builds +and in the running container, without putting them in the image. + +```sh +devcontainer up --workspace-folder . --ca-bundle /run/proxy/ca-bundle.crt +``` + +The file is a full PEM bundle: the system CAs plus the extra ones, since some tools +replace the system pool with the file instead of adding to it. A file with no PEM +certificate is refused. `build`, `up` and `run-user-commands` take the flag. + +| Where | What happens | +| --- | --- | +| Feature install (`build`, `up`) | Every Feature's install `RUN` mounts the bundle as the BuildKit secret `devcontainer-ca-bundle` at `/run/devcontainer/ca-bundle.crt`, and runs `install.sh` with `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` pointing at it, for that `RUN` only. | +| Your Dockerfile (`build`, `up`) | The CLI cannot change your `RUN`s, so it passes the same secret to buildx; a `RUN` opts in by mounting it (below). | +| Container (`up`) | The bundle is bind-mounted read-only at `/run/devcontainer/ca-bundle.crt` and the five variables are set in the container's environment, over the configuration's `containerEnv`, so lifecycle commands and every shell trust it. | +| `run-user-commands` | Sets the variables for the lifecycle commands. A running container takes no new mount, so the container must have been created with `up --ca-bundle`; one without the mount is an error. | + +```dockerfile +RUN --mount=type=secret,id=devcontainer-ca-bundle,target=/run/devcontainer/ca-bundle.crt \ + SSL_CERT_FILE=/run/devcontainer/ca-bundle.crt CURL_CA_BUNDLE=/run/devcontainer/ca-bundle.crt \ + ./install-things.sh +``` + +The secret is mounted readable by root (mode 0400); a `RUN` as another user adds +`uid=` or `mode=0444` to the mount. + +The bundle is mounted, never copied: no layer, image config or history holds it, and its +content is not part of BuildKit's cache key, so a different bundle (a rotated proxy CA) +reuses the cache and builds the same image. A build with the flag still differs from one +without it in the Feature `RUN` instructions themselves (the secret mount and the +variables are part of the instruction), so their caches are not shared. + +The bind mount holds the file it was created with: replace the bundle's content in place +rather than renaming a new file over it, or recreate the container. + +Builds need buildx; with the legacy builder the bundle is left out of the build with a +warning, as `--secrets-file` is. A Docker Compose configuration's images are built by +`docker compose build`, which takes no build secrets, so the bundle does not reach them +(a warning says so); `up` still mounts it into the Compose service's container with the +same variables. + ## Automatic credential bridge Not a flag — a behavior. The upstream CLI's credential chain applies only to its *own* @@ -209,6 +258,12 @@ helpers, `GITHUB_TOKEN` — and hands them to the `docker build` subprocess via self-contained `DOCKER_CONFIG`. If no credentials resolve, nothing changes: a build that already authenticated via the environment behaves exactly as before. +The temporary `DOCKER_CONFIG` is a directory under `TMPDIR` (Go's `os.TempDir`), removed +when the build ends. A caller that must keep registry credentials off disk points `TMPDIR` +at a tmpfs. The credentials themselves are read from `$DOCKER_CONFIG/config.json` when +`DOCKER_CONFIG` is set, which may live outside `$HOME` and be a symlink (a tmpfs or FUSE +mount), and from `~/.docker/config.json` only when it is not. + ## `--override-config` deep-merge **Documented divergence.** The upstream CLI *replaces* the configuration wholesale with the diff --git a/docs/parity/cli-flags-inventory.yaml b/docs/parity/cli-flags-inventory.yaml index 4e81a29..57cdacf 100644 --- a/docs/parity/cli-flags-inventory.yaml +++ b/docs/parity/cli-flags-inventory.yaml @@ -213,6 +213,11 @@ commands: secrets-file: type: string description: "Path to a json file containing secret environment variables as key-value pairs." + # Go-only divergence: TS has no ca-bundle. A PEM bundle given to builds as + # the secret devcontainer-ca-bundle and bound read-only into the container. + ca-bundle: + type: string + description: "PEM bundle of CAs to trust in builds and in the container." experimental-lockfile: type: boolean default: false @@ -425,6 +430,11 @@ commands: secrets-file: type: string description: "Path to a JSON file with build secrets, passed to BuildKit as build secrets (requires buildx)." + # Go-only divergence: TS has no ca-bundle. A PEM bundle given to Feature + # installs and Dockerfile RUNs as the build secret devcontainer-ca-bundle. + ca-bundle: + type: string + description: "PEM bundle of CAs to trust in Feature installs and Dockerfile RUNs (build secret devcontainer-ca-bundle)." omit-syntax-directive: type: boolean default: false @@ -516,6 +526,10 @@ commands: type: string secrets-file: type: string + # Go-only divergence: TS has no ca-bundle. The bundle's variables for the + # lifecycle commands of a container up created with --ca-bundle. + ca-bundle: + type: string log-file: type: string description: "Log file path." diff --git a/internal/cli/build.go b/internal/cli/build.go index f1eb261..9cc861b 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -44,6 +44,7 @@ type buildOpts struct { noLockfile bool frozenLockfile bool secretsFile string + caBundle string // lockfileExcludeIDs is populated after merging --additional-features; it lists // userFeatureIds supplied only via that flag, which 0.88 keeps out of the lockfile. lockfileExcludeIDs map[string]bool @@ -93,6 +94,7 @@ func newBuildCmd() *cobra.Command { f.BoolVar(&opts.noLockfile, "no-lockfile", false, "Disable lockfile generation and verification.") f.BoolVar(&opts.frozenLockfile, "frozen-lockfile", false, "Ensure lockfile exists and remains unchanged; fail otherwise.") f.StringVar(&opts.secretsFile, "secrets-file", "", "Path to a JSON file with build secrets ({\"KEY\":\"VALUE\"}); each is passed to BuildKit as a build secret (requires buildx).") + f.StringVar(&opts.caBundle, "ca-bundle", "", caBundleFlagUsage) f.Bool("omit-syntax-directive", false, "") cmd.Flags().MarkHidden("skip-feature-auto-mapping") cmd.Flags().MarkHidden("skip-persisting-customizations-from-features") @@ -127,6 +129,10 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { ); err != nil { return writeValidationError(out, err.Error()) } + var err error + if opts.caBundle, err = resolveCABundle(opts.caBundle); err != nil { + return writeValidationError(out, err.Error()) + } // --no-lockfile is mutually exclusive with every frozen/write lockfile flag // (TS devContainersSpecCLI check for the build command). @@ -397,7 +403,8 @@ func prepareDockerfileBuild(cfg *config.DevContainer) (*dockerfileBuildPrep, err // build.subfolder-config-success, build.apply-build-options-success, // build.output-oci-success, build.cache-to-local-success, // build.platform-supported-dockerfile-success), also run by -// TestE2E_BuildUpExec_Dockerfile and TestE2E_SiblingDockerignore. +// TestE2E_BuildUpExec_Dockerfile, TestE2E_SiblingDockerignore and +// TestE2E_CABundle (an opted-in RUN reads the bundle). func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, useBuildx bool) ([]string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts @@ -456,6 +463,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai } buildSecrets = buildxSecrets(secrets, useBuildx, logger) } + caSecrets := caBundleBuildSecrets(opts.caBundle, useBuildx, logger) buildOpts := docker.BuildOptions{ Dockerfile: dockerfilePath, @@ -474,6 +482,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai CacheTo: opts.cacheTo, Env: authEnv, Secrets: buildSecrets, + FileSecrets: caSecrets, } result, err := dockerClient.Build(ctx, buildOpts) @@ -871,6 +880,7 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer experimentalFrozenLockfile: opts.experimentalFrozenLockfile, noLockfile: opts.noLockfile, lockfileExcludeIDs: opts.lockfileExcludeIDs, + caBundle: opts.caBundle, }, substitute: hostMetadataSubstitution(loadResult.HostSub, nil), configLabel: r.configLabel, @@ -951,6 +961,7 @@ func (r *buildRunner) featureBuildOptions(cfg *config.DevContainer, hostSub *con opts := r.opts return &FeatureBuildOptions{ Secrets: buildSecretsFromFile(opts.secretsFile), + CABundle: opts.caBundle, OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, NoCache: opts.noCache, CacheFrom: opts.cacheFrom, diff --git a/internal/cli/ca_bundle.go b/internal/cli/ca_bundle.go new file mode 100644 index 0000000..853a833 --- /dev/null +++ b/internal/cli/ca_bundle.go @@ -0,0 +1,81 @@ +package cli + +import ( + "crypto/x509" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" + dockermount "github.com/moby/moby/api/types/mount" +) + +const caBundleFlagUsage = "PEM bundle of CAs to trust: a build secret `" + imagemeta.CABundleSecretID + "` for Feature installs and Dockerfile RUNs, and a read-only mount at " + imagemeta.CABundlePath + " in the container (requires buildx for builds)." + +const caBundleComposeBuildWarning = "--ca-bundle does not reach a Docker Compose configuration's build: `docker compose build` takes no build secrets." + +func resolveCABundle(path string) (string, error) { + if path == "" { + return "", nil + } + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("--ca-bundle: %w", err) + } + pem, err := os.ReadFile(abs) + if err != nil { + return "", fmt.Errorf("--ca-bundle: %w", err) + } + if !x509.NewCertPool().AppendCertsFromPEM(pem) { + return "", fmt.Errorf("--ca-bundle: %s holds no PEM certificate", abs) + } + return abs, nil +} + +func caBundleBuildSecrets(path string, useBuildx bool, logger log.Logger) map[string]string { + if path == "" { + return nil + } + if !useBuildx { + logger.Write("--ca-bundle requires BuildKit (buildx); the bundle is not given to the legacy builder", log.LevelWarning) + return nil + } + return map[string]string{imagemeta.CABundleSecretID: path} +} + +func caBundleMount(path string) dockermount.Mount { + return dockermount.Mount{Type: dockermount.TypeBind, Source: path, Target: imagemeta.CABundlePath, ReadOnly: true} +} + +func withCABundleEnv(env map[string]string, path string) map[string]string { + if path == "" { + return env + } + out := maps.Clone(env) + if out == nil { + out = map[string]string{} + } + for _, kv := range imagemeta.CABundleEnv() { + k, v, _ := strings.Cut(kv, "=") + out[k] = v + } + return out +} + +func caBundleRemoteEnv(path string) []string { + if path == "" { + return nil + } + return imagemeta.CABundleEnv() +} + +func containerHasCABundle(mounts []container.MountPoint) bool { + return slices.ContainsFunc(mounts, func(m container.MountPoint) bool { + return m.Destination == imagemeta.CABundlePath + }) +} diff --git a/internal/cli/ca_bundle_test.go b/internal/cli/ca_bundle_test.go new file mode 100644 index 0000000..3058813 --- /dev/null +++ b/internal/cli/ca_bundle_test.go @@ -0,0 +1,200 @@ +package cli + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "maps" + "math/big" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" + dockermount "github.com/moby/moby/api/types/mount" +) + +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + pem []byte +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "devcontainer-cli test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return &testCA{cert: cert, key: key, pem: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})} +} + +func (ca *testCA) serverPEM(t *testing.T, ip net.IP) (certPEM, keyPEM []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: ip.String()}, + IPAddresses: []net.IP{ip}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) +} + +func writeTestFile(t *testing.T, path string, data []byte) string { + t.Helper() + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestResolveCABundle(t *testing.T) { + if got, err := resolveCABundle(""); got != "" || err != nil { + t.Errorf(`resolveCABundle("") = %q, %v; want none`, got, err) + } + dir := t.TempDir() + t.Chdir(dir) + writeTestFile(t, "bundle.crt", newTestCA(t).pem) + if got, err := resolveCABundle("bundle.crt"); err != nil || got != filepath.Join(dir, "bundle.crt") { + t.Errorf("resolveCABundle(relative) = %q, %v; want %s", got, err, filepath.Join(dir, "bundle.crt")) + } + if _, err := resolveCABundle("missing.crt"); err == nil || !strings.Contains(err.Error(), "--ca-bundle") { + t.Errorf("missing file: err = %v, want a --ca-bundle error", err) + } + writeTestFile(t, "empty.crt", []byte("not a certificate\n")) + if _, err := resolveCABundle("empty.crt"); err == nil || !strings.Contains(err.Error(), "holds no PEM certificate") { + t.Errorf("no certificate: err = %v, want it refused", err) + } +} + +func TestCABundleFlagIsValidated(t *testing.T) { + bad := writeTestFile(t, filepath.Join(t.TempDir(), "bad.crt"), []byte("nothing")) + for _, args := range [][]string{ + {"build", "--ca-bundle", bad}, + {"up", "--ca-bundle", bad}, + {"run-user-commands", "--ca-bundle", bad}, + } { + var stderr bytes.Buffer + root := NewRootCommand() + root.SetArgs(append(args, "--workspace-folder", t.TempDir())) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&stderr) + root.SilenceErrors = true + if err := root.Execute(); err == nil || !strings.Contains(stderr.String(), "--ca-bundle: "+bad+" holds no PEM certificate") { + t.Errorf("%s: err %v, stderr %q; want the bundle refused", args[0], err, stderr.String()) + } + } +} + +func TestCABundleBuildSecrets(t *testing.T) { + var logged bytes.Buffer + logger := log.New(log.Options{Level: log.LevelTrace, Format: "text", Writer: &logged}) + if got := caBundleBuildSecrets("", false, logger); got != nil || logged.Len() != 0 { + t.Errorf("no bundle: %v, logged %q; want nothing", got, logged.String()) + } + if got := caBundleBuildSecrets("/b.crt", true, logger); !maps.Equal(got, map[string]string{"devcontainer-ca-bundle": "/b.crt"}) { + t.Errorf("buildx: %v", got) + } + if got := caBundleBuildSecrets("/b.crt", false, logger); got != nil || !strings.Contains(logged.String(), "--ca-bundle requires BuildKit") { + t.Errorf("legacy builder: %v, logged %q; want the bundle dropped with a warning", got, logged.String()) + } +} + +func TestWithCABundleEnv(t *testing.T) { + cfg := map[string]string{"SSL_CERT_FILE": "/etc/mine.pem", "FOO": "bar"} + got := withCABundleEnv(cfg, "/b.crt") + want := map[string]string{"FOO": "bar"} + for _, kv := range imagemeta.CABundleEnv() { + k, v, _ := strings.Cut(kv, "=") + want[k] = v + } + if !maps.Equal(got, want) { + t.Errorf("withCABundleEnv = %v, want %v", got, want) + } + if cfg["SSL_CERT_FILE"] != "/etc/mine.pem" || len(cfg) != 2 { + t.Errorf("the configuration's env changed: %v", cfg) + } + if got := withCABundleEnv(nil, "/b.crt"); len(got) != 5 { + t.Errorf("withCABundleEnv(nil) = %v, want the five variables", got) + } + if got := withCABundleEnv(cfg, ""); !maps.Equal(got, cfg) { + t.Errorf("no bundle: %v, want %v", got, cfg) + } +} + +func TestCABundleEnvNamesEachTLSClient(t *testing.T) { + want := []string{ + "SSL_CERT_FILE=/run/devcontainer/ca-bundle.crt", + "NODE_EXTRA_CA_CERTS=/run/devcontainer/ca-bundle.crt", + "REQUESTS_CA_BUNDLE=/run/devcontainer/ca-bundle.crt", + "CURL_CA_BUNDLE=/run/devcontainer/ca-bundle.crt", + "GIT_SSL_CAINFO=/run/devcontainer/ca-bundle.crt", + } + if got := imagemeta.CABundleEnv(); !slices.Equal(got, want) { + t.Errorf("CABundleEnv = %v, want %v", got, want) + } + if got := caBundleRemoteEnv("/b.crt"); !slices.Equal(got, want) { + t.Errorf("caBundleRemoteEnv = %v, want %v", got, want) + } + if got := caBundleRemoteEnv(""); got != nil { + t.Errorf(`caBundleRemoteEnv("") = %v, want none`, got) + } +} + +func TestCABundleMountIsReadOnly(t *testing.T) { + want := dockermount.Mount{Type: dockermount.TypeBind, Source: "/b.crt", Target: "/run/devcontainer/ca-bundle.crt", ReadOnly: true} + if got := caBundleMount("/b.crt"); got != want { + t.Errorf("caBundleMount = %+v, want %+v", got, want) + } +} + +func TestContainerHasCABundle(t *testing.T) { + if containerHasCABundle([]container.MountPoint{{Destination: "/workspaces/x"}}) { + t.Error("a container without the bundle's mount has it") + } + if !containerHasCABundle([]container.MountPoint{{Destination: "/workspaces/x"}, {Destination: "/run/devcontainer/ca-bundle.crt"}}) { + t.Error("a container with the bundle's mount lacks it") + } +} diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index 4573cc5..fd43e63 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -368,6 +368,9 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe svcBuild := parseComposeServiceBuild(s.composeConfig, cfg.Service) svc, _ := composeService(s.composeConfig, cfg.Service) svcUser, _ := svc["user"].(string) + if opts.caBundle != "" && !noBuild { + logger.Write(caBundleComposeBuildWarning, log.LevelWarning) + } baseName := composeAutoAddedStageLabel dockerfile := "" @@ -665,6 +668,13 @@ func (r *upRunner) writeComposeFeaturesOverride(ctx context.Context, s composeSt for _, m := range opts.mounts { mountEntries = append(mountEntries, m) } + if opts.caBundle != "" { + bundle := caBundleMount(opts.caBundle) + mountEntries = append(mountEntries, map[string]any{"type": string(bundle.Type), "source": bundle.Source, "target": bundle.Target, "readonly": bundle.ReadOnly}) + withBundle := *merged + withBundle.ContainerEnv = withCABundleEnv(merged.ContainerEnv, opts.caBundle) + merged = &withBundle + } mounts, err := composeMountsFromMetadata(mountEntries, resolve) if err != nil { return "", fmt.Errorf("resolve compose mounts: %w", err) diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go index 5bed224..dd826b6 100644 --- a/internal/cli/compose_up_held_test.go +++ b/internal/cli/compose_up_held_test.go @@ -487,6 +487,29 @@ func TestWriteComposeFeaturesOverride(t *testing.T) { t.Errorf("image details read %d times, want 1", calls) } }) + t.Run("ca bundle", func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.caBundle = "/host/bundle.crt" + svc := map[string]any{"image": "alpine:3", "command": []any{"sleep", "infinity"}} + s := h.start(&config.DevContainer{}, svc) + merged := &imagemeta.MergedConfig{OverrideCommand: boolp(true), ContainerEnv: map[string]string{"SSL_CERT_FILE": "/mine.pem", "FOO": "bar"}} + path, err := h.r.writeComposeFeaturesOverride(ctx, s, "alpine:3", "alpine:3", merged, "", unexpected, svc, nil, h.persisted) + if err != nil { + t.Fatal(err) + } + content := readFile(t, path) + for _, want := range []string{ + "\n - 'CURL_CA_BUNDLE=/run/devcontainer/ca-bundle.crt'\n - 'FOO=bar'\n - 'GIT_SSL_CAINFO=/run/devcontainer/ca-bundle.crt'\n - 'NODE_EXTRA_CA_CERTS=/run/devcontainer/ca-bundle.crt'\n - 'REQUESTS_CA_BUNDLE=/run/devcontainer/ca-bundle.crt'\n - 'SSL_CERT_FILE=/run/devcontainer/ca-bundle.crt'", + "\n volumes:\n - /host/bundle.crt:/run/devcontainer/ca-bundle.crt:ro\n", + } { + if !strings.Contains(content, want) { + t.Errorf("override lacks %q:\n%s", want, content) + } + } + if merged.ContainerEnv["SSL_CERT_FILE"] != "/mine.pem" { + t.Errorf("the caller's merged configuration changed: %v", merged.ContainerEnv) + } + }) tests := []struct { name string svc map[string]any @@ -846,6 +869,19 @@ func TestBuildAndExtendComposeImageService(t *testing.T) { t.Errorf("err = %v, ran %q", err, h.run.calls) } }) + t.Run("ca bundle", func(t *testing.T) { + for _, noBuild := range []bool{false, true} { + h := newComposeHarness(t) + h.r.opts.caBundle = "/host/bundle.crt" + s := h.start(&config.DevContainer{}, map[string]any{"image": "alpine:3"}) + if _, err := h.r.buildAndExtendCompose(ctx, s, h.persisted, "", noBuild); err != nil { + t.Fatal(err) + } + if got := strings.Contains(h.logs.String(), caBundleComposeBuildWarning); got == noBuild { + t.Errorf("noBuild=%t: warned %t:\n%s", noBuild, got, h.logs.String()) + } + } + }) } // An image service with Features is built by compose from an override that diff --git a/internal/cli/e2e_ca_bundle_test.go b/internal/cli/e2e_ca_bundle_test.go new file mode 100644 index 0000000..17936cf --- /dev/null +++ b/internal/cli/e2e_ca_bundle_test.go @@ -0,0 +1,319 @@ +package cli + +import ( + "archive/tar" + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +const e2eBaseImage = "mcr.microsoft.com/devcontainers/base:ubuntu" + +// startTLSServer runs `openssl s_server -www` on the default bridge, where +// builds' RUNs and dev containers reach it by its address, with a certificate +// for that address signed by ca. The host's firewall may drop a container's +// traffic to the host, so the server is a container too. +func startTLSServer(t *testing.T, ca *testCA) (id, url string) { + t.Helper() + certs := t.TempDir() + if err := os.Chmod(certs, 0o755); err != nil { + t.Fatal(err) + } + out, err := exec.Command("docker", "run", "-d", "--rm", "-v", certs+":/certs:ro", e2eBaseImage, "sh", "-c", + "while [ ! -f /certs/ready ]; do sleep 0.1; done; exec openssl s_server -quiet -www -accept 8443 -cert /certs/cert.pem -key /certs/key.pem").CombinedOutput() + if err != nil { + t.Fatalf("start TLS server: %v\n%s", err, out) + } + id = strings.TrimSpace(string(out)) + t.Cleanup(func() { removeByID(id) }) + ipOut, err := exec.Command("docker", "inspect", "-f", "{{.NetworkSettings.Networks.bridge.IPAddress}}", id).Output() + if err != nil { + t.Fatalf("server address: %v", err) + } + ip := net.ParseIP(strings.TrimSpace(string(ipOut))) + if ip == nil { + t.Fatalf("server address %q", ipOut) + } + certPEM, keyPEM := ca.serverPEM(t, ip) + writeTestFile(t, filepath.Join(certs, "cert.pem"), certPEM) + writeTestFile(t, filepath.Join(certs, "key.pem"), keyPEM) + writeTestFile(t, filepath.Join(certs, "ready"), nil) + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + addr := net.JoinHostPort(ip.String(), "8443") + deadline := time.Now().Add(30 * time.Second) + for { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", addr, &tls.Config{RootCAs: pool}) + if err == nil { + conn.Close() + return id, "https://" + addr + "/" + } + if time.Now().After(deadline) { + t.Fatalf("TLS server at %s never answered: %v", addr, err) + } + time.Sleep(200 * time.Millisecond) + } +} + +// imageLeaks lists the files of image's own layers among files, and where the +// markers appear in what image adds to base: the content of its own layers' +// files and its config, which holds +// the history and labels. A layer of base is skipped by its diff ID. +func imageLeaks(t *testing.T, image, base string, files []string, markers ...string) []string { + t.Helper() + baseLayers := map[string]bool{} + out, err := exec.Command("docker", "image", "inspect", "-f", "{{json .RootFS.Layers}}", base).Output() + if err != nil { + t.Fatalf("inspect %s: %v", base, err) + } + var diffIDs []string + if err := json.Unmarshal(out, &diffIDs); err != nil { + t.Fatal(err) + } + for _, d := range diffIDs { + baseLayers[strings.TrimPrefix(d, "sha256:")] = true + } + + save := exec.Command("docker", "save", image) + stdout, err := save.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := save.Start(); err != nil { + t.Fatal(err) + } + var leaks []string + check := func(where string, data []byte) { + for _, m := range markers { + if bytes.Contains(data, []byte(m)) { + leaks = append(leaks, fmt.Sprintf("%s holds %q", where, m)) + } + } + } + saved := tar.NewReader(stdout) + for { + hdr, err := saved.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("read docker save: %v", err) + } + if hdr.Typeflag != tar.TypeReg || baseLayers[filepath.Base(hdr.Name)] { + continue + } + data, err := io.ReadAll(saved) + if err != nil { + t.Fatal(err) + } + layer := tar.NewReader(bytes.NewReader(data)) + if _, err := layer.Next(); err != nil { + check(hdr.Name, data) // config, manifest or index + continue + } + layer = tar.NewReader(bytes.NewReader(data)) + for { + f, err := layer.Next() + if err != nil { + break + } + if slices.Contains(files, strings.TrimPrefix(f.Name, "./")) { + leaks = append(leaks, fmt.Sprintf("%s has the file %s", hdr.Name, f.Name)) + } + content, _ := io.ReadAll(layer) + check(hdr.Name+":"+f.Name, content) + } + } + if err := save.Wait(); err != nil { + t.Fatalf("docker save %s: %v", image, err) + } + return leaks +} + +func uniqueImageName(t *testing.T, prefix string) string { + t.Helper() + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + name := prefix + "-" + hex.EncodeToString(b) + t.Cleanup(func() { exec.Command("docker", "rmi", "-f", name).Run() }) + return name +} + +// TestE2E_CABundle: with --ca-bundle a Feature's install and a Dockerfile RUN +// that mounts the secret reach an HTTPS server signed by a CA the image does +// not trust, and fail without it; the image carries neither the bundle nor its +// content; the container trusts the server in curl, node and its lifecycle +// commands; run-user-commands refuses a container created without it. +func TestE2E_CABundle(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker not available") + } + ca := newTestCA(t) + bundle := writeTestFile(t, filepath.Join(t.TempDir(), "bundle.crt"), ca.pem) + // A line of the CA's base64 body: in a file, a layer or a config only if + // the bundle was copied there. + marker := strings.Split(string(ca.pem), "\n")[1] + serverID, url := startTLSServer(t, ca) + // The server answers any path; a path of this run's own keeps BuildKit from + // reusing a RUN an earlier run built, which would pass without the bundle. + nonce := make([]byte, 8) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + url += hex.EncodeToString(nonce) + reach := "curl -fsS --max-time 10 -o /dev/null " + url + + featureWS := writeFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{ + "image": "` + e2eBaseImage + `", + "features": { "./reach": {} }, + "postCreateCommand": "` + reach + ` && touch /tmp/post-create-trusted" + }`, + ".devcontainer/reach/devcontainer-feature.json": `{"id": "reach", "version": "1.0.0", "name": "reach"}`, + ".devcontainer/reach/install.sh": "#!/bin/sh\nset -e\n" + reach + "\necho reached > /feature-reached\n", + }) + dockerfileWS := writeFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{"build": {"dockerfile": "Dockerfile"}}`, + ".devcontainer/Dockerfile": "FROM " + e2eBaseImage + "\n" + + "RUN --mount=type=secret,id=devcontainer-ca-bundle,target=/run/devcontainer/ca-bundle.crt " + + "curl -fsS --max-time 10 --cacert /run/devcontainer/ca-bundle.crt -o /dev/null " + url + " && echo reached > /dockerfile-reached\n", + }) + + for _, tc := range []struct{ name, ws string }{{"feature", featureWS}, {"dockerfile", dockerfileWS}} { + t.Run(tc.name, func(t *testing.T) { + image := uniqueImageName(t, "e2e-ca-bundle-"+tc.name) + assertOutcome(t, runCLI(t, "build", "--workspace-folder", tc.ws, "--image-name", image), "error") + assertOutcome(t, runCLI(t, "build", "--workspace-folder", tc.ws, "--image-name", image, "--ca-bundle", bundle), "success") + if leaks := imageLeaks(t, image, e2eBaseImage, []string{"run/devcontainer/ca-bundle.crt"}, marker); len(leaks) > 0 { + t.Errorf("the image carries the bundle:\n%s", strings.Join(leaks, "\n")) + } + if env, err := exec.Command("docker", "image", "inspect", "-f", "{{json .Config.Env}}", image).Output(); err != nil || strings.Contains(string(env), "ca-bundle") { + t.Errorf("the image's env names the bundle: %s %v", env, err) + } + // A bundle of another CA cannot reach the server, so this build + // passes only if the bundle's content is outside the cache key, and + // then it makes the same image. + // The image's ID names a manifest list whose attestation carries a + // timestamp; its layers are what the build made. + before := dockerOutput(t, "image", "inspect", "-f", "{{json .RootFS.Layers}}", image) + other := writeTestFile(t, filepath.Join(t.TempDir(), "other.crt"), newTestCA(t).pem) + assertOutcome(t, runCLI(t, "build", "--workspace-folder", tc.ws, "--image-name", image, "--ca-bundle", other), "success") + if after := dockerOutput(t, "image", "inspect", "-f", "{{json .RootFS.Layers}}", image); after != before { + t.Errorf("another bundle made layers %s, want %s", after, before) + } + }) + } + + t.Run("container", func(t *testing.T) { + upOut := runCLI(t, "up", "--workspace-folder", featureWS, "--ca-bundle", bundle) + assertOutcome(t, upOut, "success") + id, _ := parseJSON(t, upOut)["containerId"].(string) + t.Cleanup(func() { removeByID(id) }) + dockerExec(t, id, "test -f /feature-reached && test -f /tmp/post-create-trusted && "+reach) + if out, err := exec.Command("docker", "exec", id, "sh", "-c", "env -u CURL_CA_BUNDLE -u SSL_CERT_FILE "+reach).CombinedOutput(); err == nil { + t.Errorf("curl trusted the server without the bundle's variables:\n%s", out) + } + if out, err := exec.Command("docker", "exec", id, "sh", "-c", "echo x > /run/devcontainer/ca-bundle.crt").CombinedOutput(); err == nil { + t.Errorf("the bundle is writable in the container:\n%s", out) + } + assertOutcome(t, runCLI(t, "run-user-commands", "--container-id", id, "--ca-bundle", bundle), "success") + refused := runCLI(t, "run-user-commands", "--container-id", serverID, "--ca-bundle", bundle) + assertOutcome(t, refused, "error") + if !strings.Contains(refused, "has no CA bundle at /run/devcontainer/ca-bundle.crt") { + t.Errorf("run-user-commands on a container without the bundle: %s", refused) + } + }) + + t.Run("node", func(t *testing.T) { + ws := writeFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{"image": "mirror.gcr.io/library/node:22-bookworm-slim"}`, + }) + upOut := runCLI(t, "up", "--workspace-folder", ws, "--ca-bundle", bundle) + assertOutcome(t, upOut, "success") + id, _ := parseJSON(t, upOut)["containerId"].(string) + t.Cleanup(func() { removeByID(id) }) + get := `node -e 'require("https").get("` + url + `", r => process.exit(r.statusCode === 200 ? 0 : 1)).on("error", e => { console.error(e.message); process.exit(1) })'` + dockerExec(t, id, get) + if out, err := exec.Command("docker", "exec", id, "sh", "-c", "env -u NODE_EXTRA_CA_CERTS "+get).CombinedOutput(); err == nil { + t.Errorf("node trusted the server without NODE_EXTRA_CA_CERTS:\n%s", out) + } + }) +} + +// TestE2E_UpSecretsFile: up --secrets-file hands a value to a Dockerfile's +// `RUN --mount=type=secret,id=K` and to postCreateCommand, and the value is in +// no layer, no image config (which holds `docker history`) and no part of the +// container's inspect, its labels included. Both sides record a hash, so the +// test compares without writing the value anywhere itself. +func TestE2E_UpSecretsFile(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker not available") + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + value := "e2e-secret-" + hex.EncodeToString(b) + sum := sha256.Sum256([]byte(value)) + wantSum := hex.EncodeToString(sum[:]) + secrets := writeTestFile(t, filepath.Join(t.TempDir(), "secrets.json"), []byte(`{"E2E_SECRET": "`+value+`"}`)) + + ws := writeFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{ + "build": {"dockerfile": "Dockerfile"}, + "postCreateCommand": "printf %s \"$E2E_SECRET\" | sha256sum | cut -d' ' -f1 > /tmp/post-create.sum" + }`, + ".devcontainer/Dockerfile": "FROM " + e2eBaseImage + "\n" + + // The run's own hash in the instruction: a secret is outside the + // cache key, so an earlier run's RUN would otherwise be reused. + "RUN --mount=type=secret,id=E2E_SECRET sha256sum /run/secrets/E2E_SECRET | cut -d' ' -f1 > /build.sum && echo " + wantSum[:16] + " > /build.run\n", + }) + upOut := runCLI(t, "up", "--workspace-folder", ws, "--secrets-file", secrets) + assertOutcome(t, upOut, "success") + id, _ := parseJSON(t, upOut)["containerId"].(string) + t.Cleanup(func() { removeByID(id) }) + + if got := dockerExec(t, id, "cat /build.sum"); got != wantSum { + t.Errorf("the Dockerfile RUN read %q, want the secret's hash %q", got, wantSum) + } + if got := dockerExec(t, id, "cat /tmp/post-create.sum"); got != wantSum { + t.Errorf("postCreateCommand read %q, want the secret's hash %q", got, wantSum) + } + image := strings.TrimSpace(dockerOutput(t, "inspect", "-f", "{{.Config.Image}}", id)) + if leaks := imageLeaks(t, image, e2eBaseImage, nil, value); len(leaks) > 0 { + t.Errorf("the image carries the secret:\n%s", strings.Join(leaks, "\n")) + } + if strings.Contains(dockerOutput(t, "history", "--no-trunc", image), value) { + t.Error("docker history shows the secret") + } + if strings.Contains(dockerOutput(t, "container", "inspect", id), value) { + t.Error("the container's inspect, labels included, shows the secret") + } +} + +func dockerOutput(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("docker", args...).Output() + if err != nil { + t.Fatalf("docker %s: %v", strings.Join(args, " "), err) + } + return string(out) +} diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index d0b814f..afc6134 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -53,6 +53,7 @@ type FeatureBuildOptions struct { // Feature's install.sh can use `RUN --mount=type=secret` (#1078). Requires // buildx; ignored by the legacy builder. Secrets []string + CABundle string ContainerEnv map[string]string SkipFeatureAutoMapping bool SkipPersistCustoms bool @@ -776,6 +777,7 @@ func extendImageWithFeatures( if useBuildx { secretIDs = buildSecretIDs(fbOpts.Secrets) } + caSecrets := caBundleBuildSecrets(fbOpts.CABundle, useBuildx, logger) if err := stageFeatureBuildFiles(tmpDir, featureSets, containerUser, remoteUser); err != nil { return nil, err } @@ -797,6 +799,7 @@ func extendImageWithFeatures( Syntax: syntax, ConfigContainerEnv: fbOpts.ContainerEnv, SecretIDs: secretIDs, + CABundle: caSecrets != nil, }) if !useBuildx { if err := buildFeatureContentImage(ctx, dockerClient, tmpDir); err != nil { @@ -841,6 +844,7 @@ func extendImageWithFeatures( Push: fbOpts.Push, Output: fbOpts.Output, Secrets: fbOpts.Secrets, + FileSecrets: caSecrets, } // Add BuildKit contexts — "." is the features folder diff --git a/internal/cli/run_user_commands.go b/internal/cli/run_user_commands.go index 461b6a2..59aa50e 100644 --- a/internal/cli/run_user_commands.go +++ b/internal/cli/run_user_commands.go @@ -35,6 +35,7 @@ type runUserCommandsOpts struct { defaultUserEnvProbe string containerSessionDataFolder string secretsFile string + caBundle string terminalColumns givenInt terminalRows givenInt } @@ -78,6 +79,7 @@ func newRunUserCommandsCmd() *cobra.Command { _ = f.MarkHidden("skip-feature-auto-mapping") // hidden testing flag (TS parity) f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "") f.StringVar(&opts.secretsFile, "secrets-file", "", "") + f.StringVar(&opts.caBundle, "ca-bundle", "", "Set the CA bundle's variables for the lifecycle commands; the container must have been created with `up --ca-bundle`.") f.String("user-data-folder", "", "") f.Var(&opts.terminalColumns, "terminal-columns", "") f.Var(&opts.terminalRows, "terminal-rows", "") @@ -130,6 +132,10 @@ func prepareRunUserCommandsOpts(opts *runUserCommandsOpts) error { if err := validateRemoteEnvs(opts.remoteEnvs); err != nil { return err } + var err error + if opts.caBundle, err = resolveCABundle(opts.caBundle); err != nil { + return err + } if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" { opts.workspaceFolder, _ = os.Getwd() } @@ -152,7 +158,8 @@ func prepareRunUserCommandsOpts(opts *runUserCommandsOpts) error { // run-user-commands.workspace-secrets-success, // run-user-commands.workspace-subfolder-config-success, // run-user-commands.container-metadata-success, -// run-user-commands.hook-failure-error). +// run-user-commands.hook-failure-error); TestE2E_CABundle runs it with +// --ca-bundle on a container with the bundle and on one without. func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCommandsOpts, logger log.Logger, loadResult *config.LoadResult) error { engine, err := docker.NewEngineClient(logger) if err != nil { @@ -191,6 +198,9 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo if err != nil { return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) } + if opts.caBundle != "" && !containerHasCABundle(inspect.Mounts) { + return writeErrorResult(out, fmt.Sprintf("--ca-bundle: container %s has no CA bundle at %s; create it with `up --ca-bundle`.", containerID, imagemeta.CABundlePath)) + } // The merged configuration is the container's metadata (TS // getImageMetadataFromContainer), which records the configuration's entry // for a container the CLI created. @@ -229,7 +239,7 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo remoteUser := lifecycleRemoteUser(merged, cfg) probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, remoteUser, lifecycleUserEnvProbe(opts.defaultUserEnvProbe, cfg), opts.containerSessionDataFolder) - remoteEnv := lifecycleRemoteEnv(probed, buildSecretsFromFile(opts.secretsFile), opts.remoteEnvs, cfg) + remoteEnv := lifecycleRemoteEnv(probed, append(caBundleRemoteEnv(opts.caBundle), buildSecretsFromFile(opts.secretsFile)...), opts.remoteEnvs, cfg) shellServer := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, remoteEnv...) defer shellServer.Close() diff --git a/internal/cli/up.go b/internal/cli/up.go index 5b3543b..0cdb0d6 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -64,6 +64,7 @@ type upOpts struct { terminalColumns givenInt terminalRows givenInt secretsFile string + caBundle string experimentalLockfile bool experimentalFrozenLockfile bool noLockfile bool @@ -158,6 +159,7 @@ func newUpCmd() *cobra.Command { f.StringVar(&opts.dotfilesTarget, "dotfiles-target-path", "~/dotfiles", "Dotfiles target.") f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "Session data folder.") f.StringVar(&opts.secretsFile, "secrets-file", "", "Secrets file path.") + f.StringVar(&opts.caBundle, "ca-bundle", "", caBundleFlagUsage) f.BoolVar(&opts.experimentalLockfile, "experimental-lockfile", false, "") f.BoolVar(&opts.experimentalFrozenLockfile, "experimental-frozen-lockfile", false, "") f.BoolVar(&opts.noLockfile, "no-lockfile", false, "Disable lockfile generation and verification.") @@ -244,12 +246,16 @@ func upHasNothingToRead(workspaceFolder, configPath, overridePath string) bool { // up.id-label-only-existing-container-error, up.config-without-workspace-success, // up.compose-dockerfile-with-features-reuse-stopped-container-with-persisted-overrides-success // and up.image-post-create-remote-env-success; also TestE2E_UpReusesStoppedContainer -// (restarts a stopped single container), TestE2E_BuildUpExec_Image and -// TestE2E_RemoveExistingBeforeInitialize. +// (restarts a stopped single container), TestE2E_BuildUpExec_Image, +// TestE2E_RemoveExistingBeforeInitialize and TestE2E_CABundle. func runUp(ctx context.Context, out Output, opts *upOpts) error { if err := validateUpOpts(opts); err != nil { return writeValidationError(out, err.Error()) } + var err error + if opts.caBundle, err = resolveCABundle(opts.caBundle); err != nil { + return writeValidationError(out, err.Error()) + } // --no-lockfile is mutually exclusive with every frozen/write lockfile flag // (matching the TS CLI up command's yargs check). if opts.noLockfile { @@ -639,7 +645,8 @@ func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cf // mutate-lane: TestParityMatrix - up.update-uid-remote-user-from-image-metadata-success, // up.dockerfile-without-features-omit-config-remote-env-from-metadata-success, // up.dockerfile-with-parallel-commands-success and up.update-uid-amd64-dockerfile-platform-success -// build through it; also TestE2E_BuildUpExec_Dockerfile. +// build through it; also TestE2E_BuildUpExec_Dockerfile and +// TestE2E_UpSecretsFile (a Dockerfile RUN reads a --secrets-file secret). func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, useBuildx bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts @@ -678,6 +685,8 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, defer authCleanup() buildResult, err := dockerClient.Build(ctx, docker.BuildOptions{ + Secrets: buildxSecrets(buildSecretsFromFile(opts.secretsFile), useBuildx, logger), + FileSecrets: caBundleBuildSecrets(opts.caBundle, useBuildx, logger), Dockerfile: dockerfilePath, ContextPath: contextPath, Tags: []string{imageName}, @@ -845,7 +854,8 @@ func findUserArg(runArgs []string) string { // mutate-lane: TestParityMatrix - up.image-success, up.app-port-success, // up.override-command-false-success, up.workspace-mount-custom-success, // up.image-with-mounts-success and up.config-without-workspace-success create -// their container through it; also TestE2E_ReadonlyMountObject. +// their container through it; also TestE2E_ReadonlyMountObject and +// TestE2E_CABundle (the bundle's mount and variables). func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, configInImage bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts // Inspect image once — used for metadata and containerUser resolution. @@ -881,7 +891,7 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf // baked as ENV during the build — see featureMetadataEntry), so this no // longer re-applies a literal ${PATH} and breaks PATH. var envVars []string - for k, v := range merged.ContainerEnv { + for k, v := range withCABundleEnv(merged.ContainerEnv, opts.caBundle) { envVars = append(envVars, fmt.Sprintf("%s=%s", k, v)) } @@ -902,6 +912,9 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf return "", err } mounts = append(mounts, flagMounts...) + if opts.caBundle != "" { + mounts = append(mounts, caBundleMount(opts.caBundle)) + } // GPU support: add --gpus all if config requests GPU and availability allows it. // gpu:false must NOT enable GPUs (a raw-length check treated "false" as truthy). @@ -1406,6 +1419,7 @@ func (r *upRunner) featureBuildOptions(cfg *config.DevContainer, loadResult *con return &FeatureBuildOptions{ Platform: platform, Secrets: buildSecretsFromFile(opts.secretsFile), + CABundle: opts.caBundle, OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), WorkspaceFolder: opts.workspaceFolder, diff --git a/internal/docker/client.go b/internal/docker/client.go index 4d60dbf..b1fc75b 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "io" + "maps" "regexp" + "slices" "strings" "github.com/devcontainers/cli/internal/exec" @@ -107,6 +109,7 @@ type BuildOptions struct { ExtraArgs []string // additional --build-context, etc. Env []string // extra env for the subprocess (e.g. DOCKER_CONFIG=...) Secrets []string // build secrets as "KEY=VALUE"; passed to buildx as --secret id=KEY,env=KEY with KEY=VALUE in the subprocess env + FileSecrets map[string]string // Buildx-specific UseBuildx bool @@ -156,6 +159,9 @@ func (c *Client) buildArgs(opts BuildOptions) []string { args = append(args, "--secret", "id="+key+",env="+key) } } + for _, id := range slices.Sorted(maps.Keys(opts.FileSecrets)) { + args = append(args, "--secret", "id="+id+",src="+opts.FileSecrets[id]) + } } else { args = append(args, "build") } diff --git a/internal/docker/inline_cache_test.go b/internal/docker/inline_cache_test.go index 2eb1c7e..05aa8d3 100644 --- a/internal/docker/inline_cache_test.go +++ b/internal/docker/inline_cache_test.go @@ -67,6 +67,19 @@ func TestBuildArgsSecrets(t *testing.T) { } } +func TestBuildArgsFileSecrets(t *testing.T) { + c := &Client{DockerPath: "docker", Log: log.Null} + files := map[string]string{"b-id": "/x/b.pem", "a-id": "/x/a.pem"} + s := strings.Join(c.buildArgs(BuildOptions{UseBuildx: true, ContextPath: ".", FileSecrets: files}), " ") + if want := "--secret id=a-id,src=/x/a.pem --secret id=b-id,src=/x/b.pem"; !strings.Contains(s, want) { + t.Fatalf("args = %s, want %q", s, want) + } + legacy := strings.Join(c.buildArgs(BuildOptions{ContextPath: ".", FileSecrets: files}), " ") + if strings.Contains(legacy, "--secret") { + t.Fatalf("legacy build must not emit --secret: %s", legacy) + } +} + // TestBuildSecretsRouteThroughEnv proves the secret values are handed to the // runner as environment, never as args (via the injected fake runner). func TestBuildSecretsRouteThroughEnv(t *testing.T) { diff --git a/internal/imagemeta/extend.go b/internal/imagemeta/extend.go index fa629e2..895647a 100644 --- a/internal/imagemeta/extend.go +++ b/internal/imagemeta/extend.go @@ -72,6 +72,21 @@ type FeaturesBuildParams struct { // /run/secrets/ (#1078). TS has no equivalent; empty when no build // secrets are in play. SecretIDs []string + CABundle bool +} + +const ( + CABundleSecretID = "devcontainer-ca-bundle" + CABundlePath = "/run/devcontainer/ca-bundle.crt" +) + +func CABundleEnv() []string { + names := []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"} + env := make([]string, len(names)) + for i, n := range names { + env[i] = n + "=" + CABundlePath + } + return env } // ComposeFeatureContentImage is the temporary image the TS CLI builds to hold the @@ -195,6 +210,11 @@ func featureLayers(p FeaturesBuildParams, contentSourceRootPath string) string { for _, id := range p.SecretIDs { secretMounts += " --mount=type=secret,id=" + id } + installCommandEnvPrefix := "" + if p.CABundle { + secretMounts += " --mount=type=secret,id=" + CABundleSecretID + ",target=" + CABundlePath + installCommandEnvPrefix = strings.Join(CABundleEnv(), " ") + " " + } for _, set := range p.FeatureSets { for _, feat := range set.Features { b.WriteString(generateContainerEnvs(feat.ContainerEnv, false)) @@ -205,12 +225,12 @@ func featureLayers(p FeaturesBuildParams, contentSourceRootPath string) string { if secretMounts != "" { runMounts = strings.TrimPrefix(secretMounts, " ") + " " } - fmt.Fprintf(&b, "\nCOPY --chown=root:root --from=dev_containers_feature_content_source %s %s\nRUN %schmod -R 0755 %s \\\n&& cd %s \\\n&& chmod +x ./%s \\\n&& ./%s\n\n", - source, dest, runMounts, dest, dest, FeatureInstallWrapperFile, FeatureInstallWrapperFile) + fmt.Fprintf(&b, "\nCOPY --chown=root:root --from=dev_containers_feature_content_source %s %s\nRUN %schmod -R 0755 %s \\\n&& cd %s \\\n&& chmod +x ./%s \\\n&& %s./%s\n\n", + source, dest, runMounts, dest, dest, FeatureInstallWrapperFile, installCommandEnvPrefix, FeatureInstallWrapperFile) continue } - fmt.Fprintf(&b, "\nRUN --mount=type=bind,from=dev_containers_feature_content_source,source=%s,target=/tmp/build-features-src/%s%s \\\n cp -ar /tmp/build-features-src/%s %s \\\n && chmod -R 0755 %s \\\n && cd %s \\\n && chmod +x ./%s \\\n && ./%s \\\n && rm -rf %s\n\n", - source, feat.ConsecutiveId, secretMounts, feat.ConsecutiveId, FeaturesContainerTempDestFolder, dest, dest, FeatureInstallWrapperFile, FeatureInstallWrapperFile, dest) + fmt.Fprintf(&b, "\nRUN --mount=type=bind,from=dev_containers_feature_content_source,source=%s,target=/tmp/build-features-src/%s%s \\\n cp -ar /tmp/build-features-src/%s %s \\\n && chmod -R 0755 %s \\\n && cd %s \\\n && chmod +x ./%s \\\n && %s./%s \\\n && rm -rf %s\n\n", + source, feat.ConsecutiveId, secretMounts, feat.ConsecutiveId, FeaturesContainerTempDestFolder, dest, dest, FeatureInstallWrapperFile, installCommandEnvPrefix, FeatureInstallWrapperFile, dest) } } return b.String() diff --git a/internal/imagemeta/extend_test.go b/internal/imagemeta/extend_test.go index 8e2e53e..8ad0a4f 100644 --- a/internal/imagemeta/extend_test.go +++ b/internal/imagemeta/extend_test.go @@ -205,6 +205,43 @@ func TestExtend_BuildSecretsMountedIntoInstall(t *testing.T) { } } +func TestExtend_CABundleMountedIntoInstall(t *testing.T) { + fss := []*features.Set{{ + SourceInfo: &features.OCISource{ID: "go"}, + Features: []features.Feature{{ID: "go", ConsecutiveId: "go_0", Version: "1.21", Value: true}}, + }} + const vars = "SSL_CERT_FILE=/run/devcontainer/ca-bundle.crt NODE_EXTRA_CA_CERTS=/run/devcontainer/ca-bundle.crt REQUESTS_CA_BUNDLE=/run/devcontainer/ca-bundle.crt CURL_CA_BUNDLE=/run/devcontainer/ca-bundle.crt GIT_SSL_CAINFO=/run/devcontainer/ca-bundle.crt " + + ctx := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true, SecretIDs: []string{"NPM_TOKEN"}, CABundle: true}).DockerfileContent + for _, want := range []string{ + "target=/tmp/build-features-src/go_0 --mount=type=secret,id=NPM_TOKEN --mount=type=secret,id=devcontainer-ca-bundle,target=/run/devcontainer/ca-bundle.crt \\\n", + " && " + vars + "./devcontainer-features-install.sh \\\n", + } { + if !strings.Contains(ctx, want) { + t.Errorf("BuildKit feature install RUN lacks %q:\n%s", want, ctx) + } + } + noCtx := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", CABundle: true}).DockerfileContent + for _, want := range []string{ + "RUN --mount=type=secret,id=devcontainer-ca-bundle,target=/run/devcontainer/ca-bundle.crt chmod -R 0755 /tmp/dev-container-features/go_0 \\\n", + "&& " + vars + "./devcontainer-features-install.sh\n", + } { + if !strings.Contains(noCtx, want) { + t.Errorf("content-image feature install RUN lacks %q:\n%s", want, noCtx) + } + } + for _, df := range []string{ctx, noCtx} { + if strings.Contains(df, "ENV SSL_CERT_FILE") { + t.Errorf("the bundle's variables became an ENV of the image:\n%s", df) + } + } + + without := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true}).DockerfileContent + if strings.Contains(without, "ca-bundle") || !strings.Contains(without, " && ./devcontainer-features-install.sh \\\n") { + t.Errorf("no bundle given, but the RUN changed:\n%s", without) + } +} + // TS getFeatureValueObject: option defaults in document order, overlaid with // the user's object; a string or boolean value sets "version" only when the // Feature declares that option. diff --git a/internal/oci/buildauth_test.go b/internal/oci/buildauth_test.go index f0a3cfc..822bb18 100644 --- a/internal/oci/buildauth_test.go +++ b/internal/oci/buildauth_test.go @@ -148,3 +148,53 @@ func TestResolveBuildAuthSkipsAnEmptyRegistry(t *testing.T) { t.Errorf("ResolveBuildAuth(\"\") = (%q, ok=%v, %v), want no auth config", dir, ok, err) } } + +// TestBuildAuthFromDockerConfigOffHome: a caller that serves the Docker config +// from tmpfs or FUSE sets DOCKER_CONFIG to a directory outside $HOME whose +// config.json is a symlink into another; the credential is read through the +// link, never from ~/.docker, and the bridged copy is written under TMPDIR and +// removed by cleanup. +func TestBuildAuthFromDockerConfigOffHome(t *testing.T) { + root := t.TempDir() + served := filepath.Join(root, "served") + dockerConfig := filepath.Join(root, "docker-config") + home := filepath.Join(root, "home") + tmp := filepath.Join(root, "tmpfs") + for _, d := range []string{served, dockerConfig, filepath.Join(home, ".docker"), tmp} { + if err := os.MkdirAll(d, 0o700); err != nil { + t.Fatal(err) + } + } + want := base64.StdEncoding.EncodeToString([]byte("served:token")) + if err := os.WriteFile(filepath.Join(served, "config.json"), []byte(`{"auths":{"reg.example.com":{"auth":"`+want+`"}}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(served, "config.json"), filepath.Join(dockerConfig, "config.json")); err != nil { + t.Fatal(err) + } + decoy := base64.StdEncoding.EncodeToString([]byte("home:wrong")) + if err := os.WriteFile(filepath.Join(home, ".docker", "config.json"), []byte(`{"auths":{"reg.example.com":{"auth":"`+decoy+`"}}}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("TMPDIR", tmp) + env := map[string]string{"DOCKER_CONFIG": dockerConfig, "HOME": home} + + if cred := getCredential(env, "reg.example.com", log.Null); cred == nil || cred.base64Encoded != want { + t.Fatalf("getCredential = %#v, want the credential behind the symlink", cred) + } + dir, cleanup, ok, err := ResolveBuildAuth(env, []string{"reg.example.com"}, log.Null) + if err != nil || !ok { + t.Fatalf("ResolveBuildAuth ok=%v err=%v", ok, err) + } + if filepath.Dir(dir) != tmp { + t.Errorf("bridged config in %s, want it under TMPDIR %s", dir, tmp) + } + if got := readConfigAuths(t, dir)["reg.example.com"].Auth; got != want { + t.Errorf("bridged auth = %q, want %q", got, want) + } + cleanup() + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("bridged config left behind: %v", err) + } +} From 9e909a242f2ad3e0de30a15e3beed91ef166fd38 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 16:52:05 -0300 Subject: [PATCH 2/3] test(cli): say why the CA bundle e2e TLS server has no address The server container ran with --rm and its address came from the bridge network by name, with docker inspect's stderr discarded: on CI the lookup failed with a bare "exit status 1". Keep the container until cleanup, read the address from whatever network it is on, and report the inspect output, the container's state and its logs. Co-Authored-By: Claude Opus 5.5 --- internal/cli/e2e_ca_bundle_test.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/cli/e2e_ca_bundle_test.go b/internal/cli/e2e_ca_bundle_test.go index 17936cf..d6bcac0 100644 --- a/internal/cli/e2e_ca_bundle_test.go +++ b/internal/cli/e2e_ca_bundle_test.go @@ -34,20 +34,24 @@ func startTLSServer(t *testing.T, ca *testCA) (id, url string) { if err := os.Chmod(certs, 0o755); err != nil { t.Fatal(err) } - out, err := exec.Command("docker", "run", "-d", "--rm", "-v", certs+":/certs:ro", e2eBaseImage, "sh", "-c", + out, err := exec.Command("docker", "run", "-d", "-v", certs+":/certs:ro", e2eBaseImage, "sh", "-c", "while [ ! -f /certs/ready ]; do sleep 0.1; done; exec openssl s_server -quiet -www -accept 8443 -cert /certs/cert.pem -key /certs/key.pem").CombinedOutput() if err != nil { t.Fatalf("start TLS server: %v\n%s", err, out) } id = strings.TrimSpace(string(out)) t.Cleanup(func() { removeByID(id) }) - ipOut, err := exec.Command("docker", "inspect", "-f", "{{.NetworkSettings.Networks.bridge.IPAddress}}", id).Output() + ipOut, err := exec.Command("docker", "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", id).CombinedOutput() if err != nil { - t.Fatalf("server address: %v", err) + t.Fatalf("server address: %v\n%s\n%s", err, ipOut, serverDiagnostics(id)) + } + fields := strings.Fields(string(ipOut)) + var ip net.IP + if len(fields) > 0 { + ip = net.ParseIP(fields[0]) } - ip := net.ParseIP(strings.TrimSpace(string(ipOut))) if ip == nil { - t.Fatalf("server address %q", ipOut) + t.Fatalf("server address %q\n%s", ipOut, serverDiagnostics(id)) } certPEM, keyPEM := ca.serverPEM(t, ip) writeTestFile(t, filepath.Join(certs, "cert.pem"), certPEM) @@ -65,12 +69,18 @@ func startTLSServer(t *testing.T, ca *testCA) (id, url string) { return id, "https://" + addr + "/" } if time.Now().After(deadline) { - t.Fatalf("TLS server at %s never answered: %v", addr, err) + t.Fatalf("TLS server at %s never answered: %v\n%s", addr, err, serverDiagnostics(id)) } time.Sleep(200 * time.Millisecond) } } +func serverDiagnostics(id string) string { + state, _ := exec.Command("docker", "inspect", "-f", "{{json .State}} {{json .NetworkSettings.Networks}}", id).CombinedOutput() + logs, _ := exec.Command("docker", "logs", id).CombinedOutput() + return fmt.Sprintf("state: %s\nlogs: %s", state, logs) +} + // imageLeaks lists the files of image's own layers among files, and where the // markers appear in what image adds to base: the content of its own layers' // files and its config, which holds From 06d7cd00a91bee335899b4870e764e65ef9ed7d0 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 16:57:01 -0300 Subject: [PATCH 3/3] test(cli): read the TLS server's container id from stdout alone docker run's stderr carries the image pull's progress when the base image is not cached, as on CI; combined with stdout it became part of the container id, and every later docker call on that id failed. Co-Authored-By: Claude Opus 5.5 --- internal/cli/e2e_ca_bundle_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/cli/e2e_ca_bundle_test.go b/internal/cli/e2e_ca_bundle_test.go index d6bcac0..8fc26af 100644 --- a/internal/cli/e2e_ca_bundle_test.go +++ b/internal/cli/e2e_ca_bundle_test.go @@ -34,10 +34,13 @@ func startTLSServer(t *testing.T, ca *testCA) (id, url string) { if err := os.Chmod(certs, 0o755); err != nil { t.Fatal(err) } - out, err := exec.Command("docker", "run", "-d", "-v", certs+":/certs:ro", e2eBaseImage, "sh", "-c", - "while [ ! -f /certs/ready ]; do sleep 0.1; done; exec openssl s_server -quiet -www -accept 8443 -cert /certs/cert.pem -key /certs/key.pem").CombinedOutput() + run := exec.Command("docker", "run", "-d", "-v", certs+":/certs:ro", e2eBaseImage, "sh", "-c", + "while [ ! -f /certs/ready ]; do sleep 0.1; done; exec openssl s_server -quiet -www -accept 8443 -cert /certs/cert.pem -key /certs/key.pem") + var runErr bytes.Buffer + run.Stderr = &runErr + out, err := run.Output() if err != nil { - t.Fatalf("start TLS server: %v\n%s", err, out) + t.Fatalf("start TLS server: %v\n%s%s", err, out, runErr.Bytes()) } id = strings.TrimSpace(string(out)) t.Cleanup(func() { removeByID(id) })