Skip to content

brokkr-broker::api::v1::stacks

Rust

pub

Derives: Debug, Deserialize, ToSchema

Wire DTO for creating a deployment object via the public API.

Distinct from brokkr_models::models::deployment_objects::NewDeploymentObject, which carries server-derived fields (e.g. yaml_checksum).

Name Type Description
yaml_content String YAML content of the deployment.
is_deletion_marker bool Optional. Defaults to false.

pub

Derives: Debug, Default, Deserialize, utoipa::IntoParams

Query parameters for the deployment-object create endpoint. Used by the raw-YAML body path (Content-Type: application/yaml), where the flag cannot ride in the body (BROKKR-T-0194).

Name Type Description
deletion_marker Option<bool> Marks the submission as a deletion marker. Only consulted on the raw-YAML path; on the JSON path the body field wins.

pub

Derives: Debug, Deserialize, Serialize, ToSchema

Name Type Description
template_id Uuid
parameters serde_json::Value

pub

Derives: Debug, serde::Deserialize, utoipa::IntoParams

Name Type Description
since Option<chrono::DateTime<chrono::Utc>> Earliest created_at to include (ISO-8601). Defaults to now - retention_ceiling_seconds. Values older than the ceiling are silently clamped: only the retained window can be returned.
limit Option<i64> Maximum rows to return. Defaults to 500; capped at 5000.

pub

Derives: Debug, serde::Serialize, utoipa::ToSchema

Name Type Description
retention_ceiling_seconds u64 Hard upper bound on retention. Never exceeds 21600 (6h).
effective_retention_seconds u64 Effective retention window for the stack. <= ceiling.
oldest_available_ts Option<chrono::DateTime<chrono::Utc>> Server-side timestamp of the oldest row currently retained for this stack, or null when no rows exist in the window.
long_term_sink_hint &'static str Recommended sink for long-term centralisation. Brokkr is NOT a log warehouse — see project_log_retention_stance.

pub

Derives: Debug, serde::Serialize, utoipa::ToSchema

Name Type Description
retention RetentionInfo
events Vec<AgentK8sEvent>

pub

Derives: Debug, serde::Serialize, utoipa::ToSchema

Name Type Description
retention RetentionInfo
lines Vec<AgentPodLog>

pub

fn routes() -> Router<DAL>
Source
pub fn routes() -> Router<DAL> {
info!("Setting up stack routes");
Router::new()
.route("/stacks", get(list_stacks).post(create_stack))
.route(
"/stacks/:id",
get(get_stack).put(update_stack).delete(delete_stack),
)
.route(
"/stacks/:id/deployment-objects",
get(list_deployment_objects).post(create_deployment_object),
)
.route(
"/stacks/:id/deployment-objects/from-template",
post(instantiate_template),
)
.route("/stacks/:id/labels", get(list_labels).post(add_label))
.route("/stacks/:id/labels/:label", delete(remove_label))
.route(
"/stacks/:id/annotations",
get(list_annotations).post(add_annotation),
)
.route("/stacks/:id/annotations/:key", delete(remove_annotation))
// WS-10: short-lived telemetry history (BROKKR-I-0019). Bounded by
// the 6h retention ceiling (project_log_retention_stance).
.route("/stacks/:id/events", get(list_telemetry_events))
.route("/stacks/:id/logs", get(list_telemetry_logs))
}

pub

