M1: Rust control plane, builder, dashboard, and infra
- Cargo workspace with hiy-server (axum 0.7 + sqlx SQLite + tokio) - SQLite schema: apps, deploys, env_vars (inline migrations, no daemon) - Background build worker: sequential queue, streams stdout/stderr to DB - REST API: CRUD for apps, deploys, env vars; GitHub webhook with HMAC-SHA256 - SSE endpoint for live build log streaming - Monospace HTMX-free dashboard: app list + per-app detail, log viewer, env editor - builder/build.sh: clone/pull → detect strategy (Dockerfile/buildpack/static) → docker build → swap container → update Caddy via admin API → prune images - infra/docker-compose.yml + Dockerfile.server for local dev (no Pi needed) - proxy/Caddyfile: auto-HTTPS off for local, comment removed for production - .env.example Compiles clean (zero warnings). Run locally: cp .env.example .env && cargo run --bin hiy-server https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
c46e3b4125
commit
8f5bb158cb
2268 changed files with 36058 additions and 0 deletions
20
.env.example
Normal file
20
.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Copy to .env and fill in values before running locally with `cargo run`.
|
||||||
|
|
||||||
|
# Where HIY stores its SQLite database, build checkouts, and env files.
|
||||||
|
HIY_DATA_DIR=./data
|
||||||
|
|
||||||
|
# Address the server listens on.
|
||||||
|
HIY_ADDR=0.0.0.0:3000
|
||||||
|
|
||||||
|
# Path to the build script (relative to CWD when running the server binary).
|
||||||
|
HIY_BUILD_SCRIPT=./builder/build.sh
|
||||||
|
|
||||||
|
# Caddy admin API URL (used by build.sh to update routing).
|
||||||
|
CADDY_API_URL=http://localhost:2019
|
||||||
|
|
||||||
|
# Suffix appended to app names to form subdomains: myapp.<DOMAIN_SUFFIX>
|
||||||
|
# Use "localhost" for local dev, your real domain on the Pi.
|
||||||
|
DOMAIN_SUFFIX=localhost
|
||||||
|
|
||||||
|
# Rust log filter.
|
||||||
|
RUST_LOG=hiy_server=debug,tower_http=info
|
||||||
2736
Cargo.lock
generated
Normal file
2736
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["server"]
|
||||||
|
resolver = "2"
|
||||||
152
builder/build.sh
Executable file
152
builder/build.sh
Executable file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# HIY Build Engine
|
||||||
|
# Environment variables injected by hiy-server:
|
||||||
|
# APP_ID, APP_NAME, REPO_URL, BRANCH, PORT, ENV_FILE, SHA, BUILD_DIR
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo "[hiy] $*"; }
|
||||||
|
|
||||||
|
log "=== HostItYourself Build Engine ==="
|
||||||
|
log "App: $APP_NAME ($APP_ID)"
|
||||||
|
log "Repo: $REPO_URL"
|
||||||
|
log "Branch: $BRANCH"
|
||||||
|
log "Build dir: $BUILD_DIR"
|
||||||
|
|
||||||
|
# ── 1. Clone or pull ───────────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
cd "$BUILD_DIR"
|
||||||
|
|
||||||
|
if [ -d ".git" ]; then
|
||||||
|
log "Updating existing clone…"
|
||||||
|
git fetch origin "$BRANCH" --depth=50
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
else
|
||||||
|
log "Cloning repository…"
|
||||||
|
git clone --depth=50 --branch "$BRANCH" "$REPO_URL" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTUAL_SHA=$(git rev-parse HEAD)
|
||||||
|
log "SHA: $ACTUAL_SHA"
|
||||||
|
|
||||||
|
# ── 2. Detect build strategy ──────────────────────────────────────────────────
|
||||||
|
IMAGE_TAG="hiy/${APP_ID}:${ACTUAL_SHA}"
|
||||||
|
CONTAINER_NAME="hiy-${APP_ID}"
|
||||||
|
|
||||||
|
if [ -f "Dockerfile" ]; then
|
||||||
|
log "Strategy: Dockerfile"
|
||||||
|
docker build --tag "$IMAGE_TAG" .
|
||||||
|
|
||||||
|
elif [ -f "package.json" ] || [ -f "yarn.lock" ]; then
|
||||||
|
log "Strategy: Node.js (Cloud Native Buildpack)"
|
||||||
|
if ! command -v pack &>/dev/null; then
|
||||||
|
log "ERROR: 'pack' CLI not found. Install it: https://buildpacks.io/docs/tools/pack/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pack build "$IMAGE_TAG" --builder paketobuildpacks/builder-jammy-base
|
||||||
|
|
||||||
|
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
|
||||||
|
log "Strategy: Python (Cloud Native Buildpack)"
|
||||||
|
if ! command -v pack &>/dev/null; then
|
||||||
|
log "ERROR: 'pack' CLI not found. Install it: https://buildpacks.io/docs/tools/pack/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pack build "$IMAGE_TAG" --builder paketobuildpacks/builder-jammy-base
|
||||||
|
|
||||||
|
elif [ -f "go.mod" ]; then
|
||||||
|
log "Strategy: Go (Cloud Native Buildpack)"
|
||||||
|
if ! command -v pack &>/dev/null; then
|
||||||
|
log "ERROR: 'pack' CLI not found. Install it: https://buildpacks.io/docs/tools/pack/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pack build "$IMAGE_TAG" --builder paketobuildpacks/builder-jammy-base
|
||||||
|
|
||||||
|
elif [ -d "static" ] || [ -d "public" ]; then
|
||||||
|
STATIC_DIR="static"
|
||||||
|
[ -d "public" ] && STATIC_DIR="public"
|
||||||
|
log "Strategy: Static files (Caddy) from ./$STATIC_DIR"
|
||||||
|
cat > Dockerfile.hiy <<EOF
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
COPY $STATIC_DIR /srv
|
||||||
|
EOF
|
||||||
|
docker build --file Dockerfile.hiy --tag "$IMAGE_TAG" .
|
||||||
|
rm -f Dockerfile.hiy
|
||||||
|
|
||||||
|
else
|
||||||
|
log "ERROR: Could not detect build strategy."
|
||||||
|
log "Add a Dockerfile, package.json, requirements.txt, go.mod, or a static/ directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Ensure Docker network ───────────────────────────────────────────────────
|
||||||
|
docker network create hiy-net 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 4. Stop & remove previous container ───────────────────────────────────────
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
log "Stopping old container…"
|
||||||
|
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Start new container ────────────────────────────────────────────────────
|
||||||
|
log "Starting container $CONTAINER_NAME…"
|
||||||
|
docker run --detach \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--network hiy-net \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
--expose "$PORT" \
|
||||||
|
--label "hiy.app=${APP_ID}" \
|
||||||
|
--label "hiy.port=${PORT}" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--memory="512m" \
|
||||||
|
--cpus="0.5" \
|
||||||
|
"$IMAGE_TAG"
|
||||||
|
|
||||||
|
# ── 6. Update Caddy via its admin API ─────────────────────────────────────────
|
||||||
|
CADDY_API="${CADDY_API_URL:-http://localhost:2019}"
|
||||||
|
DOMAIN_SUFFIX="${DOMAIN_SUFFIX:-localhost}"
|
||||||
|
|
||||||
|
if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
|
||||||
|
CONTAINER_IP=$(docker inspect "$CONTAINER_NAME" \
|
||||||
|
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
|
||||||
|
UPSTREAM="${CONTAINER_IP}:${PORT}"
|
||||||
|
log "Updating Caddy: ${APP_ID}.${DOMAIN_SUFFIX} → ${UPSTREAM}"
|
||||||
|
|
||||||
|
ROUTE_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"match": [{"host": ["${APP_ID}.${DOMAIN_SUFFIX}"]}],
|
||||||
|
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "${UPSTREAM}"}]}]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
# Upsert the route for this app.
|
||||||
|
ROUTES=$(curl --silent "${CADDY_API}/config/apps/http/servers/hiy/routes" 2>/dev/null || echo "[]")
|
||||||
|
# Remove existing route for the same host, then append the new one.
|
||||||
|
UPDATED=$(echo "$ROUTES" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
routes = json.load(sys.stdin)
|
||||||
|
new_host = '${APP_ID}.${DOMAIN_SUFFIX}'
|
||||||
|
routes = [r for r in routes if new_host not in r.get('match',[{}])[0].get('host',[])]
|
||||||
|
routes.append(json.loads(sys.argv[1]))
|
||||||
|
print(json.dumps(routes))
|
||||||
|
" "$ROUTE_JSON")
|
||||||
|
|
||||||
|
curl --silent --fail "${CADDY_API}/config/apps/http/servers/hiy/routes" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--request PUT \
|
||||||
|
--data "$UPDATED" && log "Caddy updated." \
|
||||||
|
|| log "WARNING: Caddy update failed (app is running; fix routing manually)."
|
||||||
|
else
|
||||||
|
log "Caddy admin API not reachable; skipping route update."
|
||||||
|
log "Container $CONTAINER_NAME is running. Expose manually if needed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 7. Prune old images ───────────────────────────────────────────────────────
|
||||||
|
log "Pruning old images (keeping last 3)…"
|
||||||
|
docker images "hiy/${APP_ID}" --format "{{.ID}}\t{{.CreatedAt}}" \
|
||||||
|
| sort --reverse --key=2 \
|
||||||
|
| tail -n +4 \
|
||||||
|
| awk '{print $1}' \
|
||||||
|
| xargs --no-run-if-empty docker rmi 2>/dev/null || true
|
||||||
|
|
||||||
|
log "=== Build complete: $APP_NAME @ $ACTUAL_SHA ==="
|
||||||
38
infra/Dockerfile.server
Normal file
38
infra/Dockerfile.server
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||||
|
FROM rust:1.77-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
pkg-config libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Cache dependencies separately from source.
|
||||||
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
|
COPY server/Cargo.toml ./server/
|
||||||
|
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs
|
||||||
|
RUN cargo build --release -p hiy-server 2>/dev/null || true
|
||||||
|
RUN rm -f server/src/main.rs
|
||||||
|
|
||||||
|
# Build actual source.
|
||||||
|
COPY server/src ./server/src
|
||||||
|
RUN touch server/src/main.rs && cargo build --release -p hiy-server
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
bash \
|
||||||
|
python3 \
|
||||||
|
# Docker CLI (no daemon — uses host socket)
|
||||||
|
docker.io \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/hiy-server /usr/local/bin/hiy-server
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["hiy-server"]
|
||||||
62
infra/docker-compose.yml
Normal file
62
infra/docker-compose.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# HIY — local development stack
|
||||||
|
# Run with: docker compose up --build
|
||||||
|
#
|
||||||
|
# On a real Pi you would run Caddy as a systemd service; here it runs in Compose
|
||||||
|
# so you can develop without changing the host.
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Control plane ─────────────────────────────────────────────────────────
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/Dockerfile.server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- hiy-data:/data
|
||||||
|
# Mount Docker socket so the server can spawn build containers.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Mount the builder script so edits take effect without rebuilding.
|
||||||
|
- ../builder:/app/builder:ro
|
||||||
|
environment:
|
||||||
|
HIY_DATA_DIR: /data
|
||||||
|
HIY_ADDR: 0.0.0.0:3000
|
||||||
|
HIY_BUILD_SCRIPT: /app/builder/build.sh
|
||||||
|
CADDY_API_URL: http://caddy:2019
|
||||||
|
DOMAIN_SUFFIX: ${DOMAIN_SUFFIX:-localhost}
|
||||||
|
RUST_LOG: hiy_server=debug,tower_http=info
|
||||||
|
depends_on:
|
||||||
|
caddy:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- hiy-net
|
||||||
|
- default
|
||||||
|
|
||||||
|
# ── Reverse proxy ─────────────────────────────────────────────────────────
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "2019:2019" # admin API
|
||||||
|
volumes:
|
||||||
|
- ../proxy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
networks:
|
||||||
|
- hiy-net
|
||||||
|
- default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hiy-net:
|
||||||
|
name: hiy-net
|
||||||
|
# External so deployed app containers can join it.
|
||||||
|
external: false
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hiy-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
29
proxy/Caddyfile
Normal file
29
proxy/Caddyfile
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# HIY — Caddyfile
|
||||||
|
#
|
||||||
|
# Local development: auto-HTTPS is disabled; everything runs on :80.
|
||||||
|
# Production (Pi): remove the `auto_https off` line, set your real email,
|
||||||
|
# and Caddy will obtain Let's Encrypt certificates automatically.
|
||||||
|
#
|
||||||
|
# Wildcard TLS on the Pi (recommended):
|
||||||
|
# tls your@email.com {
|
||||||
|
# dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
# }
|
||||||
|
|
||||||
|
{
|
||||||
|
# Admin API — used by build.sh to update routes dynamically.
|
||||||
|
admin 0.0.0.0:2019
|
||||||
|
|
||||||
|
# Comment this out on the Pi with a real domain.
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: serves the HIY dashboard itself.
|
||||||
|
:80 {
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example of what the build script adds via the Caddy API:
|
||||||
|
#
|
||||||
|
# myapp.yourdomain.com {
|
||||||
|
# reverse_proxy <container-ip>:3000
|
||||||
|
# }
|
||||||
27
server/Cargo.toml
Normal file
27
server/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "hiy-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "hiy-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
async-stream = "0.3"
|
||||||
|
anyhow = "1"
|
||||||
|
futures = "0.3"
|
||||||
169
server/src/builder.rs
Normal file
169
server/src/builder.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{models::Deploy, AppState, DbPool};
|
||||||
|
|
||||||
|
/// Create a deploy record and push its ID onto the build queue.
|
||||||
|
pub async fn enqueue_deploy(
|
||||||
|
state: &AppState,
|
||||||
|
app_id: &str,
|
||||||
|
triggered_by: &str,
|
||||||
|
sha: Option<String>,
|
||||||
|
) -> anyhow::Result<Deploy> {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO deploys (id, app_id, sha, status, log, triggered_by, created_at)
|
||||||
|
VALUES (?, ?, ?, 'queued', '', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(app_id)
|
||||||
|
.bind(&sha)
|
||||||
|
.bind(triggered_by)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state.build_queue.lock().await.push_back(id.clone());
|
||||||
|
|
||||||
|
let deploy = sqlx::query_as::<_, Deploy>("SELECT * FROM deploys WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(deploy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long-running background task — processes one deploy at a time.
|
||||||
|
pub async fn build_worker(state: AppState) {
|
||||||
|
loop {
|
||||||
|
let deploy_id = state.build_queue.lock().await.pop_front();
|
||||||
|
match deploy_id {
|
||||||
|
Some(id) => {
|
||||||
|
if let Err(e) = run_build(&state, &id).await {
|
||||||
|
tracing::error!("Build {} failed: {}", id, e);
|
||||||
|
let _ = set_status(&state.db, &id, "failed").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
||||||
|
let deploy = sqlx::query_as::<_, Deploy>("SELECT * FROM deploys WHERE id = ?")
|
||||||
|
.bind(deploy_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let app = sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps WHERE id = ?")
|
||||||
|
.bind(&deploy.app_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Write env file so the build script can inject it into the container.
|
||||||
|
let env_dir = format!("{}/envs", state.data_dir);
|
||||||
|
std::fs::create_dir_all(&env_dir)?;
|
||||||
|
let env_file = format!("{}/{}.env", env_dir, app.id);
|
||||||
|
|
||||||
|
let env_vars = sqlx::query_as::<_, crate::models::EnvVar>(
|
||||||
|
"SELECT * FROM env_vars WHERE app_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&app.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let env_content: String = env_vars
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{}={}\n", e.key, e.value))
|
||||||
|
.collect();
|
||||||
|
std::fs::write(&env_file, env_content)?;
|
||||||
|
|
||||||
|
// Mark as building.
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE deploys SET status = 'building', started_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(deploy_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let build_script = std::env::var("HIY_BUILD_SCRIPT")
|
||||||
|
.unwrap_or_else(|_| "./builder/build.sh".into());
|
||||||
|
|
||||||
|
let build_dir = format!("{}/builds/{}", state.data_dir, app.id);
|
||||||
|
|
||||||
|
let mut child = Command::new("bash")
|
||||||
|
.arg(&build_script)
|
||||||
|
.env("APP_ID", &app.id)
|
||||||
|
.env("APP_NAME", &app.name)
|
||||||
|
.env("REPO_URL", &app.repo_url)
|
||||||
|
.env("BRANCH", &app.branch)
|
||||||
|
.env("PORT", app.port.to_string())
|
||||||
|
.env("ENV_FILE", &env_file)
|
||||||
|
.env("SHA", deploy.sha.as_deref().unwrap_or(""))
|
||||||
|
.env("BUILD_DIR", &build_dir)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
|
|
||||||
|
// Stream stdout and stderr concurrently into the deploy log.
|
||||||
|
let db1 = state.db.clone();
|
||||||
|
let id1 = deploy_id.to_string();
|
||||||
|
let stdout_task = tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = append_log(&db1, &id1, &format!("{}\n", line)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let db2 = state.db.clone();
|
||||||
|
let id2 = deploy_id.to_string();
|
||||||
|
let stderr_task = tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stderr).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = append_log(&db2, &id2, &format!("{}\n", line)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let exit_status = child.wait().await?;
|
||||||
|
let _ = tokio::join!(stdout_task, stderr_task);
|
||||||
|
|
||||||
|
let final_status = if exit_status.success() { "success" } else { "failed" };
|
||||||
|
let finished = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query("UPDATE deploys SET status = ?, finished_at = ? WHERE id = ?")
|
||||||
|
.bind(final_status)
|
||||||
|
.bind(&finished)
|
||||||
|
.bind(deploy_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("Deploy {} finished: {}", deploy_id, final_status);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_log(db: &DbPool, deploy_id: &str, line: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("UPDATE deploys SET log = log || ? WHERE id = ?")
|
||||||
|
.bind(line)
|
||||||
|
.bind(deploy_id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_status(db: &DbPool, deploy_id: &str, status: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("UPDATE deploys SET status = ? WHERE id = ?")
|
||||||
|
.bind(status)
|
||||||
|
.bind(deploy_id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
59
server/src/db.rs
Normal file
59
server/src/db.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||||
|
|
||||||
|
pub type DbPool = SqlitePool;
|
||||||
|
|
||||||
|
pub async fn connect(data_dir: &str) -> anyhow::Result<DbPool> {
|
||||||
|
let db_path = format!("{}/hiy.db", data_dir);
|
||||||
|
let url = format!("sqlite://{}?mode=rwc", db_path);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&url)
|
||||||
|
.await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
repo_url TEXT NOT NULL,
|
||||||
|
branch TEXT NOT NULL DEFAULT 'main',
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
webhook_secret TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS deploys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
sha TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
log TEXT NOT NULL DEFAULT '',
|
||||||
|
triggered_by TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS env_vars (
|
||||||
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (app_id, key)
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
86
server/src/main.rs
Normal file
86
server/src/main.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use axum::{
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod builder;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
pub use db::DbPool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: DbPool,
|
||||||
|
/// Queue of deploy IDs waiting to be processed.
|
||||||
|
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
||||||
|
pub data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::new(
|
||||||
|
std::env::var("RUST_LOG")
|
||||||
|
.unwrap_or_else(|_| "hiy_server=debug,tower_http=debug".into()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let data_dir = std::env::var("HIY_DATA_DIR").unwrap_or_else(|_| "./data".into());
|
||||||
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
|
|
||||||
|
let db = db::connect(&data_dir).await?;
|
||||||
|
db::migrate(&db).await?;
|
||||||
|
|
||||||
|
let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
build_queue,
|
||||||
|
data_dir,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single background worker — sequential builds to avoid saturating the Pi.
|
||||||
|
let worker_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
builder::build_worker(worker_state).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
// ── Dashboard UI ──────────────────────────────────────────
|
||||||
|
.route("/", get(routes::ui::index))
|
||||||
|
.route("/apps/:id", get(routes::ui::app_detail))
|
||||||
|
// ── Apps API ──────────────────────────────────────────────
|
||||||
|
.route("/api/apps", get(routes::apps::list).post(routes::apps::create))
|
||||||
|
.route("/api/apps/:id", get(routes::apps::get_one)
|
||||||
|
.put(routes::apps::update)
|
||||||
|
.delete(routes::apps::delete))
|
||||||
|
// ── Deploys API ───────────────────────────────────────────
|
||||||
|
.route("/api/apps/:id/deploy", post(routes::deploys::trigger))
|
||||||
|
.route("/api/apps/:id/deploys", get(routes::deploys::list))
|
||||||
|
.route("/api/deploys/:id", get(routes::deploys::get_one))
|
||||||
|
.route("/api/deploys/:id/logs", get(routes::deploys::logs_sse))
|
||||||
|
// ── Env vars API ──────────────────────────────────────────
|
||||||
|
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
||||||
|
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
||||||
|
// ── GitHub Webhook ────────────────────────────────────────
|
||||||
|
.route("/webhook/:app_id", post(routes::webhooks::github))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = std::env::var("HIY_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into());
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
tracing::info!("Listening on http://{}", addr);
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
54
server/src/models.rs
Normal file
54
server/src/models.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct App {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub repo_url: String,
|
||||||
|
pub branch: String,
|
||||||
|
pub port: i64,
|
||||||
|
pub webhook_secret: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateApp {
|
||||||
|
pub name: String,
|
||||||
|
pub repo_url: String,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub port: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateApp {
|
||||||
|
pub repo_url: Option<String>,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub port: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Deploy {
|
||||||
|
pub id: String,
|
||||||
|
pub app_id: String,
|
||||||
|
pub sha: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub log: String,
|
||||||
|
pub triggered_by: String,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct EnvVar {
|
||||||
|
pub app_id: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetEnvVar {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
113
server/src/routes/apps.rs
Normal file
113
server/src/routes/apps.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{App, CreateApp, UpdateApp},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list(State(s): State<AppState>) -> Result<Json<Vec<App>>, StatusCode> {
|
||||||
|
let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| { tracing::error!("list apps: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?;
|
||||||
|
Ok(Json(apps))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Json(payload): Json<CreateApp>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), StatusCode> {
|
||||||
|
// Use the name as the slug/id (must be URL-safe).
|
||||||
|
let id = payload.name.to_lowercase().replace(' ', "-");
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let branch = payload.branch.unwrap_or_else(|| "main".into());
|
||||||
|
let secret = Uuid::new_v4().to_string().replace('-', "");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&payload.name)
|
||||||
|
.bind(&payload.repo_url)
|
||||||
|
.bind(&branch)
|
||||||
|
.bind(payload.port)
|
||||||
|
.bind(&secret)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("create app: {}", e);
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
})?;
|
||||||
|
|
||||||
|
fetch_app(&s, &id).await.map(|a| (StatusCode::CREATED, Json(a)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_one(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<App>, StatusCode> {
|
||||||
|
fetch_app(&s, &id).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(payload): Json<UpdateApp>,
|
||||||
|
) -> Result<Json<App>, StatusCode> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
if let Some(v) = payload.repo_url {
|
||||||
|
sqlx::query("UPDATE apps SET repo_url = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(v).bind(&now).bind(&id)
|
||||||
|
.execute(&s.db).await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = payload.branch {
|
||||||
|
sqlx::query("UPDATE apps SET branch = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(v).bind(&now).bind(&id)
|
||||||
|
.execute(&s.db).await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = payload.port {
|
||||||
|
sqlx::query("UPDATE apps SET port = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(v).bind(&now).bind(&id)
|
||||||
|
.execute(&s.db).await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_app(&s, &id).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_app(s: &AppState, id: &str) -> Result<App, StatusCode> {
|
||||||
|
sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
97
server/src/routes/deploys.rs
Normal file
97
server/src/routes/deploys.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::{convert::Infallible, time::Duration};
|
||||||
|
|
||||||
|
use crate::{builder, models::Deploy, AppState};
|
||||||
|
|
||||||
|
pub async fn trigger(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<(StatusCode, Json<Deploy>), StatusCode> {
|
||||||
|
// Verify app exists.
|
||||||
|
sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps WHERE id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let deploy = builder::enqueue_deploy(&s, &app_id, "manual", None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| { tracing::error!("enqueue: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(deploy)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<Deploy>>, StatusCode> {
|
||||||
|
let deploys = sqlx::query_as::<_, Deploy>(
|
||||||
|
"SELECT * FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 20",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(deploys))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_one(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(deploy_id): Path<String>,
|
||||||
|
) -> Result<Json<Deploy>, StatusCode> {
|
||||||
|
sqlx::query_as::<_, Deploy>("SELECT * FROM deploys WHERE id = ?")
|
||||||
|
.bind(&deploy_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE endpoint: streams build log lines as they arrive, closes when deploy finishes.
|
||||||
|
pub async fn logs_sse(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(deploy_id): Path<String>,
|
||||||
|
) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let db = s.db.clone();
|
||||||
|
let id = deploy_id.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::stream! {
|
||||||
|
let mut sent = 0usize;
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
|
||||||
|
let deploy = sqlx::query_as::<_, Deploy>("SELECT * FROM deploys WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let Some(deploy) = deploy else { break; };
|
||||||
|
|
||||||
|
if deploy.log.len() > sent {
|
||||||
|
let chunk = deploy.log[sent..].to_string();
|
||||||
|
sent = deploy.log.len();
|
||||||
|
yield Ok(Event::default().data(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
if deploy.status == "success" || deploy.status == "failed" {
|
||||||
|
yield Ok(Event::default().event("done").data(deploy.status.clone()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(
|
||||||
|
KeepAlive::new()
|
||||||
|
.interval(Duration::from_secs(15))
|
||||||
|
.text("ping"),
|
||||||
|
)
|
||||||
|
}
|
||||||
55
server/src/routes/envvars.rs
Normal file
55
server/src/routes/envvars.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{EnvVar, SetEnvVar},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<EnvVar>>, StatusCode> {
|
||||||
|
let vars = sqlx::query_as::<_, EnvVar>(
|
||||||
|
"SELECT * FROM env_vars WHERE app_id = ? ORDER BY key",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(vars))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
Json(payload): Json<SetEnvVar>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO env_vars (app_id, key, value) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(app_id, key) DO UPDATE SET value = excluded.value",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.bind(&payload.key)
|
||||||
|
.bind(&payload.value)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path((app_id, key)): Path<(String, String)>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
sqlx::query("DELETE FROM env_vars WHERE app_id = ? AND key = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.bind(&key)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
5
server/src/routes/mod.rs
Normal file
5
server/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod apps;
|
||||||
|
pub mod deploys;
|
||||||
|
pub mod envvars;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod webhooks;
|
||||||
346
server/src/routes/ui.rs
Normal file
346
server/src/routes/ui.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Html,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{App, Deploy, EnvVar},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Shared styles ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSS: &str = r#"
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:32px 24px;max-width:1100px;margin:0 auto}
|
||||||
|
h1{color:#a78bfa;font-size:1.6rem;margin-bottom:4px}
|
||||||
|
h2{color:#818cf8;font-size:1.1rem;margin-bottom:16px}
|
||||||
|
a{color:#818cf8;text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
.card{background:#1e293b;border-radius:10px;padding:24px;margin-bottom:24px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid #0f172a;font-size:0.9rem}
|
||||||
|
th{color:#64748b;font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
tr:last-child td{border-bottom:none}
|
||||||
|
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:0.75rem;font-weight:bold}
|
||||||
|
.badge-success{background:#14532d;color:#4ade80}
|
||||||
|
.badge-failed{background:#450a0a;color:#f87171}
|
||||||
|
.badge-building,.badge-queued{background:#451a03;color:#fb923c}
|
||||||
|
.badge-unknown{background:#1e293b;color:#64748b}
|
||||||
|
button,input[type=submit]{background:#334155;color:#e2e8f0;border:1px solid #475569;padding:5px 14px;
|
||||||
|
border-radius:6px;cursor:pointer;font-family:monospace;font-size:0.9rem}
|
||||||
|
button:hover{background:#475569}
|
||||||
|
button.danger{border-color:#7f1d1d;color:#fca5a5}
|
||||||
|
button.danger:hover{background:#7f1d1d}
|
||||||
|
button.primary{background:#4c1d95;border-color:#7c3aed;color:#ddd6fe}
|
||||||
|
button.primary:hover{background:#5b21b6}
|
||||||
|
input[type=text],input[type=password],input[type=number]{
|
||||||
|
background:#0f172a;color:#e2e8f0;border:1px solid #334155;padding:6px 10px;
|
||||||
|
border-radius:6px;font-family:monospace;font-size:0.9rem;width:100%}
|
||||||
|
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||||
|
.row input{flex:1;min-width:120px}
|
||||||
|
label{display:block;color:#64748b;font-size:0.78rem;margin-bottom:4px}
|
||||||
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||||
|
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
|
||||||
|
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.85rem}
|
||||||
|
pre{background:#0f172a;padding:16px;border-radius:8px;white-space:pre-wrap;
|
||||||
|
word-break:break-all;font-size:0.82rem;max-height:420px;overflow-y:auto;line-height:1.5}
|
||||||
|
.muted{color:#64748b;font-size:0.85rem}
|
||||||
|
nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}
|
||||||
|
.subtitle{color:#64748b;font-size:0.85rem;margin-bottom:20px}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn badge(status: &str) -> String {
|
||||||
|
let cls = match status {
|
||||||
|
"success" => "badge-success",
|
||||||
|
"failed" => "badge-failed",
|
||||||
|
"building"|"queued" => "badge-building",
|
||||||
|
_ => "badge-unknown",
|
||||||
|
};
|
||||||
|
format!(r#"<span class="badge {cls}">{status}</span>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(title: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!DOCTYPE html><html lang="en"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{title} — HostItYourself</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head><body>{body}</body></html>"#,
|
||||||
|
title = title,
|
||||||
|
CSS = CSS,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode> {
|
||||||
|
let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut rows = String::new();
|
||||||
|
for app in &apps {
|
||||||
|
let latest = sqlx::query_as::<_, Deploy>(
|
||||||
|
"SELECT * FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&app.id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let status = latest.as_ref().map(|d| d.status.as_str()).unwrap_or("–");
|
||||||
|
|
||||||
|
rows.push_str(&format!(
|
||||||
|
r#"<tr>
|
||||||
|
<td><a href="/apps/{id}">{name}</a></td>
|
||||||
|
<td class="muted">{repo}</td>
|
||||||
|
<td><code>{branch}</code></td>
|
||||||
|
<td>{badge}</td>
|
||||||
|
<td>
|
||||||
|
<button class="primary" onclick="deploy('{id}')">Deploy</button>
|
||||||
|
<button class="danger" onclick="del('{id}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>"#,
|
||||||
|
id = app.id,
|
||||||
|
name = app.name,
|
||||||
|
repo = app.repo_url,
|
||||||
|
branch = app.branch,
|
||||||
|
badge = badge(status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
r#"<nav><h1>☕ HostItYourself</h1><span class="muted">{n} app(s)</span></nav>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Add App</h2>
|
||||||
|
<div class="grid2" style="margin-bottom:12px">
|
||||||
|
<div><label>Name (slug)</label><input id="f-name" type="text" placeholder="my-api"></div>
|
||||||
|
<div><label>GitHub Repo URL</label><input id="f-repo" type="text" placeholder="https://github.com/you/repo.git"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-bottom:16px">
|
||||||
|
<div><label>Branch</label><input id="f-branch" type="text" value="main"></div>
|
||||||
|
<div><label>Container Port</label><input id="f-port" type="number" placeholder="3000"></div>
|
||||||
|
</div>
|
||||||
|
<button class="primary" onclick="createApp()">Create App</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Apps</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Repo</th><th>Branch</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="apps-body">{rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function createApp() {{
|
||||||
|
const data = {{
|
||||||
|
name: document.getElementById('f-name').value.trim(),
|
||||||
|
repo_url: document.getElementById('f-repo').value.trim(),
|
||||||
|
branch: document.getElementById('f-branch').value.trim() || 'main',
|
||||||
|
port: parseInt(document.getElementById('f-port').value),
|
||||||
|
}};
|
||||||
|
if (!data.name || !data.repo_url || !data.port) {{ alert('Fill in all fields'); return; }}
|
||||||
|
const r = await fetch('/api/apps', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/json'}},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}});
|
||||||
|
if (r.ok) window.location.reload();
|
||||||
|
else alert('Error: ' + await r.text());
|
||||||
|
}}
|
||||||
|
async function deploy(id) {{
|
||||||
|
if (!confirm('Deploy ' + id + ' now?')) return;
|
||||||
|
const r = await fetch('/api/apps/' + id + '/deploy', {{method: 'POST'}});
|
||||||
|
if (r.ok) window.location.href = '/apps/' + id;
|
||||||
|
else alert('Error: ' + await r.text());
|
||||||
|
}}
|
||||||
|
async function del(id) {{
|
||||||
|
if (!confirm('Delete app "' + id + '"? This cannot be undone.')) return;
|
||||||
|
await fetch('/api/apps/' + id, {{method: 'DELETE'}});
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
// Auto-refresh status every 5 s.
|
||||||
|
setInterval(async () => {{
|
||||||
|
const r = await fetch('/api/apps');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const apps = await r.json();
|
||||||
|
// Only update the status badges to avoid disrupting interactions.
|
||||||
|
apps.forEach(app => {{
|
||||||
|
const row = document.querySelector(`tr[data-id="${{app.id}}"]`);
|
||||||
|
if (row) row.querySelector('.badge').textContent = app.status ?? '–';
|
||||||
|
}});
|
||||||
|
}}, 5000);
|
||||||
|
</script>"#,
|
||||||
|
n = apps.len(),
|
||||||
|
rows = rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Html(page("Dashboard", &body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App detail ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn app_detail(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<Html<String>, StatusCode> {
|
||||||
|
let app = sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let deploys = sqlx::query_as::<_, Deploy>(
|
||||||
|
"SELECT * FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 15",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let env_vars = sqlx::query_as::<_, EnvVar>(
|
||||||
|
"SELECT * FROM env_vars WHERE app_id = ? ORDER BY key",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut deploy_rows = String::new();
|
||||||
|
for d in &deploys {
|
||||||
|
let sha_short = d.sha.as_deref()
|
||||||
|
.and_then(|s| s.get(..7))
|
||||||
|
.unwrap_or("–");
|
||||||
|
let time = d.created_at.get(..19).unwrap_or(&d.created_at);
|
||||||
|
deploy_rows.push_str(&format!(
|
||||||
|
r#"<tr>
|
||||||
|
<td><code>{sha}</code></td>
|
||||||
|
<td>{badge}</td>
|
||||||
|
<td class="muted">{by}</td>
|
||||||
|
<td class="muted">{time}</td>
|
||||||
|
<td><button onclick="showLog('{id}')">Logs</button></td>
|
||||||
|
</tr>"#,
|
||||||
|
sha = sha_short,
|
||||||
|
badge = badge(&d.status),
|
||||||
|
by = d.triggered_by,
|
||||||
|
time = time,
|
||||||
|
id = d.id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env_rows = String::new();
|
||||||
|
for e in &env_vars {
|
||||||
|
env_rows.push_str(&format!(
|
||||||
|
r#"<tr>
|
||||||
|
<td><code>{key}</code></td>
|
||||||
|
<td class="muted">••••••••</td>
|
||||||
|
<td><button class="danger" onclick="removeEnv('{key}')">Remove</button></td>
|
||||||
|
</tr>"#,
|
||||||
|
key = e.key,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
r#"<nav>
|
||||||
|
<h1><a href="/" style="color:inherit">HIY</a> / {name}</h1>
|
||||||
|
<button class="primary" onclick="deploy()">Deploy Now</button>
|
||||||
|
</nav>
|
||||||
|
<p class="subtitle">
|
||||||
|
<a href="{repo}" target="_blank">{repo}</a>
|
||||||
|
· branch <code>{branch}</code>
|
||||||
|
· port <code>{port}</code>
|
||||||
|
· <a href="http://{name}.{host}" target="_blank">{name}.{host}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Deploy History</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>SHA</th><th>Status</th><th>Triggered By</th><th>Time</th><th></th></tr></thead>
|
||||||
|
<tbody>{deploy_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="log-panel" style="display:none;margin-top:16px">
|
||||||
|
<h2 style="margin-bottom:8px">Build Log</h2>
|
||||||
|
<pre id="log-out"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Environment Variables</h2>
|
||||||
|
<div class="row" style="margin-bottom:16px">
|
||||||
|
<div style="flex:1"><label>Key</label><input id="ev-key" type="text" placeholder="DATABASE_URL"></div>
|
||||||
|
<div style="flex:2"><label>Value</label><input id="ev-val" type="password" placeholder="secret"></div>
|
||||||
|
<div style="align-self:flex-end"><button class="primary" onclick="setEnv()">Set</button></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
|
||||||
|
<tbody id="env-body">{env_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>GitHub Webhook</h2>
|
||||||
|
<p style="margin-bottom:8px">Configure GitHub → Settings → Webhooks → Add webhook:</p>
|
||||||
|
<table>
|
||||||
|
<tr><td style="width:140px">Payload URL</td><td><code>http(s)://YOUR_DOMAIN/webhook/{app_id}</code></td></tr>
|
||||||
|
<tr><td>Content type</td><td><code>application/json</code></td></tr>
|
||||||
|
<tr><td>Secret</td><td><code>{secret}</code></td></tr>
|
||||||
|
<tr><td>Events</td><td>Just the <em>push</em> event</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const APP_ID = '{app_id}';
|
||||||
|
async function deploy() {{
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID + '/deploy', {{method:'POST'}});
|
||||||
|
if (r.ok) {{ const d = await r.json(); showLog(d.id); }}
|
||||||
|
else alert('Deploy failed: ' + await r.text());
|
||||||
|
}}
|
||||||
|
function showLog(deployId) {{
|
||||||
|
const panel = document.getElementById('log-panel');
|
||||||
|
const out = document.getElementById('log-out');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
out.textContent = '';
|
||||||
|
panel.scrollIntoView({{behavior:'smooth'}});
|
||||||
|
const es = new EventSource('/api/deploys/' + deployId + '/logs');
|
||||||
|
es.onmessage = e => {{ out.textContent += e.data; out.scrollTop = out.scrollHeight; }};
|
||||||
|
es.addEventListener('done', () => {{ es.close(); window.location.reload(); }});
|
||||||
|
}}
|
||||||
|
async function setEnv() {{
|
||||||
|
const key = document.getElementById('ev-key').value.trim();
|
||||||
|
const val = document.getElementById('ev-val').value;
|
||||||
|
if (!key) {{ alert('Key required'); return; }}
|
||||||
|
await fetch('/api/apps/' + APP_ID + '/env', {{
|
||||||
|
method:'POST',
|
||||||
|
headers:{{'Content-Type':'application/json'}},
|
||||||
|
body: JSON.stringify({{key, value: val}}),
|
||||||
|
}});
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
async function removeEnv(key) {{
|
||||||
|
if (!confirm('Remove ' + key + '?')) return;
|
||||||
|
await fetch('/api/apps/' + APP_ID + '/env/' + key, {{method:'DELETE'}});
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
</script>"#,
|
||||||
|
name = app.name,
|
||||||
|
repo = app.repo_url,
|
||||||
|
branch = app.branch,
|
||||||
|
port = app.port,
|
||||||
|
host = host,
|
||||||
|
app_id = app.id,
|
||||||
|
secret = app.webhook_secret,
|
||||||
|
deploy_rows = deploy_rows,
|
||||||
|
env_rows = env_rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Html(page(&app.name, &body)))
|
||||||
|
}
|
||||||
77
server/src/routes/webhooks.rs
Normal file
77
server/src/routes/webhooks.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
use crate::{builder, AppState};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
pub async fn github(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> StatusCode {
|
||||||
|
let app = match sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps WHERE id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(a)) => a,
|
||||||
|
_ => return StatusCode::NOT_FOUND,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify HMAC-SHA256 signature.
|
||||||
|
let sig = headers
|
||||||
|
.get("x-hub-signature-256")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if !verify_sig(&app.webhook_secret, &body, sig) {
|
||||||
|
tracing::warn!("Bad webhook signature for app {}", app_id);
|
||||||
|
return StatusCode::UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only deploy on pushes to the configured branch.
|
||||||
|
let payload: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return StatusCode::BAD_REQUEST,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pushed_ref = payload["ref"].as_str().unwrap_or("");
|
||||||
|
let expected_ref = format!("refs/heads/{}", app.branch);
|
||||||
|
if pushed_ref != expected_ref {
|
||||||
|
return StatusCode::OK; // different branch — silently ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
let sha = payload["after"].as_str().map(String::from);
|
||||||
|
|
||||||
|
if let Err(e) = builder::enqueue_deploy(&s, &app_id, "webhook", sha).await {
|
||||||
|
tracing::error!("Enqueue deploy for {}: {}", app_id, e);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time HMAC-SHA256 signature check.
|
||||||
|
fn verify_sig(secret: &str, body: &[u8], sig_header: &str) -> bool {
|
||||||
|
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
mac.update(body);
|
||||||
|
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||||
|
|
||||||
|
// Constant-time compare to avoid timing side-channels.
|
||||||
|
expected.len() == sig_header.len()
|
||||||
|
&& expected
|
||||||
|
.bytes()
|
||||||
|
.zip(sig_header.bytes())
|
||||||
|
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
1
target/.rustc_info.json
Normal file
1
target/.rustc_info.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc_fingerprint":6041176463401798617,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: x86_64-unknown-linux-gnu\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}}
|
||||||
3
target/CACHEDIR.TAG
Normal file
3
target/CACHEDIR.TAG
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Signature: 8a477f597d28d172789f06886806bc55
|
||||||
|
# This file is a cache directory tag created by cargo.
|
||||||
|
# For information about cache directory tags see https://bford.info/cachedir/
|
||||||
0
target/debug/.cargo-lock
Normal file
0
target/debug/.cargo-lock
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
9b70253857e5783c
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"getrandom\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":17883862002600103897,"profile":2225463790103693989,"path":3620143980536268293,"deps":[[5398981501050481332,"version_check",false,9154263052637098667]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-0288aebac72acd5f/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
27ef6d0db6c48582
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[966925859616469517,"build_script_build",false,4357484802247848091]],"local":[{"RerunIfChanged":{"output":"debug/build/ahash-92a79066971adfc7/output","paths":["build.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/ahash-c793cc25bba4f174/dep-lib-ahash
Normal file
BIN
target/debug/.fingerprint/ahash-c793cc25bba4f174/dep-lib-ahash
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
77bdf651561905e6
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"getrandom\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":2241668132362809309,"path":10410372153339844996,"deps":[[966925859616469517,"build_script_build",false,9405139683021549351],[5855319743879205494,"once_cell",false,13964674197140661538],[7667230146095136825,"cfg_if",false,9793909483107332223],[17945577413884132710,"zerocopy",false,17926217989441580788],[18408407127522236545,"getrandom",false,63727027152203946]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-c793cc25bba4f174/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/ahash-c9fa53979346d5a3/dep-lib-ahash
Normal file
BIN
target/debug/.fingerprint/ahash-c9fa53979346d5a3/dep-lib-ahash
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
02c52ed3157d207e
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"getrandom\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":2225463790103693989,"path":10410372153339844996,"deps":[[966925859616469517,"build_script_build",false,9405139683021549351],[5855319743879205494,"once_cell",false,12646562715952664375],[7667230146095136825,"cfg_if",false,12371253438799191180],[17945577413884132710,"zerocopy",false,8657096612666332101],[18408407127522236545,"getrandom",false,12788882145077345421]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-c9fa53979346d5a3/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
597caafed7abb8fd
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":187265481308423917,"path":10591411839453927008,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-0b2fc4cd8c198275/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
1af6761fa2595ea4
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":8277339565235241299,"path":10591411839453927008,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-eb43713ec41d5d09/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
b95ea0c4dc7cf238
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[12478428894219133322,"build_script_build",false,17643320991977812546]],"local":[{"RerunIfChanged":{"output":"debug/build/anyhow-1a588860b974a585/output","paths":["src/nightly.rs"]}},{"RerunIfEnvChanged":{"var":"RUSTC_BOOTSTRAP","val":null}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/anyhow-727625e04efe81e0/dep-lib-anyhow
Normal file
BIN
target/debug/.fingerprint/anyhow-727625e04efe81e0/dep-lib-anyhow
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
e66c7951da585706
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"std\"]","declared_features":"[\"backtrace\", \"default\", \"std\"]","target":1563897884725121975,"profile":2241668132362809309,"path":14674967243997870647,"deps":[[12478428894219133322,"build_script_build",false,4103479498121436857]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anyhow-727625e04efe81e0/dep-lib-anyhow","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
42b207c1f6aad9f4
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"std\"]","declared_features":"[\"backtrace\", \"default\", \"std\"]","target":5408242616063297496,"profile":2225463790103693989,"path":12642053716341817010,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anyhow-ca06f8ac4a2f2c3e/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
df0f52d54e563112
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[]","declared_features":"[]","target":7636188372161476255,"profile":2241668132362809309,"path":10307940874214782619,"deps":[[302948626015856208,"futures_core",false,10057092378592569665],[2251399859588827949,"pin_project_lite",false,10351643127767646692],[7410208549481828251,"async_stream_impl",false,4303550777981220064]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/async-stream-ad08dc04a09d00d7/dep-lib-async_stream","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
e0346da2a148b93b
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[]","declared_features":"[]","target":1942159639416563378,"profile":2225463790103693989,"path":11448995682250134267,"deps":[[4289358735036141001,"proc_macro2",false,9504555881090715367],[10420560437213941093,"syn",false,2016223579458125561],[13111758008314797071,"quote",false,4352708279638886718]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/async-stream-impl-4229295cda31516e/dep-lib-async_stream_impl","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
d31cad7f131c84d3
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[]","declared_features":"[]","target":5116616278641129243,"profile":2225463790103693989,"path":6732261253809905678,"deps":[[4289358735036141001,"proc_macro2",false,9504555881090715367],[10420560437213941093,"syn",false,2016223579458125561],[13111758008314797071,"quote",false,4352708279638886718]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/async-trait-286bd5d64f485c52/dep-lib-async_trait","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/atoi-c26343944b633cf5/dep-lib-atoi
Normal file
BIN
target/debug/.fingerprint/atoi-c26343944b633cf5/dep-lib-atoi
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
1
target/debug/.fingerprint/atoi-c26343944b633cf5/lib-atoi
Normal file
1
target/debug/.fingerprint/atoi-c26343944b633cf5/lib-atoi
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
d2e9bb16ff80a510
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":2515742790907851906,"profile":2241668132362809309,"path":891084179621732787,"deps":[[5157631553186200874,"num_traits",false,6553935435313070095]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atoi-c26343944b633cf5/dep-lib-atoi","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/atoi-fafb24fcd77939eb/dep-lib-atoi
Normal file
BIN
target/debug/.fingerprint/atoi-fafb24fcd77939eb/dep-lib-atoi
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
1
target/debug/.fingerprint/atoi-fafb24fcd77939eb/lib-atoi
Normal file
1
target/debug/.fingerprint/atoi-fafb24fcd77939eb/lib-atoi
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
684b2beae0c9fc3d
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":2515742790907851906,"profile":2225463790103693989,"path":891084179621732787,"deps":[[5157631553186200874,"num_traits",false,1818885337433464208]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atoi-fafb24fcd77939eb/dep-lib-atoi","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
5f23e055fe13a975
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2241668132362809309,"path":14374989505947797619,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-56f890cc81298e09/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
d6e623f8effe0396
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":14078221836786394098,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/autocfg-51e16ac8c541547c/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/axum-5235307ac8fa651c/dep-lib-axum
Normal file
BIN
target/debug/.fingerprint/axum-5235307ac8fa651c/dep-lib-axum
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
1
target/debug/.fingerprint/axum-5235307ac8fa651c/lib-axum
Normal file
1
target/debug/.fingerprint/axum-5235307ac8fa651c/lib-axum
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ce726fc9a8b4ff7b
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\", \"form\", \"http1\", \"json\", \"macros\", \"matched-path\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\"]","declared_features":"[\"__private_docs\", \"default\", \"form\", \"http1\", \"http2\", \"json\", \"macros\", \"matched-path\", \"multipart\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\", \"ws\"]","target":13920321295547257648,"profile":2241668132362809309,"path":2716385866137931980,"deps":[[784494742817713399,"tower_service",false,10939210287113132397],[1363051979936526615,"memchr",false,17497015279712524296],[2251399859588827949,"pin_project_lite",false,10351643127767646692],[2517136641825875337,"sync_wrapper",false,6689044014277853247],[2620434475832828286,"http",false,8265322711328205183],[3632162862999675140,"tower",false,2818410078749423691],[3870702314125662939,"bytes",false,10594806789398570289],[4160778395972110362,"hyper",false,16341665274649821819],[4359148418957042248,"axum_core",false,4648817264845667737],[5898568623609459682,"futures_util",false,4987711780844382854],[6803352382179706244,"percent_encoding",false,3974473224592303547],[7712452662827335977,"tower_layer",false,5334542338229868470],[7940089053034940860,"axum_macros",false,11529454526274644560],[9678799920983747518,"matchit",false,12181415366665986895],[9938278000850417404,"itoa",false,13168959672838141200],[10229185211513642314,"mime",false,15240299046776187353],[11976082518617474977,"hyper_util",false,10664244267239372553],[13298363700532491723,"tokio",false,10003856421399296503],[13548984313718623784,"serde",false,7258488454141222367],[13795362694956882968,"serde_json",false,5098141450793485],[14084095096285906100,"http_body",false,10917613269447607183],[14156967978702956262,"rustversion",false,11075716685802352396],[14757622794040968908,"tracing",false,16698440308770492251],[14814583949208169760,"serde_path_to_error",false,15555086987690551478],[16542808166767769916,"serde_urlencoded",false,14830422893645078870],[16611674984963787466,"async_trait",false,15241337909000608979],[16900715236047033623,"http_body_util",false,6591719642866526944]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-5235307ac8fa651c/dep-lib-axum","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
993dd400acea8340
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"tracing\"]","declared_features":"[\"__private_docs\", \"tracing\"]","target":2565713999752801252,"profile":2241668132362809309,"path":5395799406021694165,"deps":[[784494742817713399,"tower_service",false,10939210287113132397],[2251399859588827949,"pin_project_lite",false,10351643127767646692],[2517136641825875337,"sync_wrapper",false,6689044014277853247],[2620434475832828286,"http",false,8265322711328205183],[3870702314125662939,"bytes",false,10594806789398570289],[5898568623609459682,"futures_util",false,4987711780844382854],[7712452662827335977,"tower_layer",false,5334542338229868470],[10229185211513642314,"mime",false,15240299046776187353],[14084095096285906100,"http_body",false,10917613269447607183],[14156967978702956262,"rustversion",false,11075716685802352396],[14757622794040968908,"tracing",false,16698440308770492251],[16611674984963787466,"async_trait",false,15241337909000608979],[16900715236047033623,"http_body_util",false,6591719642866526944]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-core-8bc270df38c3f215/dep-lib-axum_core","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
50aa9e54ced900a0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"default\"]","declared_features":"[\"__private\", \"default\"]","target":7759748055708476646,"profile":2225463790103693989,"path":8207696234792357369,"deps":[[4289358735036141001,"proc_macro2",false,9504555881090715367],[10420560437213941093,"syn",false,2016223579458125561],[13111758008314797071,"quote",false,4352708279638886718]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-macros-fa8c8c8105a7a6c6/dep-lib-axum_macros","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/base64-68f85d52946c6335/dep-lib-base64
Normal file
BIN
target/debug/.fingerprint/base64-68f85d52946c6335/dep-lib-base64
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
172ee49ece67f755
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"rustc":1100337564441796057,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2241668132362809309,"path":10274234490047668973,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/base64-68f85d52946c6335/dep-lib-base64","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/base64-698784859d74d1d7/dep-lib-base64
Normal file
BIN
target/debug/.fingerprint/base64-698784859d74d1d7/dep-lib-base64
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
This file has an mtime of when this was started.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
9bb6935d770d1203
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue