Load content from Gitea directly, drop the local clone; add deploy workflow
Deploy / deploy (push) Failing after 16s

load_questions_from_gitea (content.rs) fetches question YAML straight
from the questions repo's public Gitea contents API at startup instead
of scanning a local directory - one fewer moving part in production
(no git clone to keep in sync, no separate questions-repo deploy
workflow). Still just an in-memory startup load, same as before -
served from RAM for every request, no per-request network call.
Verified against the real repo (all 5 questions fetch correctly).

CONTENT_DIR is replaced by CONTENT_REPO/CONTENT_BRANCH, defaulting to
the real questions repo so local dev needs no env override.

Also adds .gitea/workflows/deploy.yml: builds with cargo-leptos,
ships the release under /srv/app/uhhm-portal (the generic app@.service
deploy layout), writes /etc/app/uhhm-portal.env from this repo's own
Actions Variables/Secrets, restarts the service, and drops this app's
Caddy routing snippet into services.d/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-31 06:10:06 +02:00
co-authored by Claude Sonnet 5
parent aa1a7fa572
commit 286fdbf67f
3 changed files with 133 additions and 18 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: bare
env:
# The bare runner's own systemd service intentionally has a minimal
# PATH/HOME (no rustup default toolchain in reach) - point it at the
# shared toolchain install directly rather than assuming an ambient
# dev shell environment.
CARGO_HOME: /var/local/cargo
RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo leptos build --release
- name: Ship release
run: |
set -euo pipefail
rel="/srv/app/uhhm-portal/releases/${{ github.sha }}"
mkdir -p "$rel"
cp target/release/portal "$rel/uhhm-portal"
cp -r target/site "$rel/site"
ln -sfn "$rel" /srv/app/uhhm-portal/current
# Sourced from this repo's own Settings -> Actions Variables/Secrets,
# not typed onto the host by hand - see the infrastructure repo's
# deploy-runner plan for the exact names/values to configure once.
- name: Write service env
run: |
cat > /etc/app/uhhm-portal.env <<EOF
NATS_URL=${{ secrets.PORTAL_NATS_URL }}
KANIDM_URL=${{ vars.PORTAL_KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.PORTAL_OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.PORTAL_OAUTH2_CLIENT_SECRET }}
PUBLIC_URL=${{ vars.PORTAL_PUBLIC_URL }}
COOKIE_SECURE=true
CONTENT_REPO=https://project.uhhm.no/uhhm/questions
CONTENT_BRANCH=main
SITE_NAME=${{ vars.PORTAL_SITE_NAME }}
LEPTOS_SITE_ADDR=0.0.0.0:3010
EOF
- name: Restart service
run: sudo systemctl restart app@uhhm-portal.service
- name: Update Caddy routing
run: |
cat > /etc/caddy/services.d/uhhm-portal.caddy <<'EOF'
www.{$DOMAIN}, {$DOMAIN} {
reverse_proxy host.docker.internal:3010
log { output file /var/log/caddy/www.log }
}
EOF
sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
+66 -14
View File
@@ -143,25 +143,77 @@ impl Requirement {
} }
} }
/// Reads every `*.yaml` file in `dir` as a `Question`, keyed by its own /// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a
/// `id`. Runs once at startup; no hot-reload yet - restart the process /// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// (or add a watcher later) to pick up content changes. /// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the
/// Gitea host, owner and repo name are all read from it. Runs once at
/// startup, over Gitea's public contents API (no auth - the content
/// repo is public); no hot-reload yet - restart the process (or add
/// polling later) to pick up content changes.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub fn load_questions( pub async fn load_questions_from_gitea(
dir: &std::path::Path, repo_url: &str,
branch: &str,
subdir: &str,
) -> anyhow::Result<std::collections::HashMap<String, Question>> { ) -> anyhow::Result<std::collections::HashMap<String, Question>> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
let api_base = format!(
"{}://{}",
parsed.scheme(),
parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("no host in content repo url {repo_url}"))?
);
let mut segments = parsed
.path_segments()
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
let owner = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))?;
let repo = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))?;
let client = openidconnect::reqwest::Client::new();
let list_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{subdir}?ref={branch}");
let listing = client
.get(&list_url)
.send()
.await
.map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))?
.error_for_status()
.map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))?
.text()
.await
.map_err(|e| anyhow::anyhow!("reading directory listing from {list_url}: {e}"))?;
let entries: Vec<serde_json::Value> = serde_json::from_str(&listing)
.map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?;
let mut out = std::collections::HashMap::new(); let mut out = std::collections::HashMap::new();
for entry in std::fs::read_dir(dir) for entry in entries {
.map_err(|e| anyhow::anyhow!("reading content dir {}: {e}", dir.display()))? let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
{ if !name.ends_with(".yaml") {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue; continue;
} }
let raw = std::fs::read_to_string(&path)?; let download_url = entry
let question: Question = serde_yaml::from_str(&raw) .get("download_url")
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?; .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("no download_url for {name}"))?;
let raw = client
.get(download_url)
.send()
.await
.map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))?
.error_for_status()
.map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))?
.text()
.await
.map_err(|e| anyhow::anyhow!("reading {name}: {e}"))?;
let question: Question =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {name}: {e}"))?;
out.insert(question.id.clone(), question); out.insert(question.id.clone(), question);
} }
Ok(out) Ok(out)
+5 -4
View File
@@ -25,10 +25,11 @@ async fn main() -> anyhow::Result<()> {
let addr = leptos_options.site_addr; let addr = leptos_options.site_addr;
let routes = generate_route_list(App); let routes = generate_route_list(App);
let content_dir = std::env::var("CONTENT_DIR") let content_repo = std::env::var("CONTENT_REPO")
.unwrap_or_else(|_| "../portal-content/questions".to_string()); .unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string());
let questions = content::load_questions(std::path::Path::new(&content_dir))?; let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
tracing::info!(count = questions.len(), dir = %content_dir, "loaded content"); let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let nats_url = let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());