diff --git a/errors/formatter.go b/errors/formatter.go index 69f888a41e..b395c4be62 100644 --- a/errors/formatter.go +++ b/errors/formatter.go @@ -293,10 +293,20 @@ func addExampleAndHintsSection(md *strings.Builder, err error, maxLineLength int if len(examples) > 0 { md.WriteString(newline + newline + "## Example" + newline + newline) for _, example := range examples { + // Wrap examples in code fences to prevent markdown interpretation. + // Only add fences when they do not already exist. + // This keeps backward compatibility with WithExampleFile, which may include pre-fenced markdown content. + hasFences := strings.HasPrefix(strings.TrimSpace(example), "```") + if !hasFences { + md.WriteString("```yaml" + newline) + } md.WriteString(example) if !strings.HasSuffix(example, newline) { md.WriteString(newline) } + if !hasFences { + md.WriteString("```" + newline) + } } md.WriteString(newline) } diff --git a/examples/devcontainer-build/Dockerfile b/examples/devcontainer-build/Dockerfile index 5ffe2c4691..f0dcf2d621 100644 --- a/examples/devcontainer-build/Dockerfile +++ b/examples/devcontainer-build/Dockerfile @@ -4,21 +4,9 @@ ARG GEODESIC_VERSION=latest FROM cloudposse/geodesic:${GEODESIC_VERSION} -# Install Atmos -ARG ATMOS_VERSION=latest -ARG TARGETARCH -ARG TARGETOS=linux - -# Download and install Atmos binary -RUN set -eux; \ - ATMOS_INSTALL_PATH=/usr/local/bin/atmos; \ - if [ "${ATMOS_VERSION}" = "latest" ]; then \ - DOWNLOAD_URL="https://github.com/cloudposse/atmos/releases/latest/download/atmos_${TARGETOS}_${TARGETARCH}"; \ - else \ - DOWNLOAD_URL="https://github.com/cloudposse/atmos/releases/download/${ATMOS_VERSION}/atmos_${TARGETOS}_${TARGETARCH}"; \ - fi; \ - curl -fsSL "${DOWNLOAD_URL}" -o "${ATMOS_INSTALL_PATH}"; \ - chmod +x "${ATMOS_INSTALL_PATH}"; \ +# Install Atmos using the official install script +# This handles version detection and platform detection automatically +RUN curl -fsSL https://atmos.tools/install.sh | bash && \ atmos version # Set working directory diff --git a/examples/devcontainer-build/README.md b/examples/devcontainer-build/README.md index 764d0d9b9c..3b5a034ace 100644 --- a/examples/devcontainer-build/README.md +++ b/examples/devcontainer-build/README.md @@ -4,6 +4,7 @@ This example demonstrates using a custom Dockerfile with Atmos devcontainers. Th ## Files +- **`atmos.yaml`** - Configuration showing how to use devcontainers with Dockerfile builds - **`devcontainer.json`** - Devcontainer configuration using `build` instead of `image` - **`Dockerfile`** - Custom Dockerfile extending Geodesic with Atmos pre-installed @@ -17,8 +18,7 @@ The `devcontainer.json` uses the `build` section to specify how to build the con "dockerfile": "Dockerfile", "context": ".", "args": { - "GEODESIC_VERSION": "latest", - "ATMOS_VERSION": "latest" + "GEODESIC_VERSION": "latest" } } } @@ -29,48 +29,62 @@ The `devcontainer.json` uses the `build` section to specify how to build the con The Dockerfile accepts build arguments for customization: - **`GEODESIC_VERSION`** - Version of Geodesic to use (default: `latest`) -- **`ATMOS_VERSION`** - Version of Atmos to install (default: `latest`) ## Usage +### Quick Start + +```bash +# From this directory, build and launch the devcontainer +cd examples/devcontainer-build +atmos devcontainer shell geodesic + +# Force rebuild (when Dockerfile changes) +atmos devcontainer shell geodesic --replace +``` + ### Using with atmos.yaml -Add this devcontainer to your `atmos.yaml`: +The included `atmos.yaml` shows two approaches: +**Option 1: Include devcontainer.json file** ```yaml -devcontainers: - geodesic-atmos: - # Reference the devcontainer.json file - configFile: examples/devcontainer-build/devcontainer.json +devcontainer: + geodesic: + spec: + - !include devcontainer.json ``` -Or define it inline: - +**Option 2: Define build configuration inline** ```yaml -devcontainers: - geodesic-atmos: - name: "Geodesic with Atmos" - build: - dockerfile: examples/devcontainer-build/Dockerfile - context: examples/devcontainer-build - args: - GEODESIC_VERSION: "latest" - ATMOS_VERSION: "1.100.0" # Pin to specific version - workspaceFolder: /workspace - workspaceMount: type=bind,source=${localWorkspaceFolder},target=/workspace +devcontainer: + geodesic-inline: + spec: + name: "Geodesic with Atmos" + build: + dockerfile: "Dockerfile" + context: "." + args: + GEODESIC_VERSION: "latest" + ATMOS_VERSION: "latest" + workspaceFolder: "/workspace" + workspaceMount: "type=bind,source=${localWorkspaceFolder},target=/workspace" + containerEnv: + ATMOS_BASE_PATH: "/workspace" + remoteUser: "root" ``` ### Launch the devcontainer ```bash # Build and launch -atmos devcontainer shell geodesic-atmos +atmos devcontainer shell geodesic # Force rebuild (when Dockerfile changes) -atmos devcontainer rebuild geodesic-atmos +atmos devcontainer shell geodesic --replace -# Use specific Atmos version -atmos devcontainer shell geodesic-atmos --build-arg ATMOS_VERSION=1.95.0 +# List available devcontainers +atmos devcontainer list ``` ## What's Included @@ -115,7 +129,7 @@ When you run `atmos devcontainer shell`, Atmos will: 1. **Build the image** (if not already built or if changed) - Uses `docker build` or `podman build` - Passes build args from `devcontainer.json` - - Tags the image as `atmos-devcontainer-geodesic-atmos` + - Tags the image as `atmos-devcontainer-geodesic` 2. **Create the container** from the built image @@ -127,10 +141,10 @@ To rebuild the image after changing the Dockerfile: ```bash # Rebuild the devcontainer -atmos devcontainer rebuild geodesic-atmos +atmos devcontainer rebuild geodesic # Or use --replace flag with shell command -atmos devcontainer shell geodesic-atmos --replace +atmos devcontainer shell geodesic --replace ``` ## Benefits of Custom Dockerfiles diff --git a/examples/devcontainer-build/atmos.yaml b/examples/devcontainer-build/atmos.yaml new file mode 100644 index 0000000000..bbc040a9bf --- /dev/null +++ b/examples/devcontainer-build/atmos.yaml @@ -0,0 +1,63 @@ +# Example atmos.yaml configuration with devcontainer using custom Dockerfile +# +# This example demonstrates building a container from a Dockerfile instead of +# using a pre-built image. This is useful when you need: +# - Custom tools pre-installed +# - Specific tool versions +# - Custom shell configuration +# - Project-specific setup +# +# Quick start: +# atmos devcontainer shell geodesic # Build & launch shell in custom container +# atmos devcontainer shell geodesic --replace # Force rebuild +# atmos devcontainer list # List available devcontainers + +# Base path for components and stacks +base_path: "." + +# Command aliases +aliases: + shell: "devcontainer shell geodesic" + +components: + terraform: + base_path: "components/terraform" + +# Devcontainer configurations with Dockerfile build +devcontainer: + # Geodesic devcontainer using local Dockerfile + geodesic: + settings: + runtime: podman # Optional: docker, podman, or omit for auto-detect + spec: + # Include devcontainer.json which has the build configuration + - !include devcontainer.json + + # Alternative: Define build configuration inline + geodesic-inline: + spec: + name: "Geodesic with Atmos (Inline)" + # Using "build" instead of "image" tells Atmos to build from Dockerfile + build: + dockerfile: "Dockerfile" + context: "." + args: + GEODESIC_VERSION: "latest" + workspaceFolder: "/workspace" + workspaceMount: "type=bind,source=${localWorkspaceFolder},target=/workspace" + forwardPorts: + - 8080 + - 3000 + containerEnv: + ATMOS_BASE_PATH: "/workspace" + TERM: "${localEnv:TERM}" + remoteUser: "root" + runArgs: + - "--hostname=geodesic-atmos" + +stacks: + base_path: "stacks" + included_paths: + - "**/*" + excluded_paths: + - "**/_defaults.yaml" diff --git a/examples/devcontainer-build/devcontainer.json b/examples/devcontainer-build/devcontainer.json index bb0da31514..24540e1bef 100644 --- a/examples/devcontainer-build/devcontainer.json +++ b/examples/devcontainer-build/devcontainer.json @@ -4,8 +4,7 @@ "dockerfile": "Dockerfile", "context": ".", "args": { - "GEODESIC_VERSION": "latest", - "ATMOS_VERSION": "latest" + "GEODESIC_VERSION": "latest" } }, "workspaceFolder": "/workspace", diff --git a/pkg/devcontainer/lifecycle_rebuild.go b/pkg/devcontainer/lifecycle_rebuild.go index d7098ad32a..91c15053d7 100644 --- a/pkg/devcontainer/lifecycle_rebuild.go +++ b/pkg/devcontainer/lifecycle_rebuild.go @@ -68,11 +68,22 @@ func rebuildContainer(p *rebuildParams) error { return err } - // Pull latest image unless --no-pull is set. - if err := pullImageIfNeeded(p.ctx, p.runtime, p.config.Image, p.noPull); err != nil { + // Build image if build configuration is specified. + // This must happen before createContainer since it sets config.Image. + // Track whether we built the image to skip pulling in that case. + builtLocally := p.config.Build != nil + if err := buildImageIfNeeded(p.ctx, p.runtime, p.config, p.name); err != nil { return err } + // Pull latest image unless --no-pull is set or image was built locally. + // Locally built images don't exist in remote registries, so pulling would fail. + if !builtLocally { + if err := pullImageIfNeeded(p.ctx, p.runtime, p.config.Image, p.noPull); err != nil { + return err + } + } + // Create and start new container. params := &containerParams{ ctx: p.ctx, diff --git a/pkg/devcontainer/lifecycle_rebuild_test.go b/pkg/devcontainer/lifecycle_rebuild_test.go index 260af36e07..3222b43ed6 100644 --- a/pkg/devcontainer/lifecycle_rebuild_test.go +++ b/pkg/devcontainer/lifecycle_rebuild_test.go @@ -395,6 +395,99 @@ func TestManager_Rebuild(t *testing.T) { }, expectError: false, }, + { + name: "rebuild with dockerfile build configuration", + devName: "geodesic", + instance: "default", + noPull: false, + setupMocks: func(loader *MockConfigLoader, identity *MockIdentityManager, detector *MockRuntimeDetector, runtime *MockRuntime) { + // Config has Build instead of Image (like user's geodesic example). + config := &Config{ + Name: "geodesic", + Image: "", // Empty - will be set by buildImageIfNeeded. + Build: &Build{ + Context: ".", + Dockerfile: "Dockerfile", + Args: map[string]string{ + "ATMOS_VERSION": "1.201.0", + }, + }, + } + loader.EXPECT(). + LoadConfig(gomock.Any(), "geodesic"). + Return(config, &Settings{}, nil) + detector.EXPECT(). + DetectRuntime(""). + Return(runtime, nil) + // Container doesn't exist. + runtime.EXPECT(). + Inspect(gomock.Any(), "atmos-devcontainer.geodesic.default"). + Return(nil, errors.New("not found")) + // Build should be called since config.Build is set. + runtime.EXPECT(). + Build(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, buildConfig *container.BuildConfig) error { + // Verify build config has correct values. + assert.Equal(t, ".", buildConfig.Context) + assert.Equal(t, "Dockerfile", buildConfig.Dockerfile) + assert.Equal(t, []string{"atmos-devcontainer-geodesic"}, buildConfig.Tags) + assert.Equal(t, map[string]string{"ATMOS_VERSION": "1.201.0"}, buildConfig.Args) + return nil + }) + // Pull is NOT called for locally built images since they don't exist + // in remote registries and pulling would fail. + // Create new container. + runtime.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, createConfig *container.CreateConfig) (string, error) { + // Verify the image is set to the built image name. + assert.Equal(t, "atmos-devcontainer-geodesic", createConfig.Image) + return "new-id", nil + }) + // Start new container. + runtime.EXPECT(). + Start(gomock.Any(), "new-id"). + Return(nil) + runtime.EXPECT(). + Inspect(gomock.Any(), "new-id"). + Return(&container.Info{ + ID: "new-id", + Ports: []container.PortBinding{}, + }, nil) + }, + expectError: false, + }, + { + name: "rebuild with dockerfile build fails", + devName: "geodesic", + instance: "default", + noPull: false, + setupMocks: func(loader *MockConfigLoader, identity *MockIdentityManager, detector *MockRuntimeDetector, runtime *MockRuntime) { + config := &Config{ + Name: "geodesic", + Image: "", + Build: &Build{ + Context: ".", + Dockerfile: "Dockerfile", + }, + } + loader.EXPECT(). + LoadConfig(gomock.Any(), "geodesic"). + Return(config, &Settings{}, nil) + detector.EXPECT(). + DetectRuntime(""). + Return(runtime, nil) + runtime.EXPECT(). + Inspect(gomock.Any(), "atmos-devcontainer.geodesic.default"). + Return(nil, errors.New("not found")) + // Build fails. + runtime.EXPECT(). + Build(gomock.Any(), gomock.Any()). + Return(errors.New("build failed: Dockerfile not found")) + }, + expectError: true, + errorIs: errUtils.ErrContainerRuntimeOperation, + }, { name: "operations execute in correct order", devName: "test", diff --git a/pkg/devcontainer/operations.go b/pkg/devcontainer/operations.go index 5dd1b6925a..ac65220a0d 100644 --- a/pkg/devcontainer/operations.go +++ b/pkg/devcontainer/operations.go @@ -350,15 +350,14 @@ func buildImageIfNeeded(ctx context.Context, runtime container.Runtime, config * WithHint("Review the Dockerfile for syntax errors or invalid instructions"). WithHint("See DevContainer build spec: https://containers.dev/implementors/json_reference/#build-properties"). WithHint("See Atmos docs: https://atmos.tools/cli/commands/devcontainer/configuration/"). - WithExample(`components: - devcontainer: - my-dev: - spec: - build: - context: . - dockerfile: Dockerfile - args: - VARIANT: "1.24"`). + WithExample(`devcontainer: + my-dev: + spec: + build: + context: . + dockerfile: Dockerfile + args: + VARIANT: "1.24"`). WithContext("devcontainer_name", devcontainerName). WithContext("image_name", imageName). WithContext("dockerfile", config.Build.Dockerfile). diff --git a/website/blog/2025-12-05-geodesic-production-ready-devcontainer.mdx b/website/blog/2025-12-05-geodesic-production-ready-devcontainer.mdx index 8d81e2dca6..ab12b25249 100644 --- a/website/blog/2025-12-05-geodesic-production-ready-devcontainer.mdx +++ b/website/blog/2025-12-05-geodesic-production-ready-devcontainer.mdx @@ -52,17 +52,16 @@ With [Atmos's native devcontainer support](/changelog/native-devcontainer-suppor ```yaml # atmos.yaml -components: - devcontainer: - geodesic: - spec: - name: "Geodesic DevOps Toolbox" - image: "cloudposse/geodesic:latest" - workspaceFolder: "/workspace" - workspaceMount: "type=bind,source=${PWD},target=/workspace" - containerEnv: - ATMOS_BASE_PATH: "/workspace" - remoteUser: "root" +devcontainer: + geodesic: + spec: + name: "Geodesic DevOps Toolbox" + image: "cloudposse/geodesic:latest" + workspaceFolder: "/workspace" + workspaceMount: "type=bind,source=${localWorkspaceFolder},target=/workspace" + containerEnv: + ATMOS_BASE_PATH: "/workspace" + remoteUser: "root" ``` Then launch it: @@ -145,9 +144,10 @@ This mirrors the classic Geodesic pattern where you'd type `./geodesic.sh` to la ### Use as a Base Image -Create your own custom toolbox based on Geodesic: +Create your own custom toolbox based on Geodesic. First, create a `Dockerfile`: ```dockerfile +# .devcontainer/Dockerfile FROM cloudposse/geodesic:latest # Add your organization's tools @@ -162,24 +162,60 @@ COPY scripts/ /usr/local/bin/ ENV CUSTOM_VAR=value ``` +Then configure Atmos to build from your Dockerfile: + +```yaml +# atmos.yaml +devcontainer: + geodesic: + spec: + name: "Custom Geodesic Toolbox" + build: + dockerfile: ".devcontainer/Dockerfile" + context: "." + args: + GEODESIC_VERSION: "latest" + workspaceFolder: "/workspace" + workspaceMount: "type=bind,source=${localWorkspaceFolder},target=/workspace" + containerEnv: + ATMOS_BASE_PATH: "/workspace" + remoteUser: "root" +``` + +Launch it just like any other devcontainer: + +```bash +# Build and launch +atmos devcontainer shell geodesic + +# Force rebuild after changing Dockerfile +atmos devcontainer shell geodesic --replace +``` + +Atmos will automatically build the image from your Dockerfile, tag it as `atmos-devcontainer-geodesic`, and create the container. + +:::tip Example Available +Check out the complete example in [`examples/devcontainer-build`](https://github.com/cloudposse/atmos/tree/main/examples/devcontainer-build) which includes a working Dockerfile, devcontainer.json, and atmos.yaml for building custom Geodesic containers. +::: + ### Version Pinning for Consistency Pin specific Geodesic versions per project: ```yaml # project-a/atmos.yaml -components: - devcontainer: - toolbox: - spec: - image: "cloudposse/geodesic:4.3.0" # Pinned version +devcontainer: + toolbox: + spec: + image: "cloudposse/geodesic:4.3.0" # Pinned version +``` +```yaml # project-b/atmos.yaml -components: - devcontainer: - toolbox: - spec: - image: "cloudposse/geodesic:4.4.0" # Different version +devcontainer: + toolbox: + spec: + image: "cloudposse/geodesic:4.4.0" # Different version ``` Each project gets the right tool versions automatically. @@ -290,17 +326,15 @@ brew install atmos ```yaml # atmos.yaml -components: - devcontainer: - geodesic: - spec: - image: "cloudposse/geodesic:latest" - workspaceFolder: "/workspace" - workspaceMount: "type=bind,source=${PWD},target=/workspace" - -cli: - aliases: - shell: "devcontainer shell geodesic" +devcontainer: + geodesic: + spec: + image: "cloudposse/geodesic:latest" + workspaceFolder: "/workspace" + workspaceMount: "type=bind,source=${localWorkspaceFolder},target=/workspace" + +aliases: + shell: "devcontainer shell geodesic" ``` ### 3. Launch Your Environment diff --git a/website/docs/cli/commands/devcontainer/shell.mdx b/website/docs/cli/commands/devcontainer/shell.mdx index 43ad7d2cbf..a75f3dfe4f 100644 --- a/website/docs/cli/commands/devcontainer/shell.mdx +++ b/website/docs/cli/commands/devcontainer/shell.mdx @@ -40,7 +40,7 @@ atmos devcontainer shell [flags]
Always create a new instance with auto-generated numbered name based on the `--instance` value (e.g., `default-1`, `default-2`, or `alice-1` with `--instance alice`). Use this when you want a fresh container that doesn't reuse existing instances.
`--replace`
-
Destroy and recreate the instance specified by `--instance` flag. This rebuilds the container from scratch, pulling the latest image and recreating with current configuration.
+
Destroy and recreate the instance specified by `--instance` flag. This rebuilds the container from scratch: if using a `build` configuration, it rebuilds the image from the Dockerfile; if using an `image`, it pulls the latest version. The container is then recreated with current configuration.
`--rm`
Automatically remove the container when you exit the shell. Similar to Docker's `docker run --rm` behavior. Useful for temporary or one-off containers.
@@ -82,6 +82,20 @@ atmos devcontainer shell terraform --replace atmos devcontainer shell terraform --instance prod --replace ``` +### Rebuilding Custom Dockerfiles + +When using a devcontainer with a custom Dockerfile (using `build` configuration), the `--replace` flag will rebuild the image from your Dockerfile: + +```shell +# After modifying your Dockerfile, rebuild and relaunch +atmos devcontainer shell geodesic --replace +``` + +This is particularly useful when: +- You've updated your Dockerfile with new tools or dependencies +- You want to pick up changes to build arguments +- You need a fresh container with the latest image build + ### Temporary Containers ```shell