feat: worker crate, cargo-generate config, liquid templates, docker

This commit is contained in:
2026-05-18 00:26:17 +02:00
parent 1c5ae5d239
commit 5b0d5bf15d
15 changed files with 239 additions and 116 deletions

8
.gitignore vendored
View File

@@ -1,3 +1,9 @@
/target
**/*.rs.bk
.env
*.db
data.db
*.db-shm
*.db-wal
.idea/
.vscode/
**/dev.db

10
Cargo.lock generated
View File

@@ -2486,6 +2486,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "worker"
version = "0.1.0"
dependencies = [
"adapters-sqlite",
"anyhow",
"async-trait",
"domain",
"dotenvy",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "writeable"

View File

@@ -1,27 +1,11 @@
FROM rust:1.92 AS builder
FROM rust:1.85-slim AS builder
WORKDIR /app
COPY . .
# Build the release binary
RUN cargo build --release -p api
RUN cargo build --release -p bootstrap
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install OpenSSL (required for many Rust networking crates) and CA certificates
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/api .
# Create data directory for SQLite
RUN mkdir -p /app/data
ENV DATABASE_URL=sqlite:///app/data/template.db
ENV SESSION_SECRET=supersecretchangeinproduction
COPY --from=builder /app/target/release/server ./server
EXPOSE 3000
CMD ["./api"]
CMD ["./server"]

View File

@@ -1,33 +1,35 @@
[template]
cargo_generate_version = ">=0.21.0"
ignore = [".git", "target", ".idea", ".vscode", "data.db"]
[filenames]
"api/Cargo.toml.template" = "api/Cargo.toml"
"infra/Cargo.toml.template" = "infra/Cargo.toml"
ignore = [".git", "target", ".idea", ".vscode", "data.db", "*.liquid", "**/.sqlx", "**/dev.db"]
[placeholders.project_name]
type = "string"
prompt = "Project name"
prompt = "Project name (snake_case)"
[placeholders.database]
type = "string"
prompt = "Database type"
prompt = "Database backend"
choices = ["sqlite", "postgres"]
default = "sqlite"
[placeholders.auth_jwt]
[placeholders.worker]
type = "bool"
prompt = "Enable JWT authentication (Bearer tokens)?"
default = true
prompt = "Include background worker binary?"
default = false
[placeholders.auth_oidc]
type = "bool"
prompt = "Enable OIDC integration (Login with Google, etc.)?"
default = true
prompt = "Include OIDC/OAuth2 adapter stub?"
default = false
[conditional.'database == "sqlite"']
ignore = ["migrations_postgres"]
ignore = ["crates/adapters/postgres"]
[conditional.'database == "postgres"']
ignore = ["migrations_sqlite"]
ignore = ["crates/adapters/sqlite"]
[conditional.'!worker']
ignore = ["crates/worker"]
[conditional.'!auth_oidc']
ignore = ["crates/adapters/auth/src/oidc.rs"]

View File

@@ -1,89 +1,14 @@
services:
backend:
app:
build: .
ports:
- "3000:3000"
environment:
- SESSION_SECRET=dev_secret_key_12345
- DATABASE_URL=sqlite:///app/data/notes.db
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
- HOST=0.0.0.0
- PORT=3000
- DB_MAX_CONNECTIONS=5
- DB_MIN_CONNECTIONS=1
- SECURE_COOKIE=true
DATABASE_URL: sqlite:///data/app.db
JWT_SECRET: change-me-in-production
RUST_LOG: info
volumes:
- ./data:/app/data
# nats:
# image: nats:alpine
# ports:
# - "4222:4222"
# - "6222:6222"
# - "8222:8222"
# restart: unless-stopped
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: k_template_db
ports:
- "5439:5432"
volumes:
- db_data:/var/lib/postgresql/data
zitadel-db:
image: postgres:16-alpine
container_name: zitadel_db
environment:
POSTGRES_USER: zitadel
POSTGRES_PASSWORD: zitadel_password
POSTGRES_DB: zitadel
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- zitadel_db_data:/var/lib/postgresql/data
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: zitadel_local
depends_on:
zitadel-db:
condition: service_healthy
ports:
- "8086:8080"
# USE start-from-init (Fixes the "relation does not exist" bug)
command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"'
environment:
# Database Connection
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
# APPLICATION USER (Zitadel uses this to run)
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
# ADMIN USER (Zitadel uses this to create tables/migrations)
# We use 'zitadel' because it is the owner of the DB in your postgres container.
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# General Config
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_EXTERNALPORT: 8086
ZITADEL_EXTERNALSECURE: "false"
ZITADEL_TLS_ENABLED: "false"
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
- db_data:/data
volumes:
db_data:
zitadel_db_data:

View File

@@ -0,0 +1,19 @@
[package]
name = "adapters-auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
jsonwebtoken = { workspace = true }
bcrypt = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
{% if auth_oidc %}
openidconnect = "3"
reqwest = { version = "0.12", features = ["json"] }
{% endif %}

View File

@@ -0,0 +1,27 @@
[package]
name = "bootstrap"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "{{project_name}}"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
adapters-auth = { workspace = true }
presentation = { workspace = true }
{% if database == "sqlite" %}
adapters-sqlite = { path = "../adapters/sqlite" }
{% endif %}
{% if database == "postgres" %}
adapters-postgres = { path = "../adapters/postgres" }
{% endif %}
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
tower-http = { workspace = true }
axum = { workspace = true }

18
crates/worker/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "worker"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
adapters-sqlite = { path = "../adapters/sqlite" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,23 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "{{project_name}}-worker"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
{% if database == "sqlite" %}
adapters-sqlite = { path = "../adapters/sqlite" }
{% endif %}
{% if database == "postgres" %}
adapters-postgres = { path = "../adapters/postgres" }
{% endif %}
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,18 @@
#[derive(Debug, Clone)]
pub struct WorkerConfig {
pub database_url: String,
pub example_job_interval_secs: u64,
}
impl WorkerConfig {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
example_job_interval_secs: std::env::var("EXAMPLE_JOB_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60),
}
}
}

7
crates/worker/src/job.rs Normal file
View File

@@ -0,0 +1,7 @@
use async_trait::async_trait;
#[async_trait]
pub trait Job: Send + Sync {
fn name(&self) -> &str;
async fn run(&self) -> anyhow::Result<()>;
}

View File

@@ -0,0 +1,14 @@
use async_trait::async_trait;
use tracing::info;
use crate::job::Job;
pub struct ExampleJob;
#[async_trait]
impl Job for ExampleJob {
fn name(&self) -> &str { "example" }
async fn run(&self) -> anyhow::Result<()> {
info!("example job ran — replace with real work");
Ok(())
}
}

View File

@@ -0,0 +1,2 @@
pub mod example;
pub use example::ExampleJob;

34
crates/worker/src/main.rs Normal file
View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use std::time::Duration;
use tracing::info;
mod config;
mod job;
mod jobs;
mod runner;
use jobs::ExampleJob;
use runner::JobRunner;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("worker=info".parse()?),
)
.init();
let config = config::WorkerConfig::from_env();
info!("Worker starting");
let _pool = adapters_sqlite::connect(&config.database_url).await?;
adapters_sqlite::run_migrations(&_pool).await?;
let interval = Duration::from_secs(config.example_job_interval_secs);
let runner = JobRunner::new().register(Arc::new(ExampleJob), interval);
info!("Worker running");
runner.run().await;
Ok(())
}

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info};
use crate::job::Job;
pub struct JobRunner {
jobs: Vec<(Arc<dyn Job>, Duration)>,
}
impl JobRunner {
pub fn new() -> Self { Self { jobs: vec![] } }
pub fn register(mut self, job: Arc<dyn Job>, interval: Duration) -> Self {
self.jobs.push((job, interval));
self
}
pub async fn run(self) {
let handles: Vec<_> = self.jobs.into_iter().map(|(job, interval)| {
tokio::spawn(async move {
loop {
info!(job = job.name(), "running job");
if let Err(e) = job.run().await {
error!(job = job.name(), error = %e, "job failed");
}
tokio::time::sleep(interval).await;
}
})
}).collect();
for handle in handles { let _ = handle.await; }
}
}
impl Default for JobRunner { fn default() -> Self { Self::new() } }