// Container image builder for Ubuntu and Chainguard-based images with extensible tools package main import ( "context" "dagger/containers/internal/dagger" "fmt" "gopkg.in/yaml.v3" ) type Containers struct{} // Configuration structures type ToolsConfig struct { Core CoreTools `yaml:"core"` Tools map[string]ToolDef `yaml:"tools"` } type CoreTools struct { Git string `yaml:"git"` Jq string `yaml:"jq"` Yq string `yaml:"yq"` Node20 string `yaml:"node20"` Node22 string `yaml:"node22"` Node24 string `yaml:"node24"` } type ToolDef struct { Version string `yaml:"version"` Description string `yaml:"description"` } // Load tools configuration from YAML func (m *Containers) loadConfig(ctx context.Context, configFile *dagger.File) (*ToolsConfig, error) { content, err := configFile.Contents(ctx) if err != nil { return nil, err } var config ToolsConfig if err := yaml.Unmarshal([]byte(content), &config); err != nil { return nil, err } return &config, nil } // archFor returns architecture strings used by various upstream releases. // arch: used by jq/yq/kustomize/dagger ("amd64"|"arm64") // goArch: used by zola ("x86_64"|"aarch64") func (m *Containers) archFor(platform dagger.Platform) (arch, goArch string) { arch = "amd64" goArch = "x86_64" if platform == "linux/arm64" { arch = "arm64" goArch = "aarch64" } return } // withHTTPExecutable downloads a file via Dagger HTTP and installs it to dst with +x func (m *Containers) withHTTPExecutable(ctr *dagger.Container, url, dst string) *dagger.Container { file := dag.HTTP(url) return ctr.WithFile(dst, file).WithExec([]string{"chmod", "+x", dst}) } // withHTTPFile downloads a remote file via Dagger HTTP to a temporary path func (m *Containers) withHTTPFile(ctr *dagger.Container, url, tmpPath string) *dagger.Container { file := dag.HTTP(url) return ctr.WithFile(tmpPath, file) } // extractTarGz extracts a .tar.gz archive to destination directory func (m *Containers) extractTarGz(ctr *dagger.Container, archivePath, destDir string) *dagger.Container { return ctr.WithExec([]string{"tar", "-xzf", archivePath, "-C", destDir}) } // installJq installs jq for the provided arch into /usr/local/bin func (m *Containers) installJq(ctr *dagger.Container, config *ToolsConfig, arch string) *dagger.Container { jqURL := fmt.Sprintf("https://github.com/jqlang/jq/releases/download/jq-%s/jq-linux-%s", config.Core.Jq, arch) ctr = m.withHTTPExecutable(ctr, jqURL, "/usr/local/bin/jq") return ctr } // installYq installs yq for the provided arch into /usr/local/bin func (m *Containers) installYq(ctr *dagger.Container, config *ToolsConfig, arch string) *dagger.Container { yqURL := fmt.Sprintf("https://github.com/mikefarah/yq/releases/download/v%s/yq_linux_%s", config.Core.Yq, arch) ctr = m.withHTTPExecutable(ctr, yqURL, "/usr/local/bin/yq") return ctr } // installKustomize downloads and installs kustomize for the given arch func (m *Containers) installKustomize(ctr *dagger.Container, toolDef ToolDef, arch string) *dagger.Container { url := fmt.Sprintf("https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%%2Fv%s/kustomize_v%s_linux_%s.tar.gz", toolDef.Version, toolDef.Version, arch) tmp := fmt.Sprintf("/tmp/kustomize_v%s_linux_%s.tar.gz", toolDef.Version, arch) ctr = m.withHTTPFile(ctr, url, tmp) return m.extractTarGz(ctr, tmp, "/usr/local/bin") } // installDagger downloads and installs dagger CLI for the given arch func (m *Containers) installDagger(ctr *dagger.Container, toolDef ToolDef, arch string) *dagger.Container { url := fmt.Sprintf("https://github.com/dagger/dagger/releases/download/v%s/dagger_v%s_linux_%s.tar.gz", toolDef.Version, toolDef.Version, arch) tmp := fmt.Sprintf("/tmp/dagger_v%s_linux_%s.tar.gz", toolDef.Version, arch) ctr = m.withHTTPFile(ctr, url, tmp) return m.extractTarGz(ctr, tmp, "/usr/local/bin") } // installZola downloads and installs zola for the given goArch func (m *Containers) installZola(ctr *dagger.Container, toolDef ToolDef, goArch string) *dagger.Container { url := fmt.Sprintf("https://github.com/getzola/zola/releases/download/v%s/zola-v%s-%s-unknown-linux-gnu.tar.gz", toolDef.Version, toolDef.Version, goArch) tmp := fmt.Sprintf("/tmp/zola-v%s-%s-unknown-linux-gnu.tar.gz", toolDef.Version, goArch) ctr = m.withHTTPFile(ctr, url, tmp) return m.extractTarGz(ctr, tmp, "/usr/local/bin") } // Build Ubuntu base image with core tools func (m *Containers) buildUbuntuBase(ctx context.Context, config *ToolsConfig, platform dagger.Platform) *dagger.Container { ctr := dag.Container(dagger.ContainerOpts{Platform: platform}).From("ubuntu:24.04") // Determine architecture for downloads arch, _ := m.archFor(platform) // Node.js uses "x64" for amd64 in official tarball names nodeArch := arch if arch == "amd64" { nodeArch = "x64" } // Update and install base dependencies ctr = ctr.WithExec([]string{"apt-get", "update"}). WithExec([]string{"apt-get", "install", "-y", "ca-certificates", "curl", "wget", "gnupg", "lsb-release", "xz-utils", }) // Install Git ctr = ctr.WithExec([]string{"apt-get", "install", "-y", "git"}) // Install jq & yq ctr = m.installJq(ctr, config, arch) ctr = m.installYq(ctr, config, arch) // Install multiple Node.js versions side-by-side (20, 22, 24) // We fetch official tarballs and create versioned shims: node20/node22/node24 and npm20/npm22/npm24 // Default `node`/`npm` will point to Node 24 // Prepare Node.js tarballs via Dagger HTTP (cached) and install three versions side-by-side { nodeURL := func(ver string) string { return fmt.Sprintf("https://nodejs.org/dist/v%s/node-v%s-linux-%s.tar.xz", ver, ver, nodeArch) } n20 := dag.HTTP(nodeURL(config.Core.Node20)) n22 := dag.HTTP(nodeURL(config.Core.Node22)) n24 := dag.HTTP(nodeURL(config.Core.Node24)) n20Name := fmt.Sprintf("/tmp/node-v%s-linux-%s.tar.xz", config.Core.Node20, nodeArch) n22Name := fmt.Sprintf("/tmp/node-v%s-linux-%s.tar.xz", config.Core.Node22, nodeArch) n24Name := fmt.Sprintf("/tmp/node-v%s-linux-%s.tar.xz", config.Core.Node24, nodeArch) ctr = ctr.WithFile(n20Name, n20). WithFile(n22Name, n22). WithFile(n24Name, n24) // Use bash for pipefail support (dash/sh doesn't support 'pipefail') ctr = ctr.WithExec([]string{"bash", "-lc", fmt.Sprintf(`set -euo pipefail mkdir -p /opt extract_node() { tarball="$1" ver="$2" major="$3" dest="/opt/node-v${ver}" mkdir -p "$dest" # Official tarballs extract as node-v-linux-; normalize into /opt/node-v tar -xJf "$tarball" -C "$dest" --strip-components=1 if [ ! -d "$dest" ]; then echo "Expected $dest to exist after extraction" >&2 exit 1 fi ln -sf "$dest/bin/node" "/usr/local/bin/node${major}" ln -sf "$dest/bin/npm" "/usr/local/bin/npm${major}" if [ -f "$dest/bin/npx" ]; then ln -sf "$dest/bin/npx" "/usr/local/bin/npx${major}" fi } extract_node "%s" "%s" 20 extract_node "%s" "%s" 22 extract_node "%s" "%s" 24 # Set default to Node 24 ln -sf "/usr/local/bin/node24" "/usr/local/bin/node" ln -sf "/usr/local/bin/npm24" "/usr/local/bin/npm" if [ -e "/usr/local/bin/npx24" ]; then ln -sf "/usr/local/bin/npx24" "/usr/local/bin/npx" fi `, n20Name, config.Core.Node20, n22Name, config.Core.Node22, n24Name, config.Core.Node24)}) } // Clean up ctr = ctr.WithExec([]string{"apt-get", "clean"}). WithExec([]string{"rm", "-rf", "/var/lib/apt/lists/*"}) return ctr } // Build Chainguard base image with core tools func (m *Containers) buildChainguardBase(ctx context.Context, config *ToolsConfig, platform dagger.Platform) *dagger.Container { // Using Chainguard's Wolfi-based images which are minimal ctr := dag.Container(dagger.ContainerOpts{Platform: platform}).From("cgr.dev/chainguard/wolfi-base:latest") // Determine architecture for downloads arch, _ := m.archFor(platform) // Install core tools using apk (Wolfi package manager) ctr = ctr.WithExec([]string{"apk", "update"}). WithExec([]string{"apk", "add", "git", "curl", "wget", "bash", "ca-certificates"}) // Create /usr/local/bin if it doesn't exist ctr = ctr.WithExec([]string{"mkdir", "-p", "/usr/local/bin"}) // Install jq & yq ctr = m.installJq(ctr, config, arch) ctr = m.installYq(ctr, config, arch) // Install Node.js ctr = ctr.WithExec([]string{"apk", "add", "nodejs", "npm"}) return ctr } // Build Ubuntu image with specific tool func (m *Containers) buildUbuntuTool(ctx context.Context, base *dagger.Container, toolName string, toolDef ToolDef, platform dagger.Platform) *dagger.Container { ctr := base // Determine architecture for downloads arch, goArch := m.archFor(platform) switch toolName { case "kustomize": ctr = m.installKustomize(ctr, toolDef, arch) case "dagger": ctr = m.installDagger(ctr, toolDef, arch) case "esphome": ctr = ctr.WithExec([]string{"apt-get", "update"}). WithExec([]string{"apt-get", "install", "-y", "python3", "python3-pip", "python3-venv"}). WithExec([]string{"pip3", "install", "--break-system-packages", fmt.Sprintf("esphome==%s", toolDef.Version)}). WithExec([]string{"apt-get", "clean"}) case "zola": ctr = m.installZola(ctr, toolDef, goArch) case "yamllint": ctr = ctr.WithExec([]string{"apt-get", "update"}). WithExec([]string{"apt-get", "install", "-y", "python3", "python3-pip"}). WithExec([]string{"pip3", "install", "--break-system-packages", fmt.Sprintf("yamllint==%s", toolDef.Version)}). WithExec([]string{"apt-get", "clean"}) } return ctr } // Build Chainguard image with specific tool func (m *Containers) buildChainguardTool(ctx context.Context, base *dagger.Container, toolName string, toolDef ToolDef, platform dagger.Platform) *dagger.Container { ctr := base // Determine architecture for downloads arch, goArch := m.archFor(platform) switch toolName { case "kustomize": ctr = m.installKustomize(ctr, toolDef, arch) case "dagger": ctr = m.installDagger(ctr, toolDef, arch) case "esphome": ctr = ctr.WithExec([]string{"apk", "add", "python3", "py3-pip"}). WithExec([]string{"pip3", "install", fmt.Sprintf("esphome==%s", toolDef.Version)}) case "zola": ctr = m.installZola(ctr, toolDef, goArch) case "yamllint": ctr = ctr.WithExec([]string{"apk", "add", "python3", "py3-pip"}). WithExec([]string{"pip3", "install", fmt.Sprintf("yamllint==%s", toolDef.Version)}) } return ctr } // BuildAll builds all container images for multiple architectures func (m *Containers) BuildAll(ctx context.Context, // Configuration file configFile *dagger.File, // Registry to push images to // +optional registry string, // Optional image name prefix to insert between registry and image name (e.g., "foo" -> registry/foo/image:tag) // +optional prefix string, // Username for authenticating to the registry // +optional registryUsername string, // Password/token for authenticating to the registry // +optional registryPassword *dagger.Secret, ) error { // Load configuration config, err := m.loadConfig(ctx, configFile) if err != nil { return fmt.Errorf("failed to load config: %w", err) } platforms := []dagger.Platform{"linux/amd64", "linux/arm64"} // Build and publish Ubuntu base ubuntuBaseTag := "ubuntu-runner:24.04" if prefix != "" { ubuntuBaseTag = fmt.Sprintf("%s/%s/%s", registry, prefix, ubuntuBaseTag) } else { ubuntuBaseTag = fmt.Sprintf("%s/%s", registry, ubuntuBaseTag) } var ubuntuBaseVariants []*dagger.Container for _, platform := range platforms { ubuntuBase := m.buildUbuntuBase(ctx, config, platform) ubuntuBaseVariants = append(ubuntuBaseVariants, ubuntuBase) } // Configure registry auth if provided publisher := dag.Container() if registry != "" && registryUsername != "" && registryPassword != nil { publisher = publisher.WithRegistryAuth(registry, registryUsername, registryPassword) } if _, err := publisher.Publish(ctx, ubuntuBaseTag, dagger.ContainerPublishOpts{ PlatformVariants: ubuntuBaseVariants, }); err != nil { return fmt.Errorf("failed to publish ubuntu base: %w", err) } fmt.Printf("Published (multi-arch): %s\n", ubuntuBaseTag) // Build and publish tool-specific images for toolName, toolDef := range config.Tools { // Ubuntu variant var ubuntuToolVariants []*dagger.Container for _, platform := range platforms { ubuntuBase := m.buildUbuntuBase(ctx, config, platform) ubuntuTool := m.buildUbuntuTool(ctx, ubuntuBase, toolName, toolDef, platform) ubuntuToolVariants = append(ubuntuToolVariants, ubuntuTool) } ubuntuTag := fmt.Sprintf("ubuntu-%s:%s", toolName, toolDef.Version) if prefix != "" { ubuntuTag = fmt.Sprintf("%s/%s/%s", registry, prefix, ubuntuTag) } else { ubuntuTag = fmt.Sprintf("%s/%s", registry, ubuntuTag) } publisher := dag.Container() if registry != "" && registryUsername != "" && registryPassword != nil { publisher = publisher.WithRegistryAuth(registry, registryUsername, registryPassword) } if _, err := publisher.Publish(ctx, ubuntuTag, dagger.ContainerPublishOpts{ PlatformVariants: ubuntuToolVariants, }); err != nil { return fmt.Errorf("failed to publish ubuntu-%s: %w", toolName, err) } fmt.Printf("Published (multi-arch): %s\n", ubuntuTag) // Chainguard variant var chainguardToolVariants []*dagger.Container for _, platform := range platforms { chainguardBase := m.buildChainguardBase(ctx, config, platform) chainguardTool := m.buildChainguardTool(ctx, chainguardBase, toolName, toolDef, platform) chainguardToolVariants = append(chainguardToolVariants, chainguardTool) } chainguardTag := fmt.Sprintf("%s:%s", toolName, toolDef.Version) if prefix != "" { chainguardTag = fmt.Sprintf("%s/%s/%s", registry, prefix, chainguardTag) } else { chainguardTag = fmt.Sprintf("%s/%s", registry, chainguardTag) } publisher = dag.Container() if registry != "" && registryUsername != "" && registryPassword != nil { publisher = publisher.WithRegistryAuth(registry, registryUsername, registryPassword) } if _, err := publisher.Publish(ctx, chainguardTag, dagger.ContainerPublishOpts{ PlatformVariants: chainguardToolVariants, }); err != nil { return fmt.Errorf("failed to publish %s: %w", toolName, err) } fmt.Printf("Published (multi-arch): %s\n", chainguardTag) } return nil } // Build a single tool image func (m *Containers) BuildTool(ctx context.Context, // Tool name to build (e.g., "kustomize", "dagger") tool string, // Configuration file configFile *dagger.File, // Base image type: "ubuntu" or "chainguard" // +optional // +default="ubuntu" base string, // Platform to build for // +optional // +default="linux/amd64" platform dagger.Platform, ) (*dagger.Container, error) { // Load configuration config, err := m.loadConfig(ctx, configFile) if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } toolDef, ok := config.Tools[tool] if !ok { return nil, fmt.Errorf("tool %s not found in config", tool) } var baseImage *dagger.Container if base == "ubuntu" { baseImage = m.buildUbuntuBase(ctx, config, platform) return m.buildUbuntuTool(ctx, baseImage, tool, toolDef, platform), nil } else { baseImage = m.buildChainguardBase(ctx, config, platform) return m.buildChainguardTool(ctx, baseImage, tool, toolDef, platform), nil } }