AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

AkurAI-Build / src/server/releases_api.rs

Latest change b9e3df61e6ca9f1a0e193b69ba7657b9ec4d3cc9 - OLI-273: tags and releases with attached artifacts by Óli

//! Tags and releases routes and page (OLI-273).

use axum::{
    Json,
    body::Body,
    extract::{Path, State},
    http::{HeaderMap, HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Redirect, Response},
};
use minijinja::context;
use serde::Deserialize;
use tokio_util::io::ReaderStream;

use super::access_api::{identify, role_on};
use super::{
    ApiError, AppState, Envelope, authorize, encode_code_segment, ok, repository_view, unix_time,
    valid_session,
};
use crate::access::Role;
use crate::releases::{self, Asset, NewRelease, Release, Tag};

fn require(
    headers: &HeaderMap,
    state: &AppState,
    repository: &str,
    role: Role,
) -> Result<String, ApiError> {
    let identity = identify(headers, state)?;
    match role_on(&identity, state, repository)? {
        Some(held) if held.allows(role) => Ok(identity.subject()),
        _ => Err(ApiError::forbidden(format!(
            "{} access to {repository} is required",
            role.as_str()
        ))),
    }
}

fn assets_root(state: &AppState) -> std::path::PathBuf {
    state.hosted_root.join("release-assets")
}

async fn tags_of(state: &AppState, repository: &str) -> Result<Vec<Tag>, ApiError> {
    let repo = state
        .database
        .repository(repository)
        .map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))?;
    let tree = state.tree.clone();
    let (name, url) = (repo.name.clone(), repo.url.clone());
    state
        .blocking_git
        .run(move || {
            let mirror = tree.sync_mirror(&name, &url).map_err(ApiError::internal)?;
            releases::tags(&mirror).map_err(ApiError::internal)
        })
        .await
}

pub(super) async fn list_tags(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<Tag>>>, ApiError> {
    authorize(&headers, &state)?;
    Ok(ok(tags_of(&state, &repository).await?))
}

pub(super) async fn list_releases(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<Release>>>, ApiError> {
    authorize(&headers, &state)?;
    let database = state.database.clone();
    let list = tokio::task::spawn_blocking(move || releases::list(&database, &repository))
        .await
        .map_err(ApiError::internal)?
        .map_err(ApiError::bad)?;
    Ok(ok(list))
}

#[derive(Deserialize)]
pub(super) struct CreateRelease {
    tag: String,
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    notes: String,
}

pub(super) async fn create_release(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(request): Json<CreateRelease>,
) -> Result<(StatusCode, Json<Envelope<Release>>), ApiError> {
    let author = require(&headers, &state, &repository, Role::Write)?;
    releases::validate_tag(&request.tag).map_err(ApiError::bad)?;
    let tags = tags_of(&state, &repository).await?;
    if !tags.iter().any(|t| t.name == request.tag) {
        return Err(ApiError::bad(format!(
            "tag {} does not exist in {repository}; push the tag first",
            request.tag
        )));
    }
    let database = state.database.clone();
    let created = tokio::task::spawn_blocking(move || {
        releases::create(
            &database,
            &repository,
            &NewRelease {
                tag: &request.tag,
                name: request.name.as_deref(),
                notes: &request.notes,
                author: &author,
            },
        )
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(|e| {
        if e.to_string().contains("already exists") {
            ApiError::conflict(e.to_string())
        } else {
            ApiError::bad(e.to_string())
        }
    })?;
    Ok((StatusCode::CREATED, ok(created)))
}

pub(super) async fn get_release(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, tag)): Path<(String, String)>,
) -> Result<Json<Envelope<Release>>, ApiError> {
    authorize(&headers, &state)?;
    let database = state.database.clone();
    let release = tokio::task::spawn_blocking(move || releases::get(&database, &repository, &tag))
        .await
        .map_err(ApiError::internal)?
        .map_err(ApiError::bad)?
        .ok_or_else(|| ApiError::not_found("release not found"))?;
    Ok(ok(release))
}

pub(super) async fn delete_release(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, tag)): Path<(String, String)>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    require(&headers, &state, &repository, Role::Admin)?;
    let database = state.database.clone();
    let removed =
        tokio::task::spawn_blocking(move || releases::remove(&database, &repository, &tag))
            .await
            .map_err(ApiError::internal)?
            .map_err(ApiError::bad)?;
    match removed {
        Some(paths) => {
            for path in paths {
                let _ = tokio::fs::remove_file(path).await;
            }
            Ok(ok(true))
        }
        None => Err(ApiError::not_found("release not found")),
    }
}

#[derive(Deserialize)]
pub(super) struct AttachAsset {
    artifact_id: i64,
    #[serde(default)]
    name: Option<String>,
}

/// Copy a run artifact of this repository into the release.
pub(super) async fn attach_asset(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, tag)): Path<(String, String)>,
    Json(request): Json<AttachAsset>,
) -> Result<(StatusCode, Json<Envelope<Asset>>), ApiError> {
    require(&headers, &state, &repository, Role::Write)?;
    let artifact = state
        .database
        .artifact(request.artifact_id)
        .map_err(|_| ApiError::not_found("artifact not found"))?;
    let run = state
        .database
        .run(artifact.run_id)
        .map_err(ApiError::internal)?;
    if run.repository != repository {
        return Err(ApiError::forbidden(
            "artifact belongs to another repository",
        ));
    }
    let source = state
        .runner
        .artifact_path(&artifact)
        .map_err(ApiError::bad)?;
    let name = request
        .name
        .filter(|n| !n.trim().is_empty())
        .unwrap_or_else(|| {
            artifact
                .name
                .rsplit('/')
                .next()
                .unwrap_or("asset")
                .to_string()
        });
    let database = state.database.clone();
    let root = assets_root(&state);
    let asset = tokio::task::spawn_blocking(move || {
        releases::attach(&database, &root, &repository, &tag, &name, &source)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok((StatusCode::CREATED, ok(asset)))
}

pub(super) async fn download_asset(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, tag, name)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
    let repo = state
        .database
        .repository(&repository)
        .map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))?;
    if repo.visibility != "public" {
        authorize(&headers, &state)?;
    }
    let database = state.database.clone();
    let root = assets_root(&state);
    let found = tokio::task::spawn_blocking(move || {
        releases::asset_path(&database, &root, &repository, &tag, &name)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::internal)?;
    let (asset, path) = found.ok_or_else(|| ApiError::not_found("asset not found"))?;
    let file = tokio::fs::File::open(path)
        .await
        .map_err(ApiError::internal)?;
    let mut response = Response::new(Body::from_stream(ReaderStream::new(file)));
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/octet-stream"),
    );
    response.headers_mut().insert(
        header::CONTENT_LENGTH,
        HeaderValue::from_str(&asset.bytes.to_string()).map_err(ApiError::internal)?,
    );
    response.headers_mut().insert(
        header::CONTENT_DISPOSITION,
        HeaderValue::from_str(&format!("attachment; filename=\"{}\"", asset.name))
            .map_err(ApiError::internal)?,
    );
    Ok(response)
}

/// `/repos/{repository}/releases` (public repositories) and `/app/repositories/{repository}/releases`.
pub(super) async fn releases_page(
    State(state): State<AppState>,
    headers: HeaderMap,
    uri: axum::http::Uri,
    Path(repository_name): Path<String>,
) -> Result<Response, ApiError> {
    let public = !uri.path().starts_with("/app/");
    if !public && !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let repository = state
        .database
        .repository(&repository_name)
        .map_err(|_| ApiError::not_found(format!("unknown repository {repository_name}")))?;
    if public && repository.visibility != "public" {
        return Err(ApiError::not_found("repository not found"));
    }
    let prefix = if public {
        "/repos"
    } else {
        "/app/repositories"
    };
    let encoded = encode_code_segment(&repository_name);
    let repo_href = format!("{prefix}/{encoded}");
    let commit_prefix = format!("{repo_href}/commit/");
    let api_prefix = format!("/api/repos/{encoded}");
    let tags = tags_of(&state, &repository_name).await?;
    let database = state.database.clone();
    let name = repository_name.clone();
    let list = tokio::task::spawn_blocking(move || releases::list(&database, &name))
        .await
        .map_err(ApiError::internal)?
        .map_err(ApiError::bad)?;
    let view = repository_view(repository, &state.hosted_root, &state.public_origin);
    let rendered = state
        .templates
        .get_template("repository-releases.html")
        .map_err(ApiError::internal)?
        .render(context! { page => context! {
            repository => view, public => public, repo_href => repo_href,
            commit_prefix => commit_prefix, api_prefix => api_prefix,
            releases => list, tags => tags, now => unix_time()?,
        } })
        .map_err(ApiError::internal)?;
    Ok(Html(rendered).into_response())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::to_bytes,
        http::Request,
        routing::{get, post},
    };
    use std::process::Command;
    use tower::ServiceExt;

    fn git(dir: &std::path::Path, args: &[&str]) {
        let status = Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .expect("git");
        assert!(status.success(), "git {args:?}");
    }

    /// A hosted demo.git with one commit and tag v1.0.0, plus an artifact on disk.
    fn fixture() -> (tempfile::TempDir, Router, AppState, i64) {
        let root = tempfile::tempdir().expect("root");
        let work = root.path().join("work");
        std::fs::create_dir_all(&work).expect("work");
        git(&work, &["init", "-q", "-b", "main"]);
        git(&work, &["config", "user.name", "Ada"]);
        git(&work, &["config", "user.email", "ada@example.invalid"]);
        std::fs::write(work.join("a.txt"), "a").expect("write");
        git(&work, &["add", "."]);
        git(&work, &["commit", "-q", "-m", "one"]);
        git(&work, &["tag", "-a", "v1.0.0", "-m", "First"]);
        let hosted = root.path().join("hosted");
        std::fs::create_dir_all(&hosted).expect("hosted");
        git(
            &work,
            &[
                "clone",
                "-q",
                "--bare",
                ".",
                hosted.join("demo.git").to_str().expect("utf8"),
            ],
        );
        let mut state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        state.hosted_root = root.path().to_owned();
        state
            .database
            .add_repository(
                "demo",
                hosted.join("demo.git").to_str().expect("utf8"),
                "main",
            )
            .expect("repo");
        // an artifact produced by a run of demo
        let run_id = state
            .runner
            .queue("demo", Some("main"), None, "manual")
            .expect("run");
        state
            .database
            .insert_jobs(
                run_id,
                &crate::config::parse("version: 1\njobs:\n  - name: build\n    executor: native\n    run:\n      - echo hi\n")
                    .expect("jobs"),
            )
            .expect("jobs");
        let job_id = state.database.jobs(run_id).expect("jobs")[0].id;
        let runner_root = state.runner.root().to_owned();
        std::fs::create_dir_all(&runner_root).expect("runner root");
        let artifact_dir = runner_root.join("artifacts");
        std::fs::create_dir_all(&artifact_dir).expect("artifacts");
        std::fs::write(artifact_dir.join("akurai"), b"binary").expect("write");
        let digest = {
            use sha2::Digest;
            sha2::Sha256::digest(b"binary")
                .iter()
                .map(|b| format!("{b:02x}"))
                .collect::<String>()
        };
        let artifact_id = state
            .database
            .add_artifact(
                run_id,
                job_id,
                "target/release/akurai",
                "artifacts/akurai",
                &digest,
                6,
            )
            .expect("artifact");
        let router = Router::new()
            .route("/api/repos/{repository}/tags", get(list_tags))
            .route(
                "/api/repos/{repository}/releases",
                get(list_releases).post(create_release),
            )
            .route(
                "/api/repos/{repository}/releases/{tag}",
                get(get_release).delete(delete_release),
            )
            .route(
                "/api/repos/{repository}/releases/{tag}/assets",
                post(attach_asset),
            )
            .route(
                "/api/repos/{repository}/releases/{tag}/assets/{name}",
                get(download_asset),
            )
            .route("/repos/{repository}/releases", get(releases_page))
            .route(
                "/app/repositories/{repository}/releases",
                get(releases_page),
            )
            .with_state(state.clone());
        (root, router, state, artifact_id)
    }

    async fn send(
        app: &Router,
        method: &str,
        uri: &str,
        token: Option<&str>,
        body: serde_json::Value,
    ) -> (StatusCode, Vec<u8>) {
        let mut request = Request::builder()
            .method(method)
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/json");
        if let Some(token) = token {
            request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
        }
        let response = app
            .clone()
            .oneshot(request.body(Body::from(body.to_string())).expect("request"))
            .await
            .expect("response");
        let status = response.status();
        let bytes = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        (status, bytes.to_vec())
    }

    fn json(bytes: &[u8]) -> serde_json::Value {
        serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null)
    }

    #[tokio::test]
    async fn tags_releases_assets_and_the_page_work_end_to_end() {
        let (_root, app, _state, artifact_id) = fixture();
        let master = Some("api-token-123");
        let (status, body) = send(
            &app,
            "GET",
            "/api/repos/demo/tags",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
        let tags = json(&body);
        assert_eq!(tags["data"][0]["name"], "v1.0.0");
        assert_eq!(tags["data"][0]["annotated"], true);

        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/demo/releases",
            master,
            serde_json::json!({"tag": "v9.9.9", "notes": "x"}),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::BAD_REQUEST,
            "unknown tag: {}",
            String::from_utf8_lossy(&body)
        );
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/demo/releases",
            master,
            serde_json::json!({"tag": "v1.0.0", "name": "First", "notes": "Notes here."}),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::CREATED,
            "{}",
            String::from_utf8_lossy(&body)
        );
        assert_eq!(json(&body)["data"]["author"], "forge");
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/releases",
            master,
            serde_json::json!({"tag": "v1.0.0"}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT);

        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/demo/releases/v1.0.0/assets",
            master,
            serde_json::json!({"artifact_id": artifact_id}),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::CREATED,
            "{}",
            String::from_utf8_lossy(&body)
        );
        assert_eq!(json(&body)["data"]["name"], "akurai");
        let (status, body) = send(
            &app,
            "GET",
            "/api/repos/demo/releases/v1.0.0/assets/akurai",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, b"binary");
        let (status, _) = send(
            &app,
            "GET",
            "/api/repos/demo/releases/v1.0.0/assets/akurai",
            None,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::UNAUTHORIZED,
            "private repository assets need a token"
        );

        let (status, body) = send(
            &app,
            "GET",
            "/api/repos/demo/releases",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(json(&body)["data"][0]["assets"][0]["bytes"], 6);

        let (status, _) = send(
            &app,
            "GET",
            "/app/repositories/demo/releases",
            None,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::SEE_OTHER,
            "signed-in page redirects to login"
        );
        let (status, _) = send(
            &app,
            "GET",
            "/repos/demo/releases",
            None,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::NOT_FOUND,
            "private repositories have no public page"
        );

        let (status, _) = send(
            &app,
            "DELETE",
            "/api/repos/demo/releases/v1.0.0",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        let (status, _) = send(
            &app,
            "GET",
            "/api/repos/demo/releases/v1.0.0",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::NOT_FOUND);
    }
}