Dagger
https://dagger.io allows us to define our build using Rust. It's also based around docker so we can re-use our devcontainer.
The Prompt
We'll use AI to setup our build. Use the following prompt
use anyhow::Result; use clap::{Parser, Subcommand}; use dagger_sdk::{ Container, DirectoryDockerBuildOptsBuilder, HostDirectoryOptsBuilder, Query, Service, }; use eyre::eyre; use url::Url; const POSTGRES_IMAGE: &str = "postgres:16-alpine"; const DB_PASSWORD: &str = "password"; const DB_USER: &str = "postgres"; const DB_NAME: &str = "postgres"; #[derive(Parser)] #[command(name = "infrastructure")] #[command(about = "Dagger pipeline for migrations and the web server")] struct Cli { /// Optional tag to publish the migration image (e.g. docker-daemon:local/dbmate:latest) #[arg(long)] migrations_tag: Option<String>, /// Optional tag to publish the web image (e.g. docker-daemon:local/web:latest) #[arg(long)] web_tag: Option<String>, #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Build the migration and web containers Build, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Build => { let database_url = default_database_url(); dagger_sdk::connect(|client| async move { run_build( &client, &database_url, cli.migrations_tag.as_deref(), cli.web_tag.as_deref(), ) .await .map_err(|e| eyre!(e))?; Ok(()) }) .await?; } } Ok(()) } async fn run_build( client: &Query, database_url: &str, migrations_tag: Option<&str>, web_tag: Option<&str>, ) -> Result<()> { let pipeline_db_url = pipeline_database_url(database_url); let workspace = client.host().directory_opts( ".", HostDirectoryOptsBuilder::default() .exclude(vec!["target", "dagger-cache"]) .build()?, ); let devcontainer_ctx = client.host().directory(".devcontainer"); let postgres = postgres_service(client); run_migrations(client, &pipeline_db_url, &postgres).await?; let dev_image = devcontainer_ctx.docker_build_opts( DirectoryDockerBuildOptsBuilder::default() .dockerfile("Dockerfile") .build()?, ); let builder = dev_image .with_directory("/workspace", workspace) .with_workdir("/workspace") .with_user("root") .with_service_binding("postgres", postgres.clone()) .with_env_variable("DATABASE_URL", pipeline_db_url.clone()); let builder = generate_client_and_assets(builder, &pipeline_db_url); let builder = compile_web_server(builder); build_migration_container(client, migrations_tag).await?; build_web_container(client, builder, web_tag).await?; Ok(()) } fn generate_client_and_assets(builder: Container, database_url: &str) -> Container { builder .with_exec(vec!["chmod", "-R", "u+rw", "/workspace/crates/clorinde"]) .with_exec(vec![ "clorinde", "live", "--serialize", "true", "-q", "./crates/db/queries/", "-d", "crates/clorinde", database_url, ]) .with_exec(vec![ "cargo", "build", "-p", "web-islands", "--target", "wasm32-unknown-unknown", "--release", ]) .with_exec(vec![ "wasm-bindgen", "target/wasm32-unknown-unknown/release/web_islands.wasm", "--target", "web", "--out-dir", "crates/web-assets/dist", ]) .with_exec(vec![ "tailwind-extra", "-i", "./crates/web-assets/input.css", "-o", "./crates/web-assets/dist/tailwind.css", ]) } fn compile_web_server(builder: Container) -> Container { builder.with_exec(vec![ "cargo", "build", "--release", "-p", "web-server", "--target", "x86_64-unknown-linux-musl", ]) } async fn build_migration_container(client: &Query, tag: Option<&str>) -> Result<()> { let migrations = client.host().directory("crates/db/migrations"); let image = client .container() .from("ghcr.io/amacneil/dbmate:2") .with_workdir("/db") .with_directory("/db/migrations", migrations); if let Some(tag) = tag { image.publish(tag).await?; println!("✅ migration image published to {tag}"); } else { image.id().await?; println!("✅ migration container built"); } Ok(()) } async fn build_web_container(client: &Query, builder: Container, tag: Option<&str>) -> Result<()> { let binary = builder.file("/workspace/target/x86_64-unknown-linux-musl/release/web-server"); let assets = builder.directory("/workspace/crates/web-assets/dist"); let images = builder.directory("/workspace/crates/web-assets/images"); let container = client .container() .with_user("1001") .with_file("/web-server", binary) .with_directory("/workspace/crates/web-assets/dist", assets) .with_directory("/workspace/crates/web-assets/images", images) .with_entrypoint(vec!["./web-server"]); if let Some(tag) = tag { container.publish(tag).await?; println!("✅ web image published to {tag}"); } else { container.id().await?; println!("✅ web container built"); } Ok(()) } fn postgres_service(client: &Query) -> Service { client .container() .from(POSTGRES_IMAGE) .with_env_variable("POSTGRES_PASSWORD", DB_PASSWORD) .with_env_variable("POSTGRES_USER", DB_USER) .with_env_variable("POSTGRES_DB", DB_NAME) .with_exposed_port(5432) .as_service() } async fn run_migrations(client: &Query, database_url: &str, postgres: &Service) -> Result<()> { let migration_root = client.host().directory("crates/db"); client .container() .from("ghcr.io/amacneil/dbmate:2") .with_workdir("/db") .with_directory("/db", migration_root) .with_env_variable("DATABASE_URL", database_url) .with_service_binding("postgres", postgres.clone()) .with_exec(vec!["ls", "-l", "/db/migrations"]) .with_exec(vec![ "dbmate", "-d", "/db/migrations", "-u", database_url, "wait", ]) .with_exec(vec![ "dbmate", "-d", "/db/migrations", "-u", database_url, "up", ]) .with_exec(vec![ "dbmate", "-d", "/db/migrations", "-u", database_url, "status", ]) .with_exec(vec![ "sh", "-c", "psql \"$DATABASE_URL\" -Atc \"select to_regclass('public.accounts')\" | grep -q accounts", ]) .sync() .await?; Ok(()) } fn default_database_url() -> String { format!("postgres://{DB_USER}:{DB_PASSWORD}@postgres:5432/{DB_NAME}?sslmode=disable") } fn pipeline_database_url(original: &str) -> String { if let Ok(mut parsed) = Url::parse(original) { if let Some(host) = parsed.host_str() { if matches!( host, "host.docker.internal" | "localhost" | "127.0.0.1" | "::1" ) { let _ = parsed.set_host(Some("postgres")); // Always point to the service port exposed in the pipeline. let _ = parsed.set_port(Some(5432)); } } parsed.to_string() } else { original.to_string() } }