async fn list_deployment_objects(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<DeploymentObject>>, ApiError>
Source
pub async fn list_deployment_objects(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<DeploymentObject>>, ApiError> {
fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
let objects = dal
.deployment_objects()
.list_for_stack(stack_id)
.map_err(|_| ApiError::internal("failed to fetch deployment objects"))?;
metrics::set_deployment_objects_total(objects.len() as i64);
Ok(Json(objects))
}

pub

async fn create_deployment_object(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Extension(ws_registry): Extension<Arc<ConnectionRegistry>>,
Path(stack_id): Path<Uuid>,
Query(query): Query<CreateDeploymentObjectQuery>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<DeploymentObject>), ApiError>
Source
pub async fn create_deployment_object(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Extension(ws_registry): Extension<Arc<ConnectionRegistry>>,
Path(stack_id): Path<Uuid>,
Query(query): Query<CreateDeploymentObjectQuery>,
headers: HeaderMap,
// Body-consuming extractor must come last.
body: Bytes,
) -> Result<(StatusCode, Json<DeploymentObject>), ApiError> {
let stack = fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
let (yaml_content, is_deletion_marker) = resolve_create_body(&headers, &query, &body)?;
validate_manifest_yaml(&yaml_content, is_deletion_marker)?;
let new_object = NewDeploymentObject::new(stack_id, yaml_content, is_deletion_marker)
.map_err(|e| ApiError::bad_request("invalid_deployment_object", e))?;
let object = dal
.deployment_objects()
.create(&new_object)
.map_err(|_| ApiError::internal("failed to create deployment object"))?;
// Post-commit: notify every connected agent that targets this stack so
// they can reconcile the new deployment object immediately rather than
// waiting for the next REST polling tick.
push_stack_changed_to_targets(&ws_registry, &dal, &stack);
Ok((StatusCode::CREATED, Json(object)))
}

pub

async fn list_labels(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<StackLabel>>, ApiError>
Source
pub async fn list_labels(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<StackLabel>>, ApiError> {
if !is_authorized_for_stack(&dal, &auth_payload, stack_id).await? {
return Err(ApiError::forbidden(
"stack_not_accessible",
"not authorized to access this stack",
));
}
let labels = dal
.stack_labels()
.list_for_stack(stack_id)
.map_err(|_| ApiError::internal("failed to fetch stack labels"))?;
Ok(Json(labels))
}

pub

async fn add_label(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
Json(label): Json<String>,
) -> Result<(StatusCode, Json<StackLabel>), ApiError>
Source
pub async fn add_label(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
Json(label): Json<String>,
) -> Result<(StatusCode, Json<StackLabel>), ApiError> {
fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
let new_label = NewStackLabel::new(stack_id, label)
.map_err(|e| ApiError::bad_request("invalid_label", e))?;
// Route the DB error through the standard conversion so a re-added label
// (UNIQUE (stack_id, label)) surfaces as 409 unique_violation rather than a
// blanket 500 — idempotent callers like `apply` rely on the 409 to treat a
// label that already exists as a no-op.
let label = dal
.stack_labels()
.create(&new_label)
.map_err(|e| ApiError::from_diesel(e, "failed to add stack label"))?;
Ok((StatusCode::CREATED, Json(label)))
}

pub

async fn remove_label(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path((stack_id, label)): Path<(Uuid, String)>,
) -> Result<StatusCode, ApiError>
Source
pub async fn remove_label(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path((stack_id, label)): Path<(Uuid, String)>,
) -> Result<StatusCode, ApiError> {
fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
let deleted = dal
.stack_labels()
.delete_by_stack_and_label(stack_id, &label)
.map_err(|_| ApiError::internal("failed to remove stack label"))?;
if deleted > 0 {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found(
"stack_label_not_found",
"label not found",
))
}
}

pub

async fn list_annotations(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<StackAnnotation>>, ApiError>
Source
pub async fn list_annotations(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
) -> Result<Json<Vec<StackAnnotation>>, ApiError> {
if !is_authorized_for_stack(&dal, &auth_payload, stack_id).await? {
return Err(ApiError::forbidden(
"stack_not_accessible",
"not authorized to access this stack",
));
}
let annotations = dal
.stack_annotations()
.list_for_stack(stack_id)
.map_err(|_| ApiError::internal("failed to fetch stack annotations"))?;
Ok(Json(annotations))
}

pub

async fn add_annotation(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
Json(new_annotation): Json<NewStackAnnotation>,
) -> Result<(StatusCode, Json<StackAnnotation>), ApiError>
Source
pub async fn add_annotation(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
Json(new_annotation): Json<NewStackAnnotation>,
) -> Result<(StatusCode, Json<StackAnnotation>), ApiError> {
fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
if new_annotation.stack_id != stack_id {
return Err(ApiError::bad_request(
"stack_id_mismatch",
"stack ID mismatch",
));
}
let annotation = dal
.stack_annotations()
.create(&new_annotation)
.map_err(|e| ApiError::from_diesel(e, "failed to add stack annotation"))?;
Ok((StatusCode::CREATED, Json(annotation)))
}

pub

async fn remove_annotation(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path((stack_id, key)): Path<(Uuid, String)>,
) -> Result<StatusCode, ApiError>
Source
pub async fn remove_annotation(
State(dal): State<DAL>,
Extension(auth_payload): Extension<AuthPayload>,
Path((stack_id, key)): Path<(Uuid, String)>,
) -> Result<StatusCode, ApiError> {
fetch_owned_stack(&dal, &auth_payload, stack_id).await?;
let deleted = dal
.stack_annotations()
.delete_by_stack_and_key(stack_id, &key)
.map_err(|_| ApiError::internal("failed to remove stack annotation"))?;
if deleted > 0 {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found(
"stack_annotation_not_found",
"annotation not found",
))
}
}

pub

async fn list_telemetry_events(
State(dal): State<DAL>,
Extension(auth): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
axum::extract::Query(q): axum::extract::Query<TelemetryHistoryQuery>,
) -> Result<Json<K8sEventHistoryResponse>, ApiError>
Source
pub async fn list_telemetry_events(
State(dal): State<DAL>,
Extension(auth): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
axum::extract::Query(q): axum::extract::Query<TelemetryHistoryQuery>,
) -> Result<Json<K8sEventHistoryResponse>, ApiError> {
fetch_owned_stack(&dal, &auth, stack_id).await?;
let since = clamp_since(q.since);
let limit = clamp_limit(q.limit);
let events = dal
.agent_k8s_events()
.list_for_stack(stack_id, since, limit)
.map_err(|e| {
error!("failed to list k8s events for stack {}: {:?}", stack_id, e);
ApiError::internal("failed to list telemetry events")
})?;
let oldest = events.last().map(|e| e.created_at);
Ok(Json(K8sEventHistoryResponse {
retention: retention_info(oldest),
events,
}))
}

pub

async fn list_telemetry_logs(
State(dal): State<DAL>,
Extension(auth): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
axum::extract::Query(q): axum::extract::Query<TelemetryHistoryQuery>,
) -> Result<Json<PodLogHistoryResponse>, ApiError>
Source
pub async fn list_telemetry_logs(
State(dal): State<DAL>,
Extension(auth): Extension<AuthPayload>,
Path(stack_id): Path<Uuid>,
axum::extract::Query(q): axum::extract::Query<TelemetryHistoryQuery>,
) -> Result<Json<PodLogHistoryResponse>, ApiError> {
fetch_owned_stack(&dal, &auth, stack_id).await?;
let since = clamp_since(q.since);
let limit = clamp_limit(q.limit);
let lines = dal
.agent_pod_logs()
.list_for_stack(stack_id, since, limit)
.map_err(|e| {
error!("failed to list pod logs for stack {}: {:?}", stack_id, e);
ApiError::internal("failed to list telemetry logs")
})?;
let oldest = lines.last().map(|l| l.created_at);
Ok(Json(PodLogHistoryResponse {
retention: retention_info(oldest),
lines,
}))
}