OXYGENHUB

Coverage / crates/hub/src/api/images.rs 90.8%

99 of 109 lines run, 24 of 62 functions · run #494 on main · run missed

1
//! Images by hash over the API (oxygenflow#39): `flow rootfs push` sends
2
//! an image's missing chunks and then its manifest; runners, and `flow
3
//! rootfs pull`, fetch a manifest and the chunks they lack.
4
5
use super::{caller, not_found, ApiError};
6
use crate::Shared;
7
use axum::body::Bytes;
8
use axum::extract::{Path, State};
9
use axum::http::{header, HeaderMap, StatusCode};
10
use axum::response::{IntoResponse, Response};
11
use axum::Json;
12
use jet::images::{is_hash, Manifest};
13
use serde::{Deserialize, Serialize};
14
15
/// Most chunk names one `missing` question may ask about.
16
const MAX_ASK: usize = 200_000;
17
/// The body that many names make: 64 hex digits, quotes and a comma each,
18
/// with room to spare (#147: the 2 MB default refused an 11 GB image's).
19
pub const MAX_ASK_BYTES: usize = MAX_ASK * 80;
20
21
/// Who sends images: an account whose token may write repositories.
2219×
async fn sender(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
2319×
    let c = caller(app, headers).await?;
2418×
    Ok(c.require("repo:write")?.username.clone())
2519×
}
26
27
/// Who fetches images: a runner, or any account (for `flow rootfs pull`).
287×
async fn fetcher(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
297×
    if crate::flow::runner_auth(app, headers).await.is_ok() {
306×
        return Ok(());
311×
    }
321×
    let c = caller(app, headers).await?;
331×
    c.account.as_ref().map(|_| ()).ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a runner or personal access token is required".into()))
347×
}
35
36
#[derive(Deserialize)]
37
pub struct Ask {
38
    chunks: Vec<String>,
39
}
40
41
#[derive(Serialize)]
42
pub struct Missing {
43
    missing: Vec<String>,
44
}
45
46
/// `POST /flow/images/missing {"chunks": [..]}`: which of these chunks the
47
/// hub lacks.
483×
pub async fn missing(State(app): State<Shared>, headers: HeaderMap, Json(ask): Json<Ask>) -> Result<Json<Missing>, ApiError> {
493×
    sender(&app, &headers).await?;
503×
    if ask.chunks.len() > MAX_ASK {
510×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("ask about at most {MAX_ASK} chunks at once")));
523×
    }
533×
    let store = app.images();
543×
    let missing = tokio::task::spawn_blocking(move || {
553×
        let mut seen = std::collections::HashSet::new();
5660006×
        ask.chunks.into_iter().filter(|h| is_hash(h) && seen.insert(h.clone()) && !store.has_chunk(h)).collect()
573×
    })
583×
    .await
593×
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
603×
    Ok(Json(Missing { missing }))
613×
}
62
63
/// `PUT /flow/images/chunks/{hash}`: one chunk, checked against its name.
6410×
pub async fn put_chunk(
6510×
    State(app): State<Shared>,
6610×
    headers: HeaderMap,
6710×
    Path(hash): Path<String>,
6810×
    body: Bytes,
6910×
) -> Result<StatusCode, ApiError> {
7010×
    sender(&app, &headers).await?;
719×
    if !is_hash(&hash) || body.is_empty() || body.len() > oxy_core::chunk::MAX_SIZE {
720×
        return Err(ApiError(StatusCode::BAD_REQUEST, "not a chunk: a name is 64 hex digits, a chunk 1 byte to 256 KiB".into()));
739×
    }
749×
    if blake3::hash(&body).to_hex().as_str() != hash {
751×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("those bytes are not chunk {}", &hash[..12])));
768×
    }
778×
    let store = app.images();
788×
    tokio::task::spawn_blocking(move || store.put_chunk(&hash, &body))
798×
        .await
808×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
818×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the chunk: {e}")))?;
828×
    Ok(StatusCode::NO_CONTENT)
8310×
}
84
85
/// `PUT /flow/images/{hash}`: the manifest, accepted once every chunk is
86
/// here and the image they make has this hash.
876×
pub async fn put_manifest(
886×
    State(app): State<Shared>,
896×
    headers: HeaderMap,
906×
    Path(hash): Path<String>,
916×
    Json(m): Json<Manifest>,
926×
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
936×
    let who = sender(&app, &headers).await?;
946×
    if m.hash != hash {
950×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
966×
    }
97
    // In a task of its own (#341): reading a big image can outlast the
98
    // sender's wait, and a sender that gives up must not leave an image
99
    // kept but never offered.
1006×
    let m = tokio::spawn(async move {
1016×
        let store = app.images();
102
        // A manifest kept before was verified then; only its row may be missing.
1036×
        let m = match store.manifest(&m.hash).filter(|k| k.chunks == m.chunks) {
1041×
            Some(kept) => kept,
105
            // Kept with the architecture its own files name (oxygenhub#173).
1065×
            None => tokio::task::spawn_blocking(move || store.keep_reading_arch(&m))
1075×
                .await
1085×
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1095×
                .map_err(|e| ApiError(StatusCode::CONFLICT, e))?,
110
        };
1114×
        crate::images::record(&app.db, &m, &who).await?;
1124×
        crate::db::accounts::audit(&app.db, None, "flow.image", &who, &format!("{} ({} MiB)", &m.hash[..12], m.size >> 20)).await.ok();
1134×
        Ok::<_, ApiError>(m)
1146×
    })
1156×
    .await
1166×
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
1174×
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len(), "arch": m.arch }))))
1186×
}
119
120
/// `GET /flow/images/{hash}`: an image's manifest.
1214×
pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> {
1224×
    fetcher(&app, &headers).await?;
1233×
    app.images().manifest(&hash).map(Json).ok_or_else(|| not_found("this hub does not hold that image; send it with flow rootfs push"))
1244×
}
125
126
/// `GET /flow/images/chunks/{hash}`: one chunk's bytes.
1273×
pub async fn get_chunk(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Response, ApiError> {
1283×
    fetcher(&app, &headers).await?;
1293×
    let store = app.images();
1303×
    let bytes = tokio::task::spawn_blocking(move || store.chunk(&hash))
1313×
        .await
1323×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1333×
        .map_err(|_| not_found("no such chunk"))?;
1343×
    Ok(([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, "private, max-age=31536000, immutable")], bytes)
1353×
        .into_response())
1363×
}