diff --git a/Cargo.toml b/Cargo.toml index cca9e38c..f21d2d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ secrecy = "0.10.3" serial_test = "3.2.0" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.132" +serde_yml = "0.0.12" serde_with = { version = "3.12.0", features = ["base64"] } sha2 = "0.10" sysinfo = "0.38.0" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 9d993e04..a1396b9a 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -27,6 +27,7 @@ rumqttc = { workspace = true } secrecy = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yml = { workspace = true } sysinfo = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } diff --git a/agent/src/server/handlers.rs b/agent/src/server/handlers.rs index a0b8f396..7e67ad52 100644 --- a/agent/src/server/handlers.rs +++ b/agent/src/server/handlers.rs @@ -6,20 +6,20 @@ use std::sync::Arc; use crate::errors::Error; use crate::server::{errors::*, state::State}; use crate::services::{ - deployment as dpl_svc, device as dvc_svc, git_commit as git_cmt_svc, release as rls_svc, - HttpBackend, + config_instance as ci_svc, deployment as dpl_svc, device as dvc_svc, git_commit as git_cmt_svc, + release as rls_svc, HttpBackend, }; use crate::version; use device_api::models as device_server; // external crates use axum::{ - extract::{Path, State as AxumState}, + extract::{Path, Query, State as AxumState}, http::StatusCode, response::IntoResponse, Json, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tracing::error; @@ -145,6 +145,129 @@ pub async fn get_git_commit( .await } +// ============================= CONFIG INSTANCES ================================= // +#[derive(Deserialize)] +pub struct ExpandQuery { + pub expand: Option, +} + +pub async fn get_config_instance( + AxumState(state): AxumState>, + Path(config_instance_id): Path, + Query(query): Query, +) -> impl IntoResponse { + handle( + async move { + let expand_content = query + .expand + .as_deref() + .map(|v| v == "content") + .unwrap_or(false); + let response = ci_svc::get_response( + &state.storage.cfg_insts.meta, + &state.storage.cfg_insts.content, + config_instance_id, + expand_content, + ) + .await?; + Ok::<_, ServerErr>(response) + }, + "Error getting config instance", + ) + .await +} + +pub async fn get_config_instance_content( + AxumState(state): AxumState>, + Path(config_instance_id): Path, +) -> impl IntoResponse { + let result = ci_svc::get_raw_content( + &state.storage.cfg_insts.meta, + &state.storage.cfg_insts.content, + config_instance_id, + ) + .await; + + match result { + Ok((ci, content)) => { + let format = ci_svc::infer_format(&ci.filepath); + let content_type = match format { + "yaml" => "application/x-yaml", + _ => "application/json", + }; + let filename = ci + .filepath + .rsplit('/') + .next() + .unwrap_or(&ci.filepath) + .to_string(); + let disposition = format!("attachment; filename=\"{filename}\""); + + ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + (axum::http::header::CONTENT_DISPOSITION, disposition), + ], + content, + ) + .into_response() + } + Err(e) => { + let e: ServerErr = e.into(); + error!("Error getting config instance content: {e:?}"); + let error_response = to_error_response(&e); + (e.http_status(), Json(json!(error_response))).into_response() + } + } +} + +#[derive(Deserialize)] +pub struct PrefixQuery { + pub prefix: Option, +} + +pub async fn get_config_instance_parameter( + AxumState(state): AxumState>, + Path((config_instance_id, key)): Path<(String, String)>, +) -> impl IntoResponse { + handle( + async move { + let response = ci_svc::get_parameter( + &state.storage.cfg_insts.meta, + &state.storage.cfg_insts.content, + config_instance_id, + key, + ) + .await?; + Ok::<_, ServerErr>(response) + }, + "Error getting config instance parameter", + ) + .await +} + +pub async fn list_config_instance_parameters( + AxumState(state): AxumState>, + Path(config_instance_id): Path, + Query(query): Query, +) -> impl IntoResponse { + handle( + async move { + let response = ci_svc::list_parameters( + &state.storage.cfg_insts.meta, + &state.storage.cfg_insts.content, + config_instance_id, + query.prefix, + ) + .await?; + Ok::<_, ServerErr>(response) + }, + "Error listing config instance parameters", + ) + .await +} + // ================================ UTILITIES ====================================== // async fn handle(service: F, err_msg: &str) -> (StatusCode, Json) where @@ -157,12 +280,12 @@ where Err(e) => { let e: ServerErr = e.into(); error!("{err_msg}: {e:?}"); - (e.http_status(), Json(json!(to_error_response(e)))) + (e.http_status(), Json(json!(to_error_response(&e)))) } } } -fn to_error_response(e: impl Error) -> device_server::ErrorResponse { +fn to_error_response(e: &(impl Error + ?Sized)) -> device_server::ErrorResponse { let params = e .params() .and_then(|v| serde_json::from_value(v).ok()) diff --git a/agent/src/server/mod.rs b/agent/src/server/mod.rs index 4f2830f6..83a87e01 100644 --- a/agent/src/server/mod.rs +++ b/agent/src/server/mod.rs @@ -1,6 +1,7 @@ pub mod errors; pub mod handlers; pub mod response; +pub mod responses; pub mod serve; pub mod sse; pub mod state; diff --git a/agent/src/server/responses/config_instance.rs b/agent/src/server/responses/config_instance.rs new file mode 100644 index 00000000..748ff1e2 --- /dev/null +++ b/agent/src/server/responses/config_instance.rs @@ -0,0 +1,75 @@ +// internal crates +use crate::models; + +// external crates +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ========================== CONFIG INSTANCE ============================== // +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConfigInstanceResponse { + pub object: String, + pub id: String, + pub config_type_name: String, + pub filepath: String, + pub created_at: String, + pub config_schema_id: String, + pub config_type_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ContentField { + pub format: String, + pub data: String, +} + +impl ConfigInstanceResponse { + pub fn from_model(ci: &models::ConfigInstance, content: Option) -> Self { + Self { + object: "config_instance".to_string(), + id: ci.id.clone(), + config_type_name: ci.config_type_name.clone(), + filepath: ci.filepath.clone(), + created_at: ci.created_at.to_rfc3339(), + config_schema_id: ci.config_schema_id.clone(), + config_type_id: ci.config_type_id.clone(), + content, + } + } +} + +// ============================ PARAMETER ================================== // +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ParameterResponse { + pub object: String, + pub key: Vec, + pub value: Value, +} + +impl ParameterResponse { + pub fn new(key: Vec, value: Value) -> Self { + Self { + object: "parameter".to_string(), + key, + value, + } + } +} + +// ========================= PARAMETER LIST ================================ // +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ParameterListResponse { + pub object: String, + pub data: Vec, +} + +impl ParameterListResponse { + pub fn new(data: Vec) -> Self { + Self { + object: "list".to_string(), + data, + } + } +} diff --git a/agent/src/server/responses/mod.rs b/agent/src/server/responses/mod.rs new file mode 100644 index 00000000..54dd32ec --- /dev/null +++ b/agent/src/server/responses/mod.rs @@ -0,0 +1 @@ +pub mod config_instance; diff --git a/agent/src/server/serve.rs b/agent/src/server/serve.rs index 31bc205a..6e052842 100644 --- a/agent/src/server/serve.rs +++ b/agent/src/server/serve.rs @@ -89,6 +89,24 @@ pub fn routes(state: Arc) -> Router { format!("/{api_version}/git_commits/{{git_commit_id}}").as_str(), get(handlers::get_git_commit), ) + // =========================== CONFIG INSTANCES ============================ // + .route( + format!("/{api_version}/config_instances/{{config_instance_id}}").as_str(), + get(handlers::get_config_instance), + ) + .route( + format!("/{api_version}/config_instances/{{config_instance_id}}/content").as_str(), + get(handlers::get_config_instance_content), + ) + .route( + format!("/{api_version}/config_instances/{{config_instance_id}}/parameters").as_str(), + get(handlers::list_config_instance_parameters), + ) + .route( + format!("/{api_version}/config_instances/{{config_instance_id}}/parameters/{{key}}") + .as_str(), + get(handlers::get_config_instance_parameter), + ) // ============================== EVENTS =================================== // .route( format!("/{api_version}/events").as_str(), diff --git a/agent/src/services/config_instance/.covgate b/agent/src/services/config_instance/.covgate new file mode 100644 index 00000000..265fbfb0 --- /dev/null +++ b/agent/src/services/config_instance/.covgate @@ -0,0 +1 @@ +80.00 diff --git a/agent/src/services/config_instance/get.rs b/agent/src/services/config_instance/get.rs new file mode 100644 index 00000000..037a5f77 --- /dev/null +++ b/agent/src/services/config_instance/get.rs @@ -0,0 +1,89 @@ +// internal crates +use crate::errors::Trace; +use crate::models; +use crate::server::responses::config_instance::{ConfigInstanceResponse, ContentField}; +use crate::services::errors::ServiceErr; +use crate::storage; + +// ========================= ERRORS ======================================= // +#[derive(Debug, thiserror::Error)] +#[error("config instance not found: {id}")] +pub struct ConfigInstanceNotFoundErr { + pub id: String, + pub trace: Box, +} + +impl crate::errors::Error for ConfigInstanceNotFoundErr { + fn code(&self) -> crate::errors::Code { + crate::errors::Code::ResourceNotFound + } + fn http_status(&self) -> crate::errors::HTTPCode { + crate::errors::HTTPCode::NOT_FOUND + } +} + +// ========================= SERVICE ====================================== // +pub async fn get( + cfg_insts: &storage::CfgInsts, + id: String, +) -> Result { + let ci = cfg_insts.read_optional(id.clone()).await?; + match ci { + Some(ci) => Ok(ci), + None => Err(ServiceErr::ConfigInstanceNotFoundErr( + ConfigInstanceNotFoundErr { + id, + trace: crate::trace!(), + }, + )), + } +} + +pub async fn get_response( + cfg_insts: &storage::CfgInsts, + cfg_inst_content: &storage::CfgInstContent, + id: String, + expand_content: bool, +) -> Result { + let ci = get(cfg_insts, id.clone()).await?; + let content = if expand_content { + let raw = cfg_inst_content.read_optional(id).await?; + raw.map(|data| ContentField { + format: infer_format(&ci.filepath).to_string(), + data, + }) + } else { + None + }; + Ok(ConfigInstanceResponse::from_model(&ci, content)) +} + +pub async fn get_raw_content( + cfg_insts: &storage::CfgInsts, + cfg_inst_content: &storage::CfgInstContent, + id: String, +) -> Result<(models::ConfigInstance, String), ServiceErr> { + let ci = get(cfg_insts, id.clone()).await?; + let content = cfg_inst_content.read_optional(id.clone()).await?; + match content { + Some(data) => Ok((ci, data)), + None => Err(ServiceErr::ConfigInstanceNotFoundErr( + ConfigInstanceNotFoundErr { + id, + trace: crate::trace!(), + }, + )), + } +} + +pub fn infer_format(filepath: &str) -> &'static str { + if let Some(ext) = filepath.rsplit('.').next() { + match ext { + "yaml" | "yml" => "yaml", + "json" => "json", + _ => "json", + } + } else { + "json" + } +} diff --git a/agent/src/services/config_instance/mod.rs b/agent/src/services/config_instance/mod.rs new file mode 100644 index 00000000..f296ed37 --- /dev/null +++ b/agent/src/services/config_instance/mod.rs @@ -0,0 +1,5 @@ +pub mod get; +pub mod parameter; + +pub use get::*; +pub use parameter::*; diff --git a/agent/src/services/config_instance/parameter.rs b/agent/src/services/config_instance/parameter.rs new file mode 100644 index 00000000..77f780ef --- /dev/null +++ b/agent/src/services/config_instance/parameter.rs @@ -0,0 +1,139 @@ +// internal crates +use crate::errors::Trace; +use crate::server::responses::config_instance::{ParameterListResponse, ParameterResponse}; +use crate::services::{config_instance::get, errors::ServiceErr}; +use crate::storage; + +// external crates +use serde_json::Value; + +// ========================= ERRORS ======================================= // +#[derive(Debug, thiserror::Error)] +#[error("parameter not found: {key}")] +pub struct ParameterNotFoundErr { + pub key: String, + pub trace: Box, +} + +impl crate::errors::Error for ParameterNotFoundErr { + fn code(&self) -> crate::errors::Code { + crate::errors::Code::ResourceNotFound + } + fn http_status(&self) -> crate::errors::HTTPCode { + crate::errors::HTTPCode::NOT_FOUND + } +} + +#[derive(Debug, thiserror::Error)] +#[error("failed to parse config content for config instance {id}: {reason}")] +pub struct ContentParseErr { + pub id: String, + pub reason: String, + pub trace: Box, +} + +impl crate::errors::Error for ContentParseErr {} + +// ========================= SERVICE ====================================== // +pub async fn get_parameter( + cfg_insts: &storage::CfgInsts, + cfg_inst_content: &storage::CfgInstContent, + id: String, + key: String, +) -> Result { + let (ci, raw_content) = get::get_raw_content(cfg_insts, cfg_inst_content, id.clone()).await?; + let parsed = parse_content(&ci.filepath, &raw_content, &id)?; + let parts: Vec<&str> = key.split('.').collect(); + + let value = navigate(&parsed, &parts).ok_or_else(|| { + ServiceErr::ParameterNotFoundErr(ParameterNotFoundErr { + key: key.clone(), + trace: crate::trace!(), + }) + })?; + + let key_parts = parts.into_iter().map(|s| s.to_string()).collect(); + Ok(ParameterResponse::new(key_parts, value.clone())) +} + +pub async fn list_parameters( + cfg_insts: &storage::CfgInsts, + cfg_inst_content: &storage::CfgInstContent, + id: String, + prefix: Option, +) -> Result { + let (ci, raw_content) = get::get_raw_content(cfg_insts, cfg_inst_content, id.clone()).await?; + let parsed = parse_content(&ci.filepath, &raw_content, &id)?; + + let mut params = Vec::new(); + flatten_value(&parsed, &[], &mut params); + + if let Some(ref prefix) = prefix { + let prefix_parts: Vec<&str> = prefix.split('.').collect(); + params.retain(|p| { + p.key.len() >= prefix_parts.len() + && p.key.iter().zip(prefix_parts.iter()).all(|(a, b)| a == b) + }); + } + + Ok(ParameterListResponse::new(params)) +} + +fn parse_content(filepath: &str, raw: &str, id: &str) -> Result { + let format = get::infer_format(filepath); + match format { + "yaml" => serde_yml::from_str::(raw).map_err(|e| { + ServiceErr::ContentParseErr(ContentParseErr { + id: id.to_string(), + reason: e.to_string(), + trace: crate::trace!(), + }) + }), + _ => serde_json::from_str::(raw).map_err(|e| { + ServiceErr::ContentParseErr(ContentParseErr { + id: id.to_string(), + reason: e.to_string(), + trace: crate::trace!(), + }) + }), + } +} + +fn navigate<'a>(value: &'a Value, parts: &[&str]) -> Option<&'a Value> { + let mut current = value; + for part in parts { + match current { + Value::Object(map) => { + current = map.get(*part)?; + } + Value::Array(arr) => { + let idx: usize = part.parse().ok()?; + current = arr.get(idx)?; + } + _ => return None, + } + } + Some(current) +} + +fn flatten_value(value: &Value, prefix: &[String], out: &mut Vec) { + match value { + Value::Object(map) => { + for (k, v) in map { + let mut new_prefix = prefix.to_vec(); + new_prefix.push(k.clone()); + flatten_value(v, &new_prefix, out); + } + } + Value::Array(arr) => { + for (i, v) in arr.iter().enumerate() { + let mut new_prefix = prefix.to_vec(); + new_prefix.push(i.to_string()); + flatten_value(v, &new_prefix, out); + } + } + _ => { + out.push(ParameterResponse::new(prefix.to_vec(), value.clone())); + } + } +} diff --git a/agent/src/services/errors.rs b/agent/src/services/errors.rs index fd2794d3..4e3391d3 100644 --- a/agent/src/services/errors.rs +++ b/agent/src/services/errors.rs @@ -4,6 +4,10 @@ use crate::events; use crate::filesys; use crate::http; use crate::models; +use crate::services::config_instance::{ + get::ConfigInstanceNotFoundErr, + parameter::{ContentParseErr, ParameterNotFoundErr}, +}; use crate::storage::StorageErr; use crate::sync; @@ -12,10 +16,16 @@ pub enum ServiceErr { #[error(transparent)] CacheErr(cache::CacheErr), #[error(transparent)] + ConfigInstanceNotFoundErr(ConfigInstanceNotFoundErr), + #[error(transparent)] + ContentParseErr(ContentParseErr), + #[error(transparent)] FileSysErr(filesys::FileSysErr), #[error(transparent)] ModelsErr(models::ModelsErr), #[error(transparent)] + ParameterNotFoundErr(ParameterNotFoundErr), + #[error(transparent)] StorageErr(StorageErr), #[error(transparent)] HTTPErr(http::HTTPErr), @@ -31,6 +41,18 @@ impl From for ServiceErr { } } +impl From for ServiceErr { + fn from(e: ConfigInstanceNotFoundErr) -> Self { + Self::ConfigInstanceNotFoundErr(e) + } +} + +impl From for ServiceErr { + fn from(e: ContentParseErr) -> Self { + Self::ContentParseErr(e) + } +} + impl From for ServiceErr { fn from(e: filesys::FileSysErr) -> Self { Self::FileSysErr(e) @@ -43,6 +65,12 @@ impl From for ServiceErr { } } +impl From for ServiceErr { + fn from(e: ParameterNotFoundErr) -> Self { + Self::ParameterNotFoundErr(e) + } +} + impl From for ServiceErr { fn from(e: StorageErr) -> Self { Self::StorageErr(e) @@ -69,9 +97,12 @@ impl From for ServiceErr { crate::impl_error!(ServiceErr { CacheErr, + ConfigInstanceNotFoundErr, + ContentParseErr, EventsErr, FileSysErr, ModelsErr, + ParameterNotFoundErr, StorageErr, HTTPErr, SyncErr, diff --git a/agent/src/services/mod.rs b/agent/src/services/mod.rs index 0f686932..9cefc4dd 100644 --- a/agent/src/services/mod.rs +++ b/agent/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +pub mod config_instance; pub mod deployment; pub mod device; pub mod errors; diff --git a/agent/tests/server/handlers.rs b/agent/tests/server/handlers.rs index 16e4ac03..771f3ced 100644 --- a/agent/tests/server/handlers.rs +++ b/agent/tests/server/handlers.rs @@ -468,4 +468,337 @@ pub mod routes { assert_eq!(actual.error.code, "internal_server_error"); } } + + mod config_instances { + use super::*; + use miru_agent::models::ConfigInstance; + use miru_agent::server::responses::config_instance::{ + ConfigInstanceResponse, ParameterListResponse, ParameterResponse, + }; + + fn sample_ci(id: &str, filepath: &str) -> ConfigInstance { + ConfigInstance { + id: id.to_string(), + config_type_name: "robot_config".to_string(), + filepath: filepath.to_string(), + created_at: fixed_time(), + config_schema_id: "schema-1".to_string(), + config_type_id: "type-1".to_string(), + } + } + + #[tokio::test] + async fn get_config_instance_returns_200() { + let f = Fixture::new("handler_get_ci").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + + let (status, bytes) = f.get("/v0.2/config_instances/ci-1").await; + assert_eq!(status, StatusCode::OK); + + let actual: ConfigInstanceResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.object, "config_instance"); + assert_eq!(actual.id, "ci-1"); + assert!(actual.content.is_none()); + } + + #[tokio::test] + async fn get_config_instance_with_expand_content() { + let f = Fixture::new("handler_get_ci_expand").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"key":"value"}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (status, bytes) = f.get("/v0.2/config_instances/ci-1?expand=content").await; + assert_eq!(status, StatusCode::OK); + + let actual: ConfigInstanceResponse = serde_json::from_slice(&bytes).unwrap(); + assert!(actual.content.is_some()); + let content = actual.content.unwrap(); + assert_eq!(content.format, "json"); + assert_eq!(content.data, r#"{"key":"value"}"#); + } + + #[tokio::test] + async fn get_config_instance_returns_404_when_missing() { + let f = Fixture::new("handler_get_ci_404").await; + + let (status, bytes) = f.get("/v0.2/config_instances/nonexistent").await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let actual: openapi::ErrorResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.error.code, "resource_not_found"); + } + + #[tokio::test] + async fn get_content_returns_200_with_json() { + let f = Fixture::new("handler_get_ci_content_json").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"hello":"world"}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let response = f + .app + .clone() + .oneshot( + Request::builder() + .uri("/v0.2/config_instances/ci-1/content") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json" + ); + assert!(response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap() + .contains("config.json")); + + let bytes = body::to_bytes(response.into_body(), 16384).await.unwrap(); + assert_eq!(bytes.as_ref(), b"{\"hello\":\"world\"}"); + } + + #[tokio::test] + async fn get_content_returns_200_with_yaml() { + let f = Fixture::new("handler_get_ci_content_yaml").await; + let ci = sample_ci("ci-1", "/etc/miru/robot.yaml"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + "key: value\n".to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let response = f + .app + .clone() + .oneshot( + Request::builder() + .uri("/v0.2/config_instances/ci-1/content") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/x-yaml" + ); + assert!(response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap() + .contains("robot.yaml")); + } + + #[tokio::test] + async fn get_content_returns_404_when_missing() { + let f = Fixture::new("handler_get_ci_content_404").await; + + let (status, bytes) = f.get("/v0.2/config_instances/nonexistent/content").await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let actual: openapi::ErrorResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.error.code, "resource_not_found"); + } + + #[tokio::test] + async fn get_parameter_returns_200() { + let f = Fixture::new("handler_get_ci_param").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"network":{"timeout_ms":5000}}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (status, bytes) = f + .get("/v0.2/config_instances/ci-1/parameters/network.timeout_ms") + .await; + assert_eq!(status, StatusCode::OK); + + let actual: ParameterResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.object, "parameter"); + assert_eq!(actual.key, vec!["network", "timeout_ms"]); + assert_eq!(actual.value, serde_json::json!(5000)); + } + + #[tokio::test] + async fn get_parameter_returns_404_when_key_missing() { + let f = Fixture::new("handler_get_ci_param_404").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"a":1}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (status, bytes) = f + .get("/v0.2/config_instances/ci-1/parameters/nonexistent") + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let actual: openapi::ErrorResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.error.code, "resource_not_found"); + } + + #[tokio::test] + async fn list_parameters_returns_200() { + let f = Fixture::new("handler_list_ci_params").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"a":1,"b":{"c":2}}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (status, bytes) = f.get("/v0.2/config_instances/ci-1/parameters").await; + assert_eq!(status, StatusCode::OK); + + let actual: ParameterListResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.object, "list"); + assert_eq!(actual.data.len(), 2); + } + + #[tokio::test] + async fn list_parameters_with_prefix_filter() { + let f = Fixture::new("handler_list_ci_params_prefix").await; + let ci = sample_ci("ci-1", "config.json"); + f.state + .storage + .cfg_insts + .meta + .write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + f.state + .storage + .cfg_insts + .content + .write( + "ci-1".to_string(), + r#"{"a":1,"b":{"c":2,"d":3}}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (status, bytes) = f + .get("/v0.2/config_instances/ci-1/parameters?prefix=b") + .await; + assert_eq!(status, StatusCode::OK); + + let actual: ParameterListResponse = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(actual.data.len(), 2); + for p in &actual.data { + assert_eq!(p.key[0], "b"); + } + } + } } diff --git a/agent/tests/services/config_instance/get.rs b/agent/tests/services/config_instance/get.rs new file mode 100644 index 00000000..3f7b1c3c --- /dev/null +++ b/agent/tests/services/config_instance/get.rs @@ -0,0 +1,223 @@ +// internal crates +use miru_agent::filesys::{self, Overwrite}; +use miru_agent::models::ConfigInstance; +use miru_agent::services::config_instance as ci_svc; +use miru_agent::services::ServiceErr; +use miru_agent::storage::{CfgInstContent, CfgInsts}; + +// external crates +use chrono::{DateTime, Utc}; + +async fn setup(name: &str) -> (filesys::Dir, CfgInsts, CfgInstContent) { + let dir = filesys::Dir::create_temp_dir(name).await.unwrap(); + let (meta, _) = CfgInsts::spawn(16, dir.file("cfg_inst_meta.json"), 1000) + .await + .unwrap(); + let (content, _) = CfgInstContent::spawn(16, dir.subdir("cfg_inst_content"), 1000) + .await + .unwrap(); + (dir, meta, content) +} + +fn sample_ci(id: &str, filepath: &str) -> ConfigInstance { + ConfigInstance { + id: id.to_string(), + config_type_name: "robot_config".to_string(), + filepath: filepath.to_string(), + created_at: DateTime::::UNIX_EPOCH, + config_schema_id: "schema-1".to_string(), + config_type_id: "type-1".to_string(), + } +} + +pub mod get_config_instance { + use super::*; + + #[tokio::test] + async fn returns_config_instance_by_id() { + let (_dir, meta, _content) = setup("ci_get_by_id").await; + let ci = sample_ci("ci-1", "config.json"); + meta.write( + "ci-1".to_string(), + ci.clone(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let result = ci_svc::get(&meta, "ci-1".to_string()).await.unwrap(); + assert_eq!(result.id, "ci-1"); + assert_eq!(result.config_type_name, "robot_config"); + } + + #[tokio::test] + async fn returns_not_found_when_missing() { + let (_dir, meta, _content) = setup("ci_get_not_found").await; + + let result = ci_svc::get(&meta, "nonexistent".to_string()).await; + assert!(matches!( + result, + Err(ServiceErr::ConfigInstanceNotFoundErr(_)) + )); + } +} + +pub mod get_response_tests { + use super::*; + + #[tokio::test] + async fn returns_response_without_content() { + let (_dir, meta, content) = setup("ci_resp_no_content").await; + let ci = sample_ci("ci-1", "config.json"); + meta.write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + + let resp = ci_svc::get_response(&meta, &content, "ci-1".to_string(), false) + .await + .unwrap(); + assert_eq!(resp.object, "config_instance"); + assert_eq!(resp.id, "ci-1"); + assert!(resp.content.is_none()); + } + + #[tokio::test] + async fn returns_response_with_content() { + let (_dir, meta, content) = setup("ci_resp_with_content").await; + let ci = sample_ci("ci-1", "config.json"); + meta.write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + content + .write( + "ci-1".to_string(), + r#"{"key": "value"}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let resp = ci_svc::get_response(&meta, &content, "ci-1".to_string(), true) + .await + .unwrap(); + assert_eq!(resp.object, "config_instance"); + assert!(resp.content.is_some()); + let cf = resp.content.unwrap(); + assert_eq!(cf.format, "json"); + assert_eq!(cf.data, r#"{"key": "value"}"#); + } + + #[tokio::test] + async fn returns_yaml_format_for_yaml_file() { + let (_dir, meta, content) = setup("ci_resp_yaml").await; + let ci = sample_ci("ci-1", "config.yaml"); + meta.write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + content + .write( + "ci-1".to_string(), + "key: value\n".to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let resp = ci_svc::get_response(&meta, &content, "ci-1".to_string(), true) + .await + .unwrap(); + let cf = resp.content.unwrap(); + assert_eq!(cf.format, "yaml"); + } +} + +pub mod get_raw_content_tests { + use super::*; + + #[tokio::test] + async fn returns_content_when_present() { + let (_dir, meta, content) = setup("ci_raw_content").await; + let ci = sample_ci("ci-1", "config.json"); + meta.write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + content + .write( + "ci-1".to_string(), + r#"{"hello":"world"}"#.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); + + let (ci, data) = ci_svc::get_raw_content(&meta, &content, "ci-1".to_string()) + .await + .unwrap(); + assert_eq!(ci.id, "ci-1"); + assert_eq!(data, r#"{"hello":"world"}"#); + } + + #[tokio::test] + async fn returns_not_found_when_meta_missing() { + let (_dir, meta, content) = setup("ci_raw_no_meta").await; + + let result = ci_svc::get_raw_content(&meta, &content, "missing".to_string()).await; + assert!(matches!( + result, + Err(ServiceErr::ConfigInstanceNotFoundErr(_)) + )); + } + + #[tokio::test] + async fn returns_not_found_when_content_missing() { + let (_dir, meta, content) = setup("ci_raw_no_content").await; + let ci = sample_ci("ci-1", "config.json"); + meta.write("ci-1".to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + + let result = ci_svc::get_raw_content(&meta, &content, "ci-1".to_string()).await; + assert!(matches!( + result, + Err(ServiceErr::ConfigInstanceNotFoundErr(_)) + )); + } +} + +pub mod infer_format_tests { + use super::*; + + #[test] + fn json_extension() { + assert_eq!(ci_svc::infer_format("config.json"), "json"); + } + + #[test] + fn yaml_extension() { + assert_eq!(ci_svc::infer_format("config.yaml"), "yaml"); + } + + #[test] + fn yml_extension() { + assert_eq!(ci_svc::infer_format("config.yml"), "yaml"); + } + + #[test] + fn unknown_extension_defaults_to_json() { + assert_eq!(ci_svc::infer_format("config.toml"), "json"); + } + + #[test] + fn no_extension_defaults_to_json() { + assert_eq!(ci_svc::infer_format("config"), "json"); + } + + #[test] + fn nested_path_uses_last_extension() { + assert_eq!(ci_svc::infer_format("/etc/miru/robot.yaml"), "yaml"); + } +} diff --git a/agent/tests/services/config_instance/mod.rs b/agent/tests/services/config_instance/mod.rs new file mode 100644 index 00000000..3db8d766 --- /dev/null +++ b/agent/tests/services/config_instance/mod.rs @@ -0,0 +1,2 @@ +pub mod get; +pub mod parameter; diff --git a/agent/tests/services/config_instance/parameter.rs b/agent/tests/services/config_instance/parameter.rs new file mode 100644 index 00000000..5697fb12 --- /dev/null +++ b/agent/tests/services/config_instance/parameter.rs @@ -0,0 +1,262 @@ +// internal crates +use miru_agent::filesys::{self, Overwrite}; +use miru_agent::models::ConfigInstance; +use miru_agent::services::config_instance as ci_svc; +use miru_agent::services::ServiceErr; +use miru_agent::storage::{CfgInstContent, CfgInsts}; + +// external crates +use chrono::{DateTime, Utc}; +use serde_json::json; + +async fn setup(name: &str) -> (filesys::Dir, CfgInsts, CfgInstContent) { + let dir = filesys::Dir::create_temp_dir(name).await.unwrap(); + let (meta, _) = CfgInsts::spawn(16, dir.file("cfg_inst_meta.json"), 1000) + .await + .unwrap(); + let (content, _) = CfgInstContent::spawn(16, dir.subdir("cfg_inst_content"), 1000) + .await + .unwrap(); + (dir, meta, content) +} + +fn sample_ci(id: &str, filepath: &str) -> ConfigInstance { + ConfigInstance { + id: id.to_string(), + config_type_name: "robot_config".to_string(), + filepath: filepath.to_string(), + created_at: DateTime::::UNIX_EPOCH, + config_schema_id: "schema-1".to_string(), + config_type_id: "type-1".to_string(), + } +} + +async fn seed_json(meta: &CfgInsts, content: &CfgInstContent, id: &str, json_content: &str) { + let ci = sample_ci(id, "config.json"); + meta.write(id.to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + content + .write( + id.to_string(), + json_content.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); +} + +async fn seed_yaml(meta: &CfgInsts, content: &CfgInstContent, id: &str, yaml_content: &str) { + let ci = sample_ci(id, "config.yaml"); + meta.write(id.to_string(), ci, |_, _| false, Overwrite::Allow) + .await + .unwrap(); + content + .write( + id.to_string(), + yaml_content.to_string(), + |_, _| false, + Overwrite::Allow, + ) + .await + .unwrap(); +} + +pub mod get_parameter_tests { + use super::*; + + #[tokio::test] + async fn returns_top_level_parameter() { + let (_dir, meta, content) = setup("ci_param_top").await; + seed_json(&meta, &content, "ci-1", r#"{"timeout_ms": 5000}"#).await; + + let resp = ci_svc::get_parameter( + &meta, + &content, + "ci-1".to_string(), + "timeout_ms".to_string(), + ) + .await + .unwrap(); + assert_eq!(resp.object, "parameter"); + assert_eq!(resp.key, vec!["timeout_ms"]); + assert_eq!(resp.value, json!(5000)); + } + + #[tokio::test] + async fn returns_nested_parameter() { + let (_dir, meta, content) = setup("ci_param_nested").await; + seed_json( + &meta, + &content, + "ci-1", + r#"{"network": {"timeout_ms": 3000}}"#, + ) + .await; + + let resp = ci_svc::get_parameter( + &meta, + &content, + "ci-1".to_string(), + "network.timeout_ms".to_string(), + ) + .await + .unwrap(); + assert_eq!(resp.key, vec!["network", "timeout_ms"]); + assert_eq!(resp.value, json!(3000)); + } + + #[tokio::test] + async fn returns_array_element_by_index() { + let (_dir, meta, content) = setup("ci_param_arr").await; + seed_json(&meta, &content, "ci-1", r#"{"items": ["a", "b", "c"]}"#).await; + + let resp = + ci_svc::get_parameter(&meta, &content, "ci-1".to_string(), "items.1".to_string()) + .await + .unwrap(); + assert_eq!(resp.value, json!("b")); + } + + #[tokio::test] + async fn returns_not_found_for_missing_key() { + let (_dir, meta, content) = setup("ci_param_missing").await; + seed_json(&meta, &content, "ci-1", r#"{"key": "value"}"#).await; + + let result = ci_svc::get_parameter( + &meta, + &content, + "ci-1".to_string(), + "nonexistent".to_string(), + ) + .await; + assert!(matches!(result, Err(ServiceErr::ParameterNotFoundErr(_)))); + } + + #[tokio::test] + async fn returns_not_found_for_missing_config_instance() { + let (_dir, meta, content) = setup("ci_param_no_ci").await; + + let result = + ci_svc::get_parameter(&meta, &content, "missing".to_string(), "key".to_string()).await; + assert!(matches!( + result, + Err(ServiceErr::ConfigInstanceNotFoundErr(_)) + )); + } + + #[tokio::test] + async fn works_with_yaml_content() { + let (_dir, meta, content) = setup("ci_param_yaml").await; + seed_yaml(&meta, &content, "ci-1", "network:\n timeout_ms: 2000\n").await; + + let resp = ci_svc::get_parameter( + &meta, + &content, + "ci-1".to_string(), + "network.timeout_ms".to_string(), + ) + .await + .unwrap(); + assert_eq!(resp.value, json!(2000)); + } +} + +pub mod list_parameters_tests { + use super::*; + + #[tokio::test] + async fn lists_all_leaf_parameters() { + let (_dir, meta, content) = setup("ci_list_all").await; + seed_json( + &meta, + &content, + "ci-1", + r#"{"a": 1, "b": {"c": 2, "d": 3}}"#, + ) + .await; + + let resp = ci_svc::list_parameters(&meta, &content, "ci-1".to_string(), None) + .await + .unwrap(); + assert_eq!(resp.object, "list"); + assert_eq!(resp.data.len(), 3); + + let keys: Vec> = resp.data.iter().map(|p| p.key.clone()).collect(); + assert!(keys.contains(&vec!["a".to_string()])); + assert!(keys.contains(&vec!["b".to_string(), "c".to_string()])); + assert!(keys.contains(&vec!["b".to_string(), "d".to_string()])); + } + + #[tokio::test] + async fn filters_by_prefix() { + let (_dir, meta, content) = setup("ci_list_prefix").await; + seed_json( + &meta, + &content, + "ci-1", + r#"{"a": 1, "b": {"c": 2, "d": 3}}"#, + ) + .await; + + let resp = + ci_svc::list_parameters(&meta, &content, "ci-1".to_string(), Some("b".to_string())) + .await + .unwrap(); + assert_eq!(resp.data.len(), 2); + for p in &resp.data { + assert_eq!(p.key[0], "b"); + } + } + + #[tokio::test] + async fn handles_arrays_with_numeric_indices() { + // lint:allow(field-by-field-assert) + let (_dir, meta, content) = setup("ci_list_arr").await; + seed_json(&meta, &content, "ci-1", r#"{"items": [10, 20]}"#).await; + + let resp = ci_svc::list_parameters(&meta, &content, "ci-1".to_string(), None) + .await + .unwrap(); + assert_eq!(resp.data.len(), 2); + assert_eq!(resp.data[0].key, vec!["items".to_string(), "0".to_string()]); + assert_eq!(resp.data[0].value, json!(10)); + assert_eq!(resp.data[1].key, vec!["items".to_string(), "1".to_string()]); + assert_eq!(resp.data[1].value, json!(20)); + } + + #[tokio::test] + async fn returns_empty_for_prefix_with_no_match() { + let (_dir, meta, content) = setup("ci_list_no_match").await; + seed_json(&meta, &content, "ci-1", r#"{"a": 1}"#).await; + + let resp = + ci_svc::list_parameters(&meta, &content, "ci-1".to_string(), Some("z".to_string())) + .await + .unwrap(); + assert_eq!(resp.data.len(), 0); + } + + #[tokio::test] + async fn returns_not_found_for_missing_config_instance() { + let (_dir, meta, content) = setup("ci_list_no_ci").await; + + let result = ci_svc::list_parameters(&meta, &content, "missing".to_string(), None).await; + assert!(matches!( + result, + Err(ServiceErr::ConfigInstanceNotFoundErr(_)) + )); + } + + #[tokio::test] + async fn works_with_yaml_content() { + let (_dir, meta, content) = setup("ci_list_yaml").await; + seed_yaml(&meta, &content, "ci-1", "x: 1\ny:\n z: 2\n").await; + + let resp = ci_svc::list_parameters(&meta, &content, "ci-1".to_string(), None) + .await + .unwrap(); + assert_eq!(resp.data.len(), 2); + } +} diff --git a/agent/tests/services/mod.rs b/agent/tests/services/mod.rs index 82bbaef9..9c4b31a8 100644 --- a/agent/tests/services/mod.rs +++ b/agent/tests/services/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +pub mod config_instance; pub mod deployment; pub mod device; pub mod errors; diff --git a/plans/completed/20260524-device-api-config-instances.md b/plans/completed/20260524-device-api-config-instances.md new file mode 100644 index 00000000..005da7b8 --- /dev/null +++ b/plans/completed/20260524-device-api-config-instances.md @@ -0,0 +1,159 @@ +# Add config instance endpoints to the device API + +This ExecPlan is a living document. The sections Progress, Surprises & Discoveries, Decision Log, and Outcomes & Retrospective must be kept up to date as work proceeds. + +## Scope + +| Repository | Access | Description | +|-----------|--------|-------------| +| `agent/` | read-write | Add config instance service layer, handler functions, route registrations, response types, error variants, and tests. | + +This plan lives in `agent/plans/backlog/` because all code changes happen inside the agent repo. + +## Purpose / Big Picture + +On-device applications currently access config data only by reading files from disk. After this change, four new HTTP endpoints on the agent's local Unix socket API let on-device apps fetch config instance metadata, raw file content, individual parameters by key path, and flat lists of all parameters -- all via standard HTTP. This eliminates the need for apps to know the agent's file layout or parse config files themselves. + +User-visible behavior after this change: + +- `GET /v0.2/config_instances/{id}` returns JSON metadata for a config instance. Adding `?expand=content` includes the raw file content inline. +- `GET /v0.2/config_instances/{id}/content` returns the raw config file with the correct Content-Type (`application/json` or `application/yaml`). +- `GET /v0.2/config_instances/{id}/parameters/{key}` returns a single parameter by dot-separated key path as a JSON object. +- `GET /v0.2/config_instances/{id}/parameters` returns all leaf parameters flattened into dot-separated key paths. Optional `?prefix=` filters by key prefix. + +## Progress + +- [ ] M1 -- Dependencies: add `serde_yml` to workspace and agent Cargo.toml. +- [ ] M2 -- Response types and errors: create response structs and config-instance error variants. +- [ ] M3 -- Service layer: create `services/config_instance/` with get, content, and parameter logic. +- [ ] M4 -- Handlers and routes: add handler functions and route registrations. +- [ ] M5 -- Tests: add service and handler tests. +- [ ] M6 -- Preflight: run `./scripts/preflight.sh` and fix any issues. + +## Surprises & Discoveries + +(Add entries as you go.) + +## Decision Log + +- Decision: Define response types directly in agent source code rather than modifying `libs/device-api/`. + Rationale: `libs/device-api/` is auto-generated from the OpenAPI spec in the `mirurobotics/openapi` repo. Per AGENTS.md: "Do not edit by hand." The existing `handle()` function accepts any `T: Serialize`, so custom response types work cleanly without touching generated code. + Date/Author: 2026-05-24 / plan author. + +- Decision: Use `serde_yml` (not `serde_yaml`) for YAML parsing. + Rationale: `serde_yaml` is deprecated/unmaintained. `serde_yml` is the maintained successor with the same API surface. Neither crate is currently in the dependency tree. + Date/Author: 2026-05-24 / plan author. + +- Decision: No backend fallback for config instance endpoints. If a config instance is not in the local cache, return 404. + Rationale: Config instance content is synced to the agent during deployment sync. Unlike deployments/releases/git_commits, there is no backend endpoint for fetching individual config instance metadata on demand. The content exists only after a successful deployment sync. + Date/Author: 2026-05-24 / plan author. + +- Decision: Create a new `agent/src/server/responses/` module for config instance response types rather than adding them to `agent/src/server/response.rs`. + Rationale: `response.rs` contains only `From` impls that convert agent models to generated `device_server` types. Config instance response types are standalone `Serialize` structs with no generated counterpart. A separate module keeps the concerns distinct and avoids bloating the existing file. + Date/Author: 2026-05-24 / plan author. + +- Decision: The raw content endpoint (`GET .../content`) bypasses the `handle()` wrapper and returns a custom Axum response directly. + Rationale: `handle()` always wraps the response in `Json(json!(...))`. The content endpoint must return raw text with `Content-Type: application/json` or `application/yaml` and a `Content-Disposition` header. A custom `impl IntoResponse` is the idiomatic Axum approach. + Date/Author: 2026-05-24 / plan author. + +- Decision: Add config-instance errors at the service layer (`CfgInstServiceErr`) rather than at the server layer. + Rationale: The existing pattern routes domain errors through `ServiceErr` which `ServerErr` already wraps via `From`. Adding config-instance-specific errors at the service layer keeps the error hierarchy consistent with deployment/release/git_commit. + Date/Author: 2026-05-24 / plan author. + +## Outcomes & Retrospective + +(Summarize at completion or major milestones.) + +## Context and Orientation + +### Repository layout + +The agent repo is at `/home/ben/miru/workbench2/repos/agent`. The branch `feat/device-api-config-instances` is checked out with no changes from main. + +### Storage (already exists -- no changes needed) + +Config instance data is split into two caches for performance: + +- **Metadata cache** (`CfgInsts`): `cache::FileCache` -- all metadata entries in a single JSON file. +- **Content cache** (`CfgInstContent`): `cache::DirCache` -- each content entry in its own file under a directory. +- **Composite** (`CfgInstStor`): holds `Arc` and `Arc`. +- Accessed via `state.storage.cfg_insts.meta` and `state.storage.cfg_insts.content`. + +### ConfigInstance model (agent/src/models/config_instance.rs) + +```rust +pub struct ConfigInstance { + pub id: String, + pub config_type_name: String, + pub filepath: String, + pub created_at: DateTime, + pub config_schema_id: String, + pub config_type_id: String, +} +``` + +### Handler pattern + +All existing handlers use the `handle()` utility which wraps service calls and returns `(StatusCode, Json)`. + +### Error pattern + +Custom error types derive `thiserror::Error` and implement `crate::errors::Error`. For 404 errors, override `code()` to return `Code::ResourceNotFound` and `http_status()` to return `HTTPCode::NOT_FOUND`. + +## Plan of Work + +### M1 -- Dependencies + +Add `serde_yml` to workspace `Cargo.toml` and `agent/Cargo.toml`. + +### M2 -- Response types and errors + +**New files:** +- `agent/src/server/responses/mod.rs` -- module declarations +- `agent/src/server/responses/config_instance.rs` -- ConfigInstanceResponse, ContentField, ParameterResponse, ParameterListResponse + +**New file:** +- `agent/src/services/config_instance/errors.rs` -- ConfigInstanceNotFoundErr, ContentNotFoundErr, ParameterNotFoundErr, ContentParseErr, CfgInstServiceErr enum + +**Edit:** +- `agent/src/server/mod.rs` -- add `pub mod responses;` +- `agent/src/services/errors.rs` -- add CfgInstServiceErr variant to ServiceErr + +### M3 -- Service layer + +**New files:** +- `agent/src/services/config_instance/mod.rs` +- `agent/src/services/config_instance/get.rs` -- get(), get_with_content() +- `agent/src/services/config_instance/content.rs` -- get_raw_content(), infer_format(), content_type_for_format() +- `agent/src/services/config_instance/parameters.rs` -- get_parameter(), list_parameters() + +**Edit:** +- `agent/src/services/mod.rs` -- add `pub mod config_instance;` + +### M4 -- Handlers and routes + +**Edit:** +- `agent/src/server/handlers.rs` -- add get_config_instance, get_config_instance_content, get_config_instance_parameter, list_config_instance_parameters +- `agent/src/server/serve.rs` -- register 4 new routes + +### M5 -- Tests + +**New files:** +- `agent/tests/services/config_instance/mod.rs` +- `agent/tests/services/config_instance/get.rs` +- `agent/tests/services/config_instance/content.rs` +- `agent/tests/services/config_instance/parameters.rs` + +**Edit:** +- `agent/tests/services/mod.rs` +- `agent/tests/server/handlers.rs` + +### M6 -- Preflight + +Run `./scripts/test.sh` and `./scripts/lint.sh`. Fix any issues. + +## Validation and Acceptance + +- `./scripts/test.sh` -- all tests pass +- `./scripts/lint.sh` -- all lints pass +- Preflight must report clean before changes are published