diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..843e0e9 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 < /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 diff --git a/src/content.rs b/src/content.rs index 212fa17..5b3d1de 100644 --- a/src/content.rs +++ b/src/content.rs @@ -143,25 +143,77 @@ impl Requirement { } } -/// Reads every `*.yaml` file in `dir` as a `Question`, keyed by its own -/// `id`. Runs once at startup; no hot-reload yet - restart the process -/// (or add a watcher later) to pick up content changes. +/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a +/// `Question`, keyed by its own `id`. `repo_url` is the repo's normal +/// 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")] -pub fn load_questions( - dir: &std::path::Path, +pub async fn load_questions_from_gitea( + repo_url: &str, + branch: &str, + subdir: &str, ) -> anyhow::Result> { + 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::from_str(&listing) + .map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?; + let mut out = std::collections::HashMap::new(); - for entry in std::fs::read_dir(dir) - .map_err(|e| anyhow::anyhow!("reading content dir {}: {e}", dir.display()))? - { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + for entry in entries { + let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if !name.ends_with(".yaml") { continue; } - let raw = std::fs::read_to_string(&path)?; - let question: Question = serde_yaml::from_str(&raw) - .map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?; + let download_url = entry + .get("download_url") + .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); } Ok(out) diff --git a/src/main.rs b/src/main.rs index 8491bb0..f413699 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,10 +25,11 @@ async fn main() -> anyhow::Result<()> { let addr = leptos_options.site_addr; let routes = generate_route_list(App); - let content_dir = std::env::var("CONTENT_DIR") - .unwrap_or_else(|_| "../portal-content/questions".to_string()); - let questions = content::load_questions(std::path::Path::new(&content_dir))?; - tracing::info!(count = questions.len(), dir = %content_dir, "loaded content"); + let content_repo = std::env::var("CONTENT_REPO") + .unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string()); + let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); + 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 = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());