OXYGENHUB

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

139 of 151 lines run, 32 of 80 functions · run #542 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.
2222×
async fn sender(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
2322×
    let c = caller(app, headers).await?;
2421×
    Ok(c.require("repo:write")?.username.clone())
2522×
}
26
27
/// Who fetches images: a runner, or any account (for `flow rootfs pull`).
289×
async fn fetcher(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
299×
    if crate::flow::runner_auth(app, headers).await.is_ok() {
308×
        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()))
349×
}
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.
6412×
pub async fn put_chunk(
6512×
    State(app): State<Shared>,
6612×
    headers: HeaderMap,
6712×
    Path(hash): Path<String>,
6812×
    body: Bytes,
6912×
) -> Result<StatusCode, ApiError> {
7012×
    sender(&app, &headers).await?;
7111×
    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()));
7311×
    }
7411×
    if blake3::hash(&body).to_hex().as_str() != hash {
751×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("those bytes are not chunk {}", &hash[..12])));
7610×
    }
7710×
    let store = app.images();
7810×
    tokio::task::spawn_blocking(move || store.put_chunk(&hash, &body))
7910×
        .await
8010×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
8110×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the chunk: {e}")))?;
8210×
    Ok(StatusCode::NO_CONTENT)
8312×
}
84
85
/// `PUT /flow/images/{hash}`: the manifest, accepted once every chunk is
86
/// here and the image they make has this hash.
877×
pub async fn put_manifest(
887×
    State(app): State<Shared>,
897×
    headers: HeaderMap,
907×
    Path(hash): Path<String>,
917×
    Json(m): Json<Manifest>,
927×
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
937×
    let who = sender(&app, &headers).await?;
947×
    if m.hash != hash {
950×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
967×
    }
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.
1007×
    let m = tokio::spawn(async move {
1017×
        let store = app.images();
102
        // A manifest kept before was verified then; only its row may be missing.
1037×
        let m = match store.manifest(&m.hash).filter(|k| k.chunks == m.chunks) {
1042×
            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
        };
1115×
        crate::images::record(&app.db, &m, &who).await?;
1125×
        crate::db::accounts::audit(&app.db, None, "flow.image", &who, &format!("{} ({} MiB)", &m.hash[..12], m.size >> 20)).await.ok();
1135×
        Ok::<_, ApiError>(m)
1147×
    })
1157×
    .await
1167×
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
1175×
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len(), "arch": m.arch }))))
1187×
}
119
120
/// Most images one `held` question may name.
121
const MAX_HELD: usize = 1000;
122
123
/// `POST /flow/images/held` (a runner's token): the images it holds; the
124
/// answer names those the hub keeps no manifest of, for the runner to send
125
/// with `PUT /flow/images/{hash}/held` (oxygenflow#94).
1263×
pub async fn held(
1273×
    State(app): State<Shared>,
1283×
    headers: HeaderMap,
1293×
    Json(ask): Json<flow_core::api::HeldImages>,
1303×
) -> Result<Json<flow_core::api::WantedManifests>, ApiError> {
1313×
    crate::flow::runner_auth(&app, &headers).await?;
1322×
    let kept = crate::images::held(&app.db).await?;
1332×
    let mut wanted: Vec<String> =
1344×
        ask.images.into_iter().take(MAX_HELD).map(|h| h.to_ascii_lowercase()).filter(|h| is_hash(h) && !kept.contains(h)).collect();
1352×
    wanted.sort();
1362×
    wanted.dedup();
1372×
    Ok(Json(flow_core::api::WantedManifests { wanted }))
1383×
}
139
140
/// `PUT /flow/images/{hash}/held` (a runner's token): the manifest of an
141
/// image the runner holds and the hub does not. Kept as held by runners
142
/// only: the hub has none of its chunks to check it against, and a fetcher
143
/// checks what it assembles against the pinned hash (jetstream.md §8.1.1).
1442×
pub async fn put_held(
1452×
    State(app): State<Shared>,
1462×
    headers: HeaderMap,
1472×
    Path(hash): Path<String>,
1482×
    Json(m): Json<Manifest>,
1492×
) -> Result<StatusCode, ApiError> {
1502×
    let runner = crate::flow::runner_auth(&app, &headers).await?;
1512×
    if !is_hash(&hash) || m.hash != hash {
1521×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
1531×
    }
1541×
    if crate::images::held(&app.db).await?.contains(&hash) {
1550×
        return Ok(StatusCode::NO_CONTENT);
1561×
    }
1571×
    let store = app.images();
1581×
    let kept = m.clone();
1591×
    tokio::task::spawn_blocking(move || store.keep_peer_manifest(&kept))
1601×
        .await
1611×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1621×
        .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1631×
    crate::images::record_peer(&app.db, &m, &runner.name).await?;
1641×
    Ok(StatusCode::CREATED)
1652×
}
166
167
/// `GET /flow/images/{hash}`: an image's manifest.
1685×
pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> {
1695×
    fetcher(&app, &headers).await?;
1704×
    app.images().manifest(&hash).map(Json).ok_or_else(|| not_found("this hub does not hold that image; send it with flow rootfs push"))
1715×
}
172
173
/// `GET /flow/images/chunks/{hash}`: one chunk's bytes.
1744×
pub async fn get_chunk(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Response, ApiError> {
1754×
    fetcher(&app, &headers).await?;
1764×
    let store = app.images();
1774×
    let bytes = tokio::task::spawn_blocking(move || store.chunk(&hash))
1784×
        .await
1794×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1804×
        .map_err(|_| not_found("no such chunk"))?;
1813×
    Ok(([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, "private, max-age=31536000, immutable")], bytes)
1823×
        .into_response())
1834×
}