OXYGENHUB
# eb56c8bc4d0e oxygenhub / crates / hub / src / api / images.rs
5.2 KiB · Rust · last changed Images by hash on the hub: chunks, verified manifests, and placement for runners that fetch 24 days ago source blame history raw
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
18
/// Who sends images: an account whose token may write repositories.
19
async fn sender(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
20
    let c = caller(app, headers).await?;
21
    Ok(c.require("repo:write")?.username.clone())
22
}
23
24
/// Who fetches images: a runner, or any account (for `flow rootfs pull`).
25
async fn fetcher(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
26
    if crate::flow::runner_auth(app, headers).await.is_ok() {
27
        return Ok(());
28
    }
29
    let c = caller(app, headers).await?;
30
    c.account.as_ref().map(|_| ()).ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a runner or personal access token is required".into()))
31
}
32
33
#[derive(Deserialize)]
34
pub struct Ask {
35
    chunks: Vec<String>,
36
}
37
38
#[derive(Serialize)]
39
pub struct Missing {
40
    missing: Vec<String>,
41
}
42
43
/// `POST /flow/images/missing {"chunks": [..]}`: which of these chunks the
44
/// hub lacks.
45
pub async fn missing(State(app): State<Shared>, headers: HeaderMap, Json(ask): Json<Ask>) -> Result<Json<Missing>, ApiError> {
46
    sender(&app, &headers).await?;
47
    if ask.chunks.len() > MAX_ASK {
48
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("ask about at most {MAX_ASK} chunks at once")));
49
    }
50
    let store = app.images();
51
    let missing = tokio::task::spawn_blocking(move || {
52
        let mut seen = std::collections::HashSet::new();
53
        ask.chunks.into_iter().filter(|h| is_hash(h) && seen.insert(h.clone()) && !store.has_chunk(h)).collect()
54
    })
55
    .await
56
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
57
    Ok(Json(Missing { missing }))
58
}
59
60
/// `PUT /flow/images/chunks/{hash}`: one chunk, checked against its name.
61
pub async fn put_chunk(
62
    State(app): State<Shared>,
63
    headers: HeaderMap,
64
    Path(hash): Path<String>,
65
    body: Bytes,
66
) -> Result<StatusCode, ApiError> {
67
    sender(&app, &headers).await?;
68
    if !is_hash(&hash) || body.is_empty() || body.len() > oxy_core::chunk::MAX_SIZE {
69
        return Err(ApiError(StatusCode::BAD_REQUEST, "not a chunk: a name is 64 hex digits, a chunk 1 byte to 256 KiB".into()));
70
    }
71
    if blake3::hash(&body).to_hex().as_str() != hash {
72
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("those bytes are not chunk {}", &hash[..12])));
73
    }
74
    let store = app.images();
75
    tokio::task::spawn_blocking(move || store.put_chunk(&hash, &body))
76
        .await
77
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
78
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the chunk: {e}")))?;
79
    Ok(StatusCode::NO_CONTENT)
80
}
81
82
/// `PUT /flow/images/{hash}`: the manifest, accepted once every chunk is
83
/// here and the image they make has this hash.
84
pub async fn put_manifest(
85
    State(app): State<Shared>,
86
    headers: HeaderMap,
87
    Path(hash): Path<String>,
88
    Json(m): Json<Manifest>,
89
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
90
    let who = sender(&app, &headers).await?;
91
    if m.hash != hash {
92
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
93
    }
94
    let store = app.images();
95
    let kept = m.clone();
96
    tokio::task::spawn_blocking(move || store.verify_and_keep(&kept))
97
        .await
98
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
99
        .map_err(|e| ApiError(StatusCode::CONFLICT, e))?;
100
    crate::images::record(&app.db, &m, &who).await?;
101
    crate::db::accounts::audit(&app.db, None, "flow.image", &who, &format!("{} ({} MiB)", &m.hash[..12], m.size >> 20)).await.ok();
102
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len() }))))
103
}
104
105
/// `GET /flow/images/{hash}`: an image's manifest.
106
pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> {
107
    fetcher(&app, &headers).await?;
108
    app.images().manifest(&hash).map(Json).ok_or_else(|| not_found("this hub does not hold that image; send it with flow rootfs push"))
109
}
110
111
/// `GET /flow/images/chunks/{hash}`: one chunk's bytes.
112
pub async fn get_chunk(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Response, ApiError> {
113
    fetcher(&app, &headers).await?;
114
    let store = app.images();
115
    let bytes = tokio::task::spawn_blocking(move || store.chunk(&hash))
116
        .await
117
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
118
        .map_err(|_| not_found("no such chunk"))?;
119
    Ok(([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, "private, max-age=31536000, immutable")], bytes)
120
        .into_response())
121
}