Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions errors/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
// but only if they don't already have fences (for 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)
}
Expand Down
18 changes: 3 additions & 15 deletions examples/devcontainer-build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 39 additions & 25 deletions examples/devcontainer-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
}
}
}
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions examples/devcontainer-build/atmos.yaml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 1 addition & 2 deletions examples/devcontainer-build/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"GEODESIC_VERSION": "latest",
"ATMOS_VERSION": "latest"
"GEODESIC_VERSION": "latest"
}
},
"workspaceFolder": "/workspace",
Expand Down
15 changes: 13 additions & 2 deletions pkg/devcontainer/lifecycle_rebuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions pkg/devcontainer/lifecycle_rebuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 8 additions & 9 deletions pkg/devcontainer/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading