feat(werkbank): make the loop runnable — enqueue + artifact serve/fetch (WB-05b) (#209)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m43s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped

This commit was merged in pull request #209.
This commit is contained in:
2026-07-17 14:34:00 +00:00
parent 70a4ee55ab
commit 94c0d51a11
6 changed files with 208 additions and 6 deletions
@@ -10,18 +10,20 @@
//! so a job run by a remote runner lands the same findings an in-process run
//! would (WB-05, the control-plane cut-over).
use axum::extract::{Extension, Request};
use axum::extract::{Extension, Path, Request};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use mongodb::bson::doc;
use mongodb::bson::{doc, oid::ObjectId};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use compliance_core::models::werkbank::{
CompleteRequest, CompleteResponse, HeartbeatRequest, JobResult, LeaseRequest,
CompleteRequest, CompleteResponse, HeartbeatRequest, InputRef, Job, JobResult, LeaseRequest,
};
use compliance_core::models::ArtifactKind;
use super::dto::AgentExt;
use crate::database::Database;
@@ -122,6 +124,100 @@ pub async fn complete(
Ok(Json(CompleteResponse { recorded }))
}
/// `GET /api/v1/werkbank/artifacts/{hash}` — serve a content-addressed blob (the
/// program a runner needs to load). The hash is validated against traversal by
/// [`crate::ingest::blob::read_blob`]; a runner fetches this for a job's `blob`
/// input.
#[tracing::instrument(skip_all, fields(hash = %hash))]
pub async fn serve_artifact(
Extension(agent): AgentExt,
Path(hash): Path<String>,
) -> Result<Response, StatusCode> {
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
match crate::ingest::blob::read_blob(base, &hash) {
Ok(bytes) => {
Ok(([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
}
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
/// Enqueue a `plc-provision` job for a target: extract its control-logic program,
/// stash it as a content-addressed blob (which the runner fetches via
/// [`serve_artifact`]), and queue the job. This is the control-plane "enqueue"
/// half of the loop — a runner then leases it, provisions, and posts results.
#[derive(Debug, Deserialize)]
pub struct EnqueueRequest {
/// The tenant whose queue to enqueue into.
pub tenant: String,
/// The onboarded target to test.
pub target_id: String,
}
/// The enqueued job's id.
#[derive(Debug, Serialize)]
pub struct EnqueueResponse {
/// The new job id.
pub job_id: String,
/// Whether this call inserted it (false = already queued).
pub enqueued: bool,
}
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, target = %req.target_id))]
pub async fn enqueue(
Extension(agent): AgentExt,
Json(req): Json<EnqueueRequest>,
) -> Result<Json<EnqueueResponse>, StatusCode> {
let db = tenant_db(&agent, &req.tenant).await?;
let oid = ObjectId::parse_str(&req.target_id).map_err(|_| StatusCode::BAD_REQUEST)?;
let target = db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(internal)?
.ok_or(StatusCode::NOT_FOUND)?;
// Extract the control-logic program from the target's PLC-source artifacts
// (same selection as the in-process PLC scan).
let ctx = crate::ingest::IngestContext::from_config(&agent.config, &req.target_id);
let ingest_set = crate::ingest::ingest_all(&target, &ctx).map_err(internal)?;
let program = target
.artifacts
.iter()
.filter(|a| {
matches!(
a.kind,
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
)
})
.find_map(|a| {
let path = ingest_set
.get(&a.id)
.and_then(|ia| ia.working_path.clone())?;
werkbank_exec::plc::extract_program(&path)
})
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
// Stash the program source so the runner can fetch it by hash.
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
let hash =
crate::ingest::blob::store_bytes(base, program.source.as_bytes()).map_err(internal)?;
let job_id = format!("job_{}", uuid::Uuid::new_v4().simple());
let job = Job::plc_provision(
&job_id,
&req.tenant,
&req.target_id,
InputRef::blob(hash),
agent.config.plc_runtime.max_lifetime_secs,
);
let enqueued = JobQueue::new(&db)
.enqueue(job, chrono::Utc::now())
.await
.map_err(internal)?;
Ok(Json(EnqueueResponse { job_id, enqueued }))
}
/// Persist a job result's findings against its target: general findings
/// (dedup'd by fingerprint) and DAST findings. Best-effort — a persistence hiccup
/// is logged, not surfaced to the runner (its result is already recorded).
+8
View File
@@ -91,6 +91,14 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A
"/api/v1/werkbank/jobs/complete",
post(handlers::werkbank_jobs::complete),
)
.route(
"/api/v1/werkbank/jobs/enqueue",
post(handlers::werkbank_jobs::enqueue),
)
.route(
"/api/v1/werkbank/artifacts/{hash}",
get(handlers::werkbank_jobs::serve_artifact),
)
.layer(middleware::from_fn(
handlers::werkbank_jobs::require_runner_token,
))