Dagger
Dagger 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; 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 { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Build the migration and web containers Build { /// 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>, }, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Build { migrations_tag, web_tag, } => { let database_url = default_database_url(); dagger_sdk::connect(|client| async move { run_build( &client, &database_url, migrations_tag.as_deref(), 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 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); 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("DBMATE_MIGRATIONS_DIR", "/workspace/crates/db/migrations") .with_env_variable("DATABASE_URL", database_url); let builder = run_migrations(builder); let builder = generate_client_and_assets(builder, &database_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() } fn run_migrations(builder: Container) -> Container { builder .with_exec(vec!["ls", "-l", "crates/db/migrations"]) .with_exec(vec![ "sh", "-c", "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 <<'SQL'\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'application_user') THEN\n CREATE ROLE application_user LOGIN PASSWORD 'testpassword';\n END IF;\nEND\n$$;\nSQL", ]) .with_exec(vec![ "dbmate", "up", ]) .with_exec(vec![ "dbmate", "status", ]) .with_exec(vec![ "sh", "-c", "psql \"$DATABASE_URL\" -Atc \"select to_regclass('public.accounts')\" | grep -q accounts", ]) } fn default_database_url() -> String { format!("postgres://{DB_USER}:{DB_PASSWORD}@postgres:5432/{DB_NAME}?sslmode=disable") }