orion-server 1.11.0

Turn business logic into live REST/Kafka services, declared as JSON
//! Package receipts (K14): the single package-aware point of the admin API.
//!
//! Packaging itself lives in the `orion-server package` CLI, which stages and
//! activates artifacts through the per-kind endpoints. What the server keeps
//! is one receipt per applied package version, because the promotion rule —
//! *an applied package version is immutable; only a staged one may change;
//! any content change rides a version bump* — cannot be enforced without the
//! target remembering what was applied. The enforcement itself is in
//! [`crate::storage::repositories::packages`]; these handlers add validation,
//! audit, and the wire shapes.

use axum::extract::{Path, State};
use axum::{Extension, Json};
use serde_json::Value;

use crate::errors::OrionError;
use crate::server::admin_auth::AdminPrincipal;
use crate::server::extract::{OrionJson, OrionQuery};
use crate::server::routes::openapi::{DataEnvelope, PaginatedEnvelope};
use crate::server::routes::response_helpers::{data_response, paginated_into};
use crate::server::state::AppState;
use crate::storage::models::{PackageReceiptResponse, PackageState};
use crate::storage::repositories::helpers::clamp_pagination;
use crate::storage::repositories::packages::PutPackageReceiptRequest;

use super::audit_log;

/// `GET /api/v1/admin/packages/{name}` — one package's receipts.
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct PackageDetail {
    name: String,
    /// The newest `applied` receipt — what this deployment currently runs.
    /// `null` while every receipt is still `staged`.
    current: Option<PackageReceiptResponse>,
    /// Every receipt for this package, newest first.
    versions: Vec<PackageReceiptResponse>,
}

/// Query parameters of `GET /api/v1/admin/packages`.
#[derive(Debug, Default, serde::Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct PackageListQuery {
    /// Page size, clamped to [1, 1000] (default 50).
    pub limit: Option<i64>,
    /// Pagination offset (default 0).
    pub offset: Option<i64>,
    /// When true, one row per package — its `current` receipt, the newest
    /// applied one — with the `inventory` it recorded. A package whose
    /// receipts are all staged is left out.
    #[serde(default)]
    pub current: bool,
}

/// The most ids one inventory list may hold — the import cap an artifact's
/// member arrays are already bound by.
const MAX_INVENTORY_IDS: usize = super::MAX_IMPORT_ITEMS;
/// The longest id an inventory may name.
const MAX_INVENTORY_ID_LEN: usize = 255;

/// A receipt key: the one rule `compile` and `package export` also apply,
/// as a `400`. The MySQL column widths
/// (`migrations/mysql/013_package_receipts.sql`) are sized to its caps, so
/// the route layer must refuse anything longer before it reaches the driver.
fn validate_key_field(field: &str, value: &str, max_len: usize) -> Result<(), OrionError> {
    crate::validation::package_key(field, value, max_len).map_err(OrionError::validation)
}

fn validate_put(name: &str, req: &PutPackageReceiptRequest) -> Result<(), OrionError> {
    validate_key_field(
        "package name",
        name,
        crate::validation::MAX_PACKAGE_NAME_LEN,
    )?;
    validate_key_field(
        "version",
        &req.version,
        crate::validation::MAX_PACKAGE_VERSION_LEN,
    )?;
    // `sha256:<hex>` is the expected spelling — strip the one legitimate ':'
    // and hold the rest to the shared charset.
    validate_key_field(
        "content_hash",
        &req.content_hash.replacen(':', "", 1),
        crate::validation::MAX_PACKAGE_HASH_LEN,
    )?;
    if let Some(inventory) = &req.inventory {
        for (kind, ids) in inventory.kinds() {
            if ids.len() > MAX_INVENTORY_IDS {
                return Err(OrionError::validation(format!(
                    "inventory.{kind} lists {} ids — at most {MAX_INVENTORY_IDS}",
                    ids.len()
                )));
            }
            if let Some(bad) = ids
                .iter()
                .find(|id| id.is_empty() || id.len() > MAX_INVENTORY_ID_LEN)
            {
                return Err(OrionError::validation(format!(
                    "inventory.{kind} holds an id of {} bytes — each must be 1 to \
                     {MAX_INVENTORY_ID_LEN}",
                    bad.len()
                )));
            }
        }
    }
    Ok(())
}

#[utoipa::path(
    get,
    path = "/api/v1/admin/packages",
    tag = "Packages",
    params(PackageListQuery),
    responses(
        (status = 200, description = "Paginated receipt rows, ordered by package name, \
            newest first within a package, without their `inventory`. With \
            `?current=true`, each package's current receipt with its `inventory`.",
            body = PaginatedEnvelope<PackageReceiptResponse>),
    )
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_packages(
    State(state): State<AppState>,
    OrionQuery(filter): OrionQuery<PackageListQuery>,
) -> Result<Json<Value>, OrionError> {
    let (limit, offset) = clamp_pagination(filter.limit, filter.offset);
    if filter.current {
        let result = state.repos.packages.list_current(limit, offset).await?;
        return paginated_into(result, |r| {
            Ok::<_, OrionError>(PackageReceiptResponse::from(r))
        });
    }
    // Every version of every package: the inventories would dominate the
    // page, and a caller that needs one reads its package.
    let result = state.repos.packages.list(limit, offset).await?;
    paginated_into(result, |r| {
        Ok::<_, OrionError>(PackageReceiptResponse {
            inventory: None,
            ..PackageReceiptResponse::from(r)
        })
    })
}

#[utoipa::path(
    get,
    path = "/api/v1/admin/packages/{name}",
    tag = "Packages",
    params(("name" = String, Path, description = "Package name")),
    responses(
        (status = 200, description = "The package's receipts, with `current` naming the \
            newest applied version.", body = DataEnvelope<PackageDetail>),
        (status = 404, description = "No receipts recorded for this package"),
    )
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn get_package(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<Json<Value>, OrionError> {
    let rows = state.repos.packages.get_by_name(&name).await?;
    // Rows arrive newest-first, so the first applied receipt is the current
    // one — re-applying an older version touches its updated_at, which is
    // exactly what moves `current` back to it (the rollback path).
    let current = rows
        .iter()
        .find(|r| r.state == PackageState::Applied.as_str())
        .map(PackageReceiptResponse::from);
    Ok(data_response(PackageDetail {
        name,
        current,
        versions: rows.iter().map(PackageReceiptResponse::from).collect(),
    }))
}

#[utoipa::path(
    put,
    path = "/api/v1/admin/packages/{name}",
    tag = "Packages",
    params(("name" = String, Path, description = "Package name")),
    request_body = PutPackageReceiptRequest,
    responses(
        (status = 200, description = "The receipt as stored", body = DataEnvelope<PackageReceiptResponse>),
        (status = 400, description = "Invalid name, version, content hash, state, or \
            inventory (more than 1000 ids in one list, or an empty or over-long id)"),
        (status = 409, description = "The version is already applied with different \
            content (an applied package version is immutable — bump the package \
            version), already applied and asked to go back to staged, or was written \
            by a concurrent request."),
    )
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn put_package(
    State(state): State<AppState>,
    principal: Option<Extension<AdminPrincipal>>,
    Path(name): Path<String>,
    OrionJson(req): OrionJson<PutPackageReceiptRequest>,
) -> Result<Json<Value>, OrionError> {
    validate_put(&name, &req)?;
    let who = principal
        .as_ref()
        .map(|e| e.0.key_id.as_str())
        .unwrap_or(super::ANONYMOUS_PRINCIPAL);
    let receipt = state.repos.packages.put(&name, &req, who).await?;
    // Receipts never touch the engine — no reload, like every draft path.
    audit_log(
        &state.audit_queue,
        &principal,
        &format!("package_{}", req.state),
        "package",
        &format!("{name}@{}", req.version),
    );
    Ok(data_response(PackageReceiptResponse::from(&receipt)))
}