feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix (#163)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s
This commit was merged in pull request #163.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::extract::{Extension, Multipart, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::{doc, oid::ObjectId, to_bson};
|
||||
@@ -377,6 +377,116 @@ pub async fn add_artifact(
|
||||
get_target(Extension(agent), tenant, Path(id)).await
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading
|
||||
/// its file (PLC project, firmware image, source archive, mobile package). The
|
||||
/// bytes are written to the artifact blob store and referenced by `stored_path`,
|
||||
/// so ingest resolves them locally (no URL fetch).
|
||||
///
|
||||
/// Multipart fields: `file` (required), `kind` (required, snake_case
|
||||
/// `ArtifactKind`), `plc_format` (optional, for PLC projects).
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn upload_artifact(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
if db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let mut kind: Option<ArtifactKind> = None;
|
||||
let mut plc_format: Option<PlcFormat> = None;
|
||||
let mut filename = String::from("upload.bin");
|
||||
let mut bytes: Option<axum::body::Bytes> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
{
|
||||
match field.name().unwrap_or("") {
|
||||
"kind" => {
|
||||
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
kind = parse_enum(&v);
|
||||
}
|
||||
"plc_format" => {
|
||||
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
plc_format = parse_enum(&v);
|
||||
}
|
||||
"file" => {
|
||||
if let Some(fname) = field.file_name() {
|
||||
filename = fname.to_string();
|
||||
}
|
||||
bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (Some(kind), Some(bytes)) = (kind, bytes) else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
// Store the uploaded bytes under the artifact blob store.
|
||||
let safe_name: String = filename
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let dir = std::path::Path::new(&agent.config.artifact_store_base_path)
|
||||
.join("uploads")
|
||||
.join(&id);
|
||||
std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Build the artifact for this kind, referencing the stored file.
|
||||
let mut artifact = match kind {
|
||||
ArtifactKind::PlcProject => Artifact::plc_project(
|
||||
filename.clone(),
|
||||
plc_format.unwrap_or(PlcFormat::PlcopenXml),
|
||||
),
|
||||
ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()),
|
||||
ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()),
|
||||
ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()),
|
||||
// Non-file kinds (git repo, live URL, container ref, text) use the JSON
|
||||
// add-artifact endpoint, not upload.
|
||||
_ => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
artifact.stored_path = Some(dest.to_string_lossy().to_string());
|
||||
artifact.size_bytes = Some(bytes.len() as u64);
|
||||
|
||||
let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
db.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
get_target(Extension(agent), tenant, Path(id)).await
|
||||
}
|
||||
|
||||
/// Deserialize a snake_case enum value from a plain string.
|
||||
fn parse_enum<T: for<'de> Deserialize<'de>>(s: &str) -> Option<T> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
|
||||
}
|
||||
|
||||
/// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn applicable_scans_for_target(
|
||||
|
||||
@@ -26,6 +26,10 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/targets/{id}/artifacts",
|
||||
post(handlers::onboarding::add_artifact),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/artifacts/upload",
|
||||
post(handlers::onboarding::upload_artifact),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/applicable-scans",
|
||||
get(handlers::onboarding::applicable_scans_for_target),
|
||||
|
||||
Reference in New Issue
Block a user