Menu
Public repository / main
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);
}
}