1use actix_web::{http::StatusCode, web, HttpResponse};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use std::collections::{BTreeMap, BTreeSet};
18use uuid::Uuid;
19
20use bamboo_config::cluster_fabric::{
21 Cluster, ClusterCredentialAction, ClusterFabricConfig, ClusterNodeCredentialIntents,
22 ClusterNodeCredentialRefs, DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
23};
24use bamboo_config::{
25 patch::is_masked_api_key, ConfigStoreError, CredentialSource, CredentialStatus,
26 SectionEnvelope, SectionStatus,
27};
28use bamboo_server_tools::FabricCommitSnapshot;
29
30use crate::app_state::AppState;
31use crate::error::AppError;
32
33mod deploy;
34
35#[derive(Serialize)]
39pub struct FabricListResponse {
40 pub nodes: Vec<Value>,
41 pub clusters: Vec<Cluster>,
42}
43
44#[derive(Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct NodeUpsertRequest {
48 pub expected_revision: u64,
49 pub label: String,
50 pub placement: NodePlacementRequest,
51 #[serde(default)]
52 pub trust_level: TrustLevel,
53 #[serde(default)]
54 pub deploy: DeployProfile,
55 #[serde(default = "default_true")]
56 pub enabled: bool,
57 pub credential_changes: NodeCredentialChangesRequest,
58 #[serde(default)]
59 pub membership: Option<NodeMembershipRequest>,
60}
61
62#[derive(Clone, Deserialize)]
65#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
66pub enum NodePlacementRequest {
67 Local,
68 Ssh {
69 host: String,
70 #[serde(default = "default_ssh_port")]
71 port: u16,
72 username: String,
73 auth: SshAuthRequest,
74 #[serde(default)]
75 host_key_fingerprint: Option<String>,
76 },
77}
78
79#[derive(Clone, Deserialize)]
80#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
81pub enum SshAuthRequest {
82 SystemSshConfig {},
83 Password {},
84 PrivateKey {
85 #[serde(default)]
86 private_key_path: Option<String>,
87 },
88}
89
90#[derive(Clone, Deserialize)]
91#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
92pub enum CredentialActionRequest {
93 Keep,
94 Replace { value: String },
95 Clear,
96}
97
98#[derive(Clone, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct NodeCredentialChangesRequest {
101 pub password: CredentialActionRequest,
102 pub private_key: CredentialActionRequest,
103 pub passphrase: CredentialActionRequest,
104}
105
106#[derive(Clone, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct NodeMembershipRequest {
109 #[serde(default)]
112 pub cluster_names: Vec<String>,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct NodeDeleteQuery {
118 pub expected_revision: u64,
119}
120
121fn default_true() -> bool {
122 true
123}
124
125fn default_ssh_port() -> u16 {
126 22
127}
128
129#[derive(Clone, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct ClusterUpsertRequest {
133 pub expected_revision: u64,
134 pub name: String,
135 #[serde(default)]
136 pub description: Option<String>,
137 #[serde(default)]
138 pub node_ids: Vec<String>,
139}
140
141fn validate_node(req: &NodeUpsertRequest) -> Result<(), AppError> {
144 if req.label.trim().is_empty() {
145 return Err(AppError::BadRequest("Node label is required".into()));
146 }
147 if let NodePlacementRequest::Ssh {
148 host,
149 port,
150 username,
151 ..
152 } = &req.placement
153 {
154 if host.trim().is_empty() {
155 return Err(AppError::BadRequest("SSH host is required".into()));
156 }
157 if username.trim().is_empty() {
158 return Err(AppError::BadRequest("SSH username is required".into()));
159 }
160 if *port == 0 {
161 return Err(AppError::BadRequest("SSH port must be non-zero".into()));
162 }
163 }
164 validate_credential_actions(&req.placement, &req.credential_changes)?;
165 Ok(())
166}
167
168fn validate_credential_actions(
169 placement: &NodePlacementRequest,
170 changes: &NodeCredentialChangesRequest,
171) -> Result<(), AppError> {
172 for action in [&changes.password, &changes.private_key, &changes.passphrase] {
173 if let CredentialActionRequest::Replace { value } = action {
174 if value.is_empty() || is_masked_api_key(value) {
175 return Err(AppError::BadRequest(
176 "credential replacements must be nonempty and must not use a mask sentinel"
177 .to_string(),
178 ));
179 }
180 }
181 }
182 let is_clear =
183 |action: &CredentialActionRequest| matches!(action, CredentialActionRequest::Clear);
184 match placement {
185 NodePlacementRequest::Local
186 | NodePlacementRequest::Ssh {
187 auth: SshAuthRequest::SystemSshConfig {},
188 ..
189 } => {
190 if !is_clear(&changes.password)
191 || !is_clear(&changes.private_key)
192 || !is_clear(&changes.passphrase)
193 {
194 return Err(AppError::BadRequest(
195 "this placement requires clearing all stored SSH credentials".to_string(),
196 ));
197 }
198 }
199 NodePlacementRequest::Ssh {
200 auth: SshAuthRequest::Password {},
201 ..
202 } => {
203 if is_clear(&changes.password) {
204 return Err(AppError::BadRequest(
205 "password authentication requires keep or replace".to_string(),
206 ));
207 }
208 if !is_clear(&changes.private_key) || !is_clear(&changes.passphrase) {
209 return Err(AppError::BadRequest(
210 "password authentication requires clearing private-key credentials".to_string(),
211 ));
212 }
213 }
214 NodePlacementRequest::Ssh {
215 auth: SshAuthRequest::PrivateKey { private_key_path },
216 ..
217 } => {
218 if !is_clear(&changes.password) {
219 return Err(AppError::BadRequest(
220 "private-key authentication requires clearing the password credential"
221 .to_string(),
222 ));
223 }
224 let uses_path = private_key_path
225 .as_deref()
226 .is_some_and(|path| !path.trim().is_empty());
227 if uses_path && !is_clear(&changes.private_key) {
228 return Err(AppError::BadRequest(
229 "private-key-path authentication requires clearing the inline private key"
230 .to_string(),
231 ));
232 }
233 if !uses_path && is_clear(&changes.private_key) {
234 return Err(AppError::BadRequest(
235 "private-key authentication requires an inline key or key path".to_string(),
236 ));
237 }
238 }
239 }
240 Ok(())
241}
242
243impl NodePlacementRequest {
244 fn into_domain(self) -> NodePlacement {
245 match self {
246 Self::Local => NodePlacement::Local,
247 Self::Ssh {
248 host,
249 port,
250 username,
251 auth,
252 host_key_fingerprint,
253 } => NodePlacement::Ssh(SshTarget {
254 host,
255 port,
256 username,
257 auth: match auth {
258 SshAuthRequest::SystemSshConfig {} => SshAuth::SystemSshConfig,
259 SshAuthRequest::Password {} => SshAuth::Password {
260 password: String::new(),
261 password_encrypted: None,
262 },
263 SshAuthRequest::PrivateKey { private_key_path } => SshAuth::PrivateKey {
264 private_key: String::new(),
265 private_key_encrypted: None,
266 private_key_path: private_key_path.filter(|path| !path.trim().is_empty()),
267 passphrase: String::new(),
268 passphrase_encrypted: None,
269 },
270 },
271 host_key_fingerprint,
272 }),
273 }
274 }
275}
276
277impl CredentialActionRequest {
278 fn into_domain(self) -> ClusterCredentialAction {
279 match self {
280 Self::Keep => ClusterCredentialAction::Keep,
281 Self::Replace { value } => ClusterCredentialAction::Replace(value),
282 Self::Clear => ClusterCredentialAction::Clear,
283 }
284 }
285}
286
287impl NodeCredentialChangesRequest {
288 fn into_domain(self) -> ClusterNodeCredentialIntents {
289 ClusterNodeCredentialIntents {
290 password: self.password.into_domain(),
291 private_key: self.private_key.into_domain(),
292 passphrase: self.passphrase.into_domain(),
293 }
294 }
295}
296
297fn secret_free_node_value(node: &Node) -> Value {
301 let mut value = serde_json::to_value(node).unwrap_or(Value::Null);
302 if let Some(auth) = value
303 .get_mut("placement")
304 .and_then(|p| p.get_mut("auth"))
305 .and_then(|a| a.as_object_mut())
306 {
307 for field in [
308 "password",
309 "password_encrypted",
310 "private_key",
311 "private_key_encrypted",
312 "passphrase",
313 "passphrase_encrypted",
314 ] {
315 auth.remove(field);
316 }
317 }
318 value
319}
320
321#[derive(Clone, Copy, Serialize)]
322#[serde(rename_all = "snake_case")]
323enum ClusterCredentialState {
324 Configured,
325 FromEnv,
326 Missing,
327 Error,
328}
329
330#[derive(Serialize)]
331struct ClusterCredentialFieldStatus {
332 state: ClusterCredentialState,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 source: Option<CredentialSource>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 updated_at: Option<DateTime<Utc>>,
337}
338
339#[derive(Serialize)]
340struct ClusterNodeCredentialStatusView {
341 password: ClusterCredentialFieldStatus,
342 private_key: ClusterCredentialFieldStatus,
343 passphrase: ClusterCredentialFieldStatus,
344}
345
346fn credential_field_status(
347 configured: bool,
348 reference: Option<&bamboo_config::CredentialRef>,
349 statuses: &BTreeMap<String, CredentialStatus>,
350 store_healthy: bool,
351) -> ClusterCredentialFieldStatus {
352 if !configured {
353 return ClusterCredentialFieldStatus {
354 state: ClusterCredentialState::Missing,
355 source: None,
356 updated_at: None,
357 };
358 }
359 if !store_healthy {
360 return ClusterCredentialFieldStatus {
361 state: ClusterCredentialState::Error,
362 source: None,
363 updated_at: None,
364 };
365 }
366 let Some(status) = reference.and_then(|reference| statuses.get(reference.as_str())) else {
367 return ClusterCredentialFieldStatus {
368 state: ClusterCredentialState::Error,
369 source: None,
370 updated_at: None,
371 };
372 };
373 if !status.configured {
374 return ClusterCredentialFieldStatus {
375 state: ClusterCredentialState::Error,
376 source: Some(status.source),
377 updated_at: status.updated_at,
378 };
379 }
380 ClusterCredentialFieldStatus {
381 state: if status.source == CredentialSource::Environment {
382 ClusterCredentialState::FromEnv
383 } else {
384 ClusterCredentialState::Configured
385 },
386 source: Some(status.source),
387 updated_at: status.updated_at,
388 }
389}
390
391fn node_credential_status(
392 metadata: Option<&ClusterNodeCredentialRefs>,
393 statuses: &BTreeMap<String, CredentialStatus>,
394 store_healthy: bool,
395) -> ClusterNodeCredentialStatusView {
396 let metadata = metadata.cloned().unwrap_or_default();
397 ClusterNodeCredentialStatusView {
398 password: credential_field_status(
399 metadata.password_configured,
400 metadata.password_credential_ref.as_ref(),
401 statuses,
402 store_healthy,
403 ),
404 private_key: credential_field_status(
405 metadata.private_key_configured,
406 metadata.private_key_credential_ref.as_ref(),
407 statuses,
408 store_healthy,
409 ),
410 passphrase: credential_field_status(
411 metadata.passphrase_configured,
412 metadata.passphrase_credential_ref.as_ref(),
413 statuses,
414 store_healthy,
415 ),
416 }
417}
418
419fn project_cluster_section(
420 fabric: ClusterFabricConfig,
421 mut envelope: SectionEnvelope<Value>,
422 statuses: Vec<CredentialStatus>,
423 store_healthy: bool,
424) -> Result<SectionEnvelope<Value>, AppError> {
425 let statuses = statuses
426 .into_iter()
427 .map(|status| (status.credential_ref.as_str().to_string(), status))
428 .collect::<BTreeMap<_, _>>();
429 let mut credential_status = BTreeMap::new();
430 for node in &fabric.nodes {
431 credential_status.insert(
432 node.id.clone(),
433 node_credential_status(
434 fabric.credential_refs.get(&node.id),
435 &statuses,
436 store_healthy,
437 ),
438 );
439 }
440 let mut data = serde_json::to_value(&fabric)
441 .map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
442 let object = data.as_object_mut().ok_or_else(|| {
443 AppError::InternalError(anyhow::anyhow!(
444 "cluster section projection is not an object"
445 ))
446 })?;
447 object.remove("credential_refs");
448 object.insert(
449 "nodes".to_string(),
450 Value::Array(fabric.nodes.iter().map(secret_free_node_value).collect()),
451 );
452 object.insert(
453 "credential_status".to_string(),
454 serde_json::to_value(credential_status)
455 .map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?,
456 );
457 envelope.data = data;
458 if envelope.last_error.is_some() {
459 envelope.last_error = Some("cluster configuration is unavailable".to_string());
460 }
461 Ok(envelope)
462}
463
464async fn cluster_section_envelope(
465 app_state: &AppState,
466) -> Result<SectionEnvelope<Value>, AppError> {
467 app_state.config_facade.as_ref().ok_or_else(|| {
468 AppError::BadRequest(
469 "cluster settings require the modular configuration facade".to_string(),
470 )
471 })?;
472 let data_dir = app_state.app_data_dir.clone();
473 let exact = tokio::task::spawn_blocking(move || {
474 bamboo_config::read_exact_cluster_fabric_snapshot(&data_dir, None)
475 })
476 .await
477 .map_err(|error| {
478 AppError::InternalError(anyhow::anyhow!("cluster snapshot task failed: {error}"))
479 })?
480 .map_err(|error| match error {
481 ConfigStoreError::Io(error) => AppError::StorageError(error),
482 ConfigStoreError::Json(_)
483 | ConfigStoreError::Validation(_)
484 | ConfigStoreError::CommitIndeterminate(_) => {
485 AppError::InternalError(anyhow::anyhow!("cluster section snapshot is unavailable"))
486 }
487 ConfigStoreError::Conflict { .. } => AppError::InternalError(anyhow::anyhow!(
488 "cluster section snapshot returned an unexpected conflict"
489 )),
490 ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
491 "cluster section snapshot watch failed: {error}"
492 )),
493 })?;
494 let store_healthy = exact.section.status != SectionStatus::Degraded
495 && exact.credential_health.status != SectionStatus::Degraded;
496 project_cluster_section(
497 exact.cluster_fabric,
498 exact.section,
499 exact.credential_statuses,
500 store_healthy,
501 )
502}
503
504pub(super) fn committed_cluster_section(
505 snapshot: FabricCommitSnapshot,
506) -> Result<SectionEnvelope<Value>, AppError> {
507 let store_healthy = snapshot.section.status != SectionStatus::Degraded
508 && snapshot.credential_health.status != SectionStatus::Degraded;
509 project_cluster_section(
510 snapshot.config.cluster_fabric.clone(),
511 snapshot.section,
512 snapshot.credential_statuses,
513 store_healthy,
514 )
515}
516
517pub(super) async fn get_cluster_section(
518 app_state: web::Data<AppState>,
519) -> Result<HttpResponse, AppError> {
520 let _io = app_state.config_io_lock.lock().await;
521 Ok(HttpResponse::Ok().json(cluster_section_envelope(&app_state).await?))
522}
523
524fn replace_node_membership(
525 fabric: &mut ClusterFabricConfig,
526 node_id: &str,
527 membership: Option<&NodeMembershipRequest>,
528) -> Result<(), AppError> {
529 let Some(membership) = membership else {
530 return Ok(());
531 };
532 let mut names = BTreeSet::new();
533 for name in &membership.cluster_names {
534 let name = name.trim();
535 if name.is_empty() {
536 return Err(AppError::BadRequest(
537 "Cluster membership names must be nonempty".to_string(),
538 ));
539 }
540 if !names.insert(name.to_string()) {
541 return Err(AppError::BadRequest(
542 "Cluster membership names must be unique".to_string(),
543 ));
544 }
545 }
546 for name in &names {
547 if fabric.cluster(name).is_none() {
548 fabric.clusters.push(Cluster {
549 name: name.clone(),
550 description: None,
551 node_ids: Vec::new(),
552 });
553 }
554 }
555 for cluster in &mut fabric.clusters {
556 cluster.node_ids.retain(|member| member != node_id);
557 if names.contains(&cluster.name) {
558 cluster.node_ids.push(node_id.to_string());
559 }
560 }
561 Ok(())
562}
563
564#[derive(Serialize)]
565struct ClusterMutationResponse {
566 #[serde(skip_serializing_if = "Option::is_none")]
567 node_id: Option<String>,
568 #[serde(flatten)]
569 section: SectionEnvelope<Value>,
570}
571
572fn cluster_mutation_response(
573 status: StatusCode,
574 node_id: Option<String>,
575 snapshot: FabricCommitSnapshot,
576) -> Result<HttpResponse, AppError> {
577 let section = committed_cluster_section(snapshot)?;
578 Ok(HttpResponse::build(status).json(ClusterMutationResponse { node_id, section }))
579}
580
581pub async fn list_nodes(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
585 let config = app_state.config.read().await;
586 let nodes = config
587 .cluster_fabric
588 .nodes
589 .iter()
590 .map(secret_free_node_value)
591 .collect();
592 Ok(HttpResponse::Ok().json(FabricListResponse {
593 nodes,
594 clusters: config.cluster_fabric.clusters.clone(),
595 }))
596}
597
598pub async fn get_node(
600 app_state: web::Data<AppState>,
601 path: web::Path<String>,
602) -> Result<HttpResponse, AppError> {
603 let id = path.into_inner();
604 let config = app_state.config.read().await;
605 let node = config
606 .cluster_fabric
607 .node(&id)
608 .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
609 Ok(HttpResponse::Ok().json(secret_free_node_value(node)))
610}
611
612pub async fn create_node(
614 app_state: web::Data<AppState>,
615 payload: web::Json<NodeUpsertRequest>,
616) -> Result<HttpResponse, AppError> {
617 let req = payload.into_inner();
618 validate_node(&req)?;
619 let NodeUpsertRequest {
620 expected_revision,
621 label,
622 placement,
623 trust_level,
624 deploy,
625 enabled,
626 credential_changes,
627 membership,
628 } = req;
629
630 let node = Node {
631 id: Uuid::new_v4().to_string(),
632 label,
633 placement: placement.into_domain(),
634 trust_level,
635 deploy,
636 state: None,
637 enabled,
638 };
639 let node_id = node.id.clone();
640 let node_id_for_update = node_id.clone();
641 let node_intents = BTreeMap::from([(node_id.clone(), credential_changes.into_domain())]);
642
643 let snapshot = app_state
644 .update_cluster_fabric_credentials(expected_revision, node_intents, move |cfg| {
645 cfg.cluster_fabric.nodes.push(node.clone());
646 replace_node_membership(
647 &mut cfg.cluster_fabric,
648 &node_id_for_update,
649 membership.as_ref(),
650 )?;
651 Ok(())
652 })
653 .await?;
654
655 cluster_mutation_response(StatusCode::CREATED, Some(node_id), snapshot)
656}
657
658pub async fn update_node(
660 app_state: web::Data<AppState>,
661 path: web::Path<String>,
662 payload: web::Json<NodeUpsertRequest>,
663) -> Result<HttpResponse, AppError> {
664 let id = path.into_inner();
665 let req = payload.into_inner();
666 validate_node(&req)?;
667 let NodeUpsertRequest {
668 expected_revision,
669 label,
670 placement,
671 trust_level,
672 deploy,
673 enabled,
674 credential_changes,
675 membership,
676 } = req;
677 let id_for_response = id.clone();
678 let placement = placement.into_domain();
679 let node_intents = BTreeMap::from([(id.clone(), credential_changes.into_domain())]);
680
681 let snapshot = app_state
682 .update_cluster_fabric_credentials(expected_revision, node_intents, move |cfg| {
683 let existing = cfg
684 .cluster_fabric
685 .node(&id)
686 .cloned()
687 .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
688 let node = Node {
689 id: existing.id.clone(),
690 label: label.clone(),
691 placement: placement.clone(),
692 trust_level,
693 deploy: deploy.clone(),
694 state: existing.state.clone(), enabled,
696 };
697
698 let slot = cfg
699 .cluster_fabric
700 .node_mut(&id)
701 .expect("node existed above");
702 *slot = node;
703 replace_node_membership(&mut cfg.cluster_fabric, &id, membership.as_ref())?;
704 Ok(())
705 })
706 .await?;
707
708 cluster_mutation_response(StatusCode::OK, Some(id_for_response), snapshot)
709}
710
711pub async fn delete_node(
713 app_state: web::Data<AppState>,
714 path: web::Path<String>,
715 query: web::Query<NodeDeleteQuery>,
716) -> Result<HttpResponse, AppError> {
717 let id = path.into_inner();
718 let expected_revision = query.expected_revision;
719 let id_for_update = id.clone();
720
721 let snapshot = app_state
722 .delete_cluster_node_credentials(
723 expected_revision,
724 id.clone(),
725 BTreeMap::from([(id.clone(), ClusterNodeCredentialIntents::clear_all())]),
726 move |cfg| {
727 let before = cfg.cluster_fabric.nodes.len();
728 cfg.cluster_fabric.nodes.retain(|n| n.id != id_for_update);
729 if cfg.cluster_fabric.nodes.len() == before {
730 return Err(AppError::NotFound(format!("Node '{id_for_update}'")));
731 }
732 for cluster in &mut cfg.cluster_fabric.clusters {
734 cluster.node_ids.retain(|nid| nid != &id_for_update);
735 }
736 cfg.cluster_fabric.credential_refs.remove(&id_for_update);
737 Ok(())
738 },
739 )
740 .await?;
741
742 cluster_mutation_response(StatusCode::OK, Some(id), snapshot)
743}
744
745pub async fn create_cluster(
749 app_state: web::Data<AppState>,
750 payload: web::Json<ClusterUpsertRequest>,
751) -> Result<HttpResponse, AppError> {
752 let req = payload.into_inner();
753 if req.name.trim().is_empty() {
754 return Err(AppError::BadRequest("Cluster name is required".into()));
755 }
756 let expected_revision = req.expected_revision;
757
758 let snapshot = app_state
759 .update_cluster_fabric_credentials(expected_revision, BTreeMap::new(), move |cfg| {
760 if cfg.cluster_fabric.cluster(&req.name).is_some() {
761 return Err(AppError::BadRequest(format!(
762 "Cluster '{}' already exists",
763 req.name
764 )));
765 }
766 cfg.cluster_fabric.clusters.push(Cluster {
767 name: req.name.clone(),
768 description: req.description.clone(),
769 node_ids: req.node_ids.clone(),
770 });
771 Ok(())
772 })
773 .await?;
774
775 cluster_mutation_response(StatusCode::CREATED, None, snapshot)
776}
777
778pub async fn update_cluster(
780 app_state: web::Data<AppState>,
781 path: web::Path<String>,
782 payload: web::Json<ClusterUpsertRequest>,
783) -> Result<HttpResponse, AppError> {
784 let name = path.into_inner();
785 let req = payload.into_inner();
786 if req.name.trim().is_empty() {
787 return Err(AppError::BadRequest("Cluster name is required".into()));
788 }
789 let expected_revision = req.expected_revision;
790
791 let snapshot = app_state
792 .update_cluster_fabric_credentials(expected_revision, BTreeMap::new(), move |cfg| {
793 if req.name != name && cfg.cluster_fabric.cluster(&req.name).is_some() {
794 return Err(AppError::BadRequest(format!(
795 "Cluster '{}' already exists",
796 req.name
797 )));
798 }
799 let cluster = cfg
800 .cluster_fabric
801 .clusters
802 .iter_mut()
803 .find(|c| c.name == name)
804 .ok_or_else(|| AppError::NotFound(format!("Cluster '{name}'")))?;
805 cluster.description = req.description.clone();
806 cluster.node_ids = req.node_ids.clone();
807 cluster.name = req.name.clone();
808 Ok(())
809 })
810 .await?;
811
812 cluster_mutation_response(StatusCode::OK, None, snapshot)
813}
814
815pub async fn delete_cluster(
817 app_state: web::Data<AppState>,
818 path: web::Path<String>,
819 query: web::Query<NodeDeleteQuery>,
820) -> Result<HttpResponse, AppError> {
821 let name = path.into_inner();
822 let expected_revision = query.expected_revision;
823
824 let snapshot = app_state
825 .update_cluster_fabric_credentials(expected_revision, BTreeMap::new(), move |cfg| {
826 let before = cfg.cluster_fabric.clusters.len();
827 cfg.cluster_fabric.clusters.retain(|c| c.name != name);
828 if cfg.cluster_fabric.clusters.len() == before {
829 return Err(AppError::NotFound(format!("Cluster '{name}'")));
830 }
831 Ok(())
832 })
833 .await?;
834
835 cluster_mutation_response(StatusCode::OK, None, snapshot)
836}
837
838pub async fn node_status(
842 app_state: web::Data<AppState>,
843 path: web::Path<String>,
844) -> Result<HttpResponse, AppError> {
845 let id = path.into_inner();
846 let config = app_state.config.read().await;
847 let node = config
848 .cluster_fabric
849 .node(&id)
850 .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
851 Ok(HttpResponse::Ok().json(json!({
852 "id": node.id,
853 "enabled": node.enabled,
854 "state": node.state,
855 })))
856}
857
858#[derive(Debug, Clone, Deserialize)]
860pub struct DeployQuery {
861 pub expected_revision: u64,
862 #[serde(default)]
864 pub echo: bool,
865}
866
867#[derive(Debug, Clone, Deserialize)]
868#[serde(deny_unknown_fields)]
869pub struct LifecycleQuery {
870 pub expected_revision: u64,
871}
872
873#[derive(Serialize)]
874struct NodeStateMutationResponse {
875 id: String,
876 state: bamboo_config::cluster_fabric::NodeState,
877 #[serde(flatten)]
878 section: SectionEnvelope<Value>,
879}
880
881#[derive(Serialize)]
882struct NodeTestResponse {
883 id: String,
884 ok: bool,
885 preflight: String,
886 #[serde(flatten)]
887 section: SectionEnvelope<Value>,
888}
889
890pub async fn node_deploy(
892 app_state: web::Data<AppState>,
893 path: web::Path<String>,
894 query: web::Query<DeployQuery>,
895) -> Result<HttpResponse, AppError> {
896 let id = path.into_inner();
897 let result = deploy::deploy_node(&app_state, &id, query.echo, query.expected_revision).await?;
898 let section = committed_cluster_section(result.snapshot)?;
899 Ok(HttpResponse::Ok().json(NodeStateMutationResponse {
900 id,
901 state: result.value,
902 section,
903 }))
904}
905
906pub async fn node_stop(
908 app_state: web::Data<AppState>,
909 path: web::Path<String>,
910 query: web::Query<LifecycleQuery>,
911) -> Result<HttpResponse, AppError> {
912 let id = path.into_inner();
913 let result = deploy::stop_node(&app_state, &id, query.expected_revision).await?;
914 let section = committed_cluster_section(result.snapshot)?;
915 Ok(HttpResponse::Ok().json(NodeStateMutationResponse {
916 id,
917 state: result.value,
918 section,
919 }))
920}
921
922pub async fn node_test(
924 app_state: web::Data<AppState>,
925 path: web::Path<String>,
926 query: web::Query<LifecycleQuery>,
927) -> Result<HttpResponse, AppError> {
928 let id = path.into_inner();
929 let result = deploy::test_node(&app_state, &id, query.expected_revision).await?;
930 let section = committed_cluster_section(result.snapshot)?;
931 Ok(HttpResponse::Ok().json(NodeTestResponse {
932 id,
933 ok: true,
934 preflight: result.value,
935 section,
936 }))
937}
938
939#[derive(Debug, Clone, Deserialize)]
941pub struct LogsQuery {
942 #[serde(default = "default_log_lines")]
944 pub lines: usize,
945}
946
947fn default_log_lines() -> usize {
948 200
949}
950
951pub async fn node_logs(
953 app_state: web::Data<AppState>,
954 path: web::Path<String>,
955 query: web::Query<LogsQuery>,
956) -> Result<HttpResponse, AppError> {
957 let id = path.into_inner();
958 let lines = query.lines.clamp(1, 5000);
959 let logs = deploy::read_logs(&app_state, &id, lines).await?;
960 Ok(HttpResponse::Ok().json(json!({ "id": id, "logs": logs })))
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use actix_web::{test, App};
967 use bamboo_config::{credential_ref, CredentialRef};
968
969 fn pw_node(password: &str, encrypted: Option<&str>) -> Node {
970 Node {
971 id: "n1".into(),
972 label: "n1".into(),
973 placement: NodePlacement::Ssh(SshTarget {
974 host: "h".into(),
975 port: 22,
976 username: "u".into(),
977 auth: SshAuth::Password {
978 password: password.into(),
979 password_encrypted: encrypted.map(|s| s.into()),
980 },
981 host_key_fingerprint: None,
982 }),
983 trust_level: TrustLevel::Trusted,
984 deploy: DeployProfile::default(),
985 state: None,
986 enabled: true,
987 }
988 }
989
990 #[::core::prelude::v1::test]
991 fn public_node_projection_omits_plaintext_ciphertext_and_masks() {
992 let node = pw_node("hunter2", Some("ciphertext"));
993 let v = secret_free_node_value(&node);
994 let auth = &v["placement"]["auth"];
995 assert!(auth.get("password").is_none());
996 assert!(auth.get("password_encrypted").is_none());
997 let encoded = serde_json::to_string(&v).unwrap();
998 assert!(!encoded.contains("hunter2"));
999 assert!(!encoded.contains("ciphertext"));
1000 assert!(!encoded.contains("****"));
1001 }
1002
1003 #[::core::prelude::v1::test]
1004 fn request_rejects_credentials_inside_placement() {
1005 let request = serde_json::json!({
1006 "expected_revision": 1,
1007 "label": "node",
1008 "placement": {
1009 "type": "ssh",
1010 "host": "example.test",
1011 "username": "deploy",
1012 "auth": {"method": "password", "password": "must-not-enter-placement"}
1013 },
1014 "credential_changes": {
1015 "password": {"action": "replace", "value": "request-only-secret"},
1016 "private_key": {"action": "clear"},
1017 "passphrase": {"action": "clear"}
1018 }
1019 });
1020 let error = match serde_json::from_value::<NodeUpsertRequest>(request) {
1021 Ok(_) => panic!("placement credential must be rejected"),
1022 Err(error) => error,
1023 };
1024 assert!(error.to_string().contains("unknown field `password`"));
1025 assert!(!error.to_string().contains("must-not-enter-placement"));
1026 }
1027
1028 #[::core::prelude::v1::test]
1029 fn password_request_accepts_explicit_keep_or_replace_only() {
1030 let placement = NodePlacementRequest::Ssh {
1031 host: "example.test".to_string(),
1032 port: 22,
1033 username: "deploy".to_string(),
1034 auth: SshAuthRequest::Password {},
1035 host_key_fingerprint: None,
1036 };
1037 let valid = NodeCredentialChangesRequest {
1038 password: CredentialActionRequest::Keep,
1039 private_key: CredentialActionRequest::Clear,
1040 passphrase: CredentialActionRequest::Clear,
1041 };
1042 validate_credential_actions(&placement, &valid).unwrap();
1043
1044 let cleared = NodeCredentialChangesRequest {
1045 password: CredentialActionRequest::Clear,
1046 private_key: CredentialActionRequest::Clear,
1047 passphrase: CredentialActionRequest::Clear,
1048 };
1049 assert!(validate_credential_actions(&placement, &cleared).is_err());
1050 }
1051
1052 #[::core::prelude::v1::test]
1053 fn credential_replacement_rejects_mask_sentinel() {
1054 let placement = NodePlacementRequest::Ssh {
1055 host: "example.test".to_string(),
1056 port: 22,
1057 username: "deploy".to_string(),
1058 auth: SshAuthRequest::Password {},
1059 host_key_fingerprint: None,
1060 };
1061 let invalid = NodeCredentialChangesRequest {
1062 password: CredentialActionRequest::Replace {
1063 value: "****...****".to_string(),
1064 },
1065 private_key: CredentialActionRequest::Clear,
1066 passphrase: CredentialActionRequest::Clear,
1067 };
1068 assert!(validate_credential_actions(&placement, &invalid)
1069 .unwrap_err()
1070 .to_string()
1071 .contains("mask sentinel"));
1072 }
1073
1074 #[::core::prelude::v1::test]
1075 fn membership_replacement_creates_missing_cluster_in_candidate() {
1076 let node = Node {
1077 id: "n1".to_string(),
1078 label: "n1".to_string(),
1079 placement: NodePlacement::Local,
1080 trust_level: TrustLevel::Trusted,
1081 deploy: DeployProfile::default(),
1082 state: None,
1083 enabled: true,
1084 };
1085 let mut fabric = ClusterFabricConfig {
1086 nodes: vec![node],
1087 clusters: vec![Cluster {
1088 name: "old".to_string(),
1089 description: None,
1090 node_ids: vec!["n1".to_string()],
1091 }],
1092 ..ClusterFabricConfig::default()
1093 };
1094 replace_node_membership(
1095 &mut fabric,
1096 "n1",
1097 Some(&NodeMembershipRequest {
1098 cluster_names: vec!["new".to_string()],
1099 }),
1100 )
1101 .unwrap();
1102 assert!(fabric.cluster("old").unwrap().node_ids.is_empty());
1103 assert_eq!(fabric.cluster("new").unwrap().node_ids, ["n1"]);
1104 }
1105
1106 #[::core::prelude::v1::test]
1107 fn credential_status_distinguishes_configured_missing_and_error_without_refs() {
1108 let reference: CredentialRef = credential_ref("cluster", "n1", "password").unwrap();
1109 let metadata = ClusterNodeCredentialRefs {
1110 password_credential_ref: Some(reference.clone()),
1111 password_configured: true,
1112 ..ClusterNodeCredentialRefs::default()
1113 };
1114 let status = CredentialStatus {
1115 credential_ref: reference,
1116 configured: true,
1117 source: CredentialSource::User,
1118 updated_at: None,
1119 };
1120 let statuses = BTreeMap::from([(status.credential_ref.as_str().to_string(), status)]);
1121 let healthy = node_credential_status(Some(&metadata), &statuses, true);
1122 let healthy_json = serde_json::to_value(healthy).unwrap();
1123 assert_eq!(healthy_json["password"]["state"], "configured");
1124 assert_eq!(healthy_json["private_key"]["state"], "missing");
1125 assert!(!healthy_json.to_string().contains("cluster.n1.password"));
1126
1127 let error = node_credential_status(Some(&metadata), &statuses, false);
1128 assert_eq!(
1129 serde_json::to_value(error).unwrap()["password"]["state"],
1130 "error"
1131 );
1132 }
1133
1134 #[actix_web::test]
1135 async fn delayed_mutation_response_stays_bound_to_its_own_commit_snapshot() {
1136 let _key = bamboo_config::encryption::set_test_encryption_key([0x73; 32]);
1137 let dir = tempfile::tempdir().unwrap();
1138 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
1139 let first = state
1140 .update_cluster_fabric_credentials(
1141 0,
1142 BTreeMap::from([(
1143 "race-node".to_string(),
1144 ClusterNodeCredentialIntents::clear_all(),
1145 )]),
1146 |config| {
1147 config.cluster_fabric.nodes.push(Node {
1148 id: "race-node".to_string(),
1149 label: "first-commit".to_string(),
1150 placement: NodePlacement::Local,
1151 trust_level: TrustLevel::Trusted,
1152 deploy: DeployProfile::default(),
1153 state: None,
1154 enabled: true,
1155 });
1156 Ok(())
1157 },
1158 )
1159 .await
1160 .unwrap();
1161 state
1162 .update_cluster_fabric_credentials(
1163 1,
1164 BTreeMap::from([(
1165 "race-node".to_string(),
1166 ClusterNodeCredentialIntents::clear_all(),
1167 )]),
1168 |config| {
1169 config.cluster_fabric.node_mut("race-node").unwrap().label =
1170 "second-commit".to_string();
1171 Ok(())
1172 },
1173 )
1174 .await
1175 .unwrap();
1176
1177 let response =
1178 cluster_mutation_response(StatusCode::OK, Some("race-node".to_string()), first)
1179 .unwrap();
1180 let body = actix_web::body::to_bytes(response.into_body())
1181 .await
1182 .unwrap();
1183 let body: Value = serde_json::from_slice(&body).unwrap();
1184 assert_eq!(body["revision"], 1);
1185 assert_eq!(body["data"]["nodes"][0]["label"], "first-commit");
1186 assert_eq!(
1187 state
1188 .config
1189 .read()
1190 .await
1191 .cluster_fabric
1192 .node("race-node")
1193 .unwrap()
1194 .label,
1195 "second-commit"
1196 );
1197 }
1198
1199 #[actix_web::test]
1200 async fn cluster_get_reads_one_newer_durable_metadata_and_credential_generation() {
1201 let dir = tempfile::tempdir().unwrap();
1204 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
1205 state
1206 .update_cluster_fabric_credentials(
1207 0,
1208 BTreeMap::from([(
1209 "coherent-node".to_string(),
1210 ClusterNodeCredentialIntents::clear_all(),
1211 )]),
1212 |config| {
1213 config.cluster_fabric.nodes.push(Node {
1214 id: "coherent-node".to_string(),
1215 label: "generation-one".to_string(),
1216 placement: NodePlacement::Local,
1217 trust_level: TrustLevel::Trusted,
1218 deploy: DeployProfile::default(),
1219 state: None,
1220 enabled: true,
1221 });
1222 Ok(())
1223 },
1224 )
1225 .await
1226 .unwrap();
1227
1228 let guard = state.config_io_lock.lock().await;
1231 let local_facade = state.config_facade.as_ref().unwrap();
1232 assert_eq!(
1233 local_facade.registry().cluster_fabric.snapshot().revision,
1234 1
1235 );
1236 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
1237 let mut winner = external.effective_config();
1238 let node = winner.cluster_fabric.node_mut("coherent-node").unwrap();
1239 node.label = "generation-two".to_string();
1240 node.placement = NodePlacement::Ssh(SshTarget {
1241 host: "generation-two.example.test".to_string(),
1242 port: 22,
1243 username: "operator".to_string(),
1244 auth: SshAuth::Password {
1245 password: String::new(),
1246 password_encrypted: None,
1247 },
1248 host_key_fingerprint: None,
1249 });
1250 let revision = bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
1251 dir.path(),
1252 &mut winner,
1253 &BTreeMap::from([(
1254 "coherent-node".to_string(),
1255 ClusterNodeCredentialIntents {
1256 password: ClusterCredentialAction::Replace(
1257 "generation-two-password".to_string(),
1258 ),
1259 private_key: ClusterCredentialAction::Clear,
1260 passphrase: ClusterCredentialAction::Clear,
1261 },
1262 )]),
1263 1,
1264 )
1265 .unwrap();
1266 assert_eq!(revision, 2);
1267 assert_eq!(
1268 local_facade.registry().cluster_fabric.snapshot().revision,
1269 1,
1270 "the local facade remains intentionally stale behind the held publication lock"
1271 );
1272
1273 if let Err(error) = bamboo_config::read_exact_cluster_fabric_snapshot(dir.path(), None) {
1274 panic!("exact durable cluster read failed before GET projection: {error}");
1275 }
1276 let envelope = cluster_section_envelope(&state)
1277 .await
1278 .expect("GET projection must read the coherent durable generation");
1279 assert_eq!(envelope.revision, 2);
1280 let node = envelope.data["nodes"]
1281 .as_array()
1282 .unwrap()
1283 .iter()
1284 .find(|node| node["id"] == "coherent-node")
1285 .unwrap();
1286 assert_eq!(node["label"], "generation-two");
1287 assert_eq!(node["placement"]["type"], "ssh");
1288 assert_eq!(node["placement"]["auth"]["method"], "password");
1289 assert_eq!(
1290 envelope.data["credential_status"]["coherent-node"]["password"]["state"],
1291 "configured"
1292 );
1293 let encoded = serde_json::to_string(&envelope).unwrap();
1294 assert!(!encoded.contains("generation-two-password"));
1295 assert!(!encoded.contains("credential_ref"));
1296 assert_eq!(
1297 state
1298 .config
1299 .read()
1300 .await
1301 .cluster_fabric
1302 .node("coherent-node")
1303 .unwrap()
1304 .label,
1305 "generation-one",
1306 "the read must not depend on or silently mutate the stale process runtime"
1307 );
1308
1309 let credentials_path = dir.path().join("credentials.json");
1310 let credential_primary = std::fs::read(&credentials_path).unwrap();
1311 let password_ref = bamboo_config::cluster_password_credential_ref("coherent-node").unwrap();
1312 let mut corrupt_ciphertext: Value = serde_json::from_slice(&credential_primary).unwrap();
1313 corrupt_ciphertext["data"]["entries"][password_ref.as_str()]["ciphertext"] =
1314 Value::String("nonempty-but-undecryptable".to_string());
1315 std::fs::write(
1316 &credentials_path,
1317 serde_json::to_vec_pretty(&corrupt_ciphertext).unwrap(),
1318 )
1319 .unwrap();
1320 let corrupt_status = cluster_section_envelope(&state)
1321 .await
1322 .expect("GET must project corrupt ciphertext as status metadata");
1323 assert_eq!(
1324 corrupt_status.data["credential_status"]["coherent-node"]["password"]["state"],
1325 "error"
1326 );
1327 assert!(!serde_json::to_string(&corrupt_status)
1328 .unwrap()
1329 .contains("generation-two-password"));
1330 std::fs::write(&credentials_path, credential_primary).unwrap();
1331
1332 let cluster_primary = std::fs::read(dir.path().join("cluster-fabric.json")).unwrap();
1333 std::fs::write(dir.path().join("cluster-fabric.json"), b"{invalid-primary").unwrap();
1334 let degraded = cluster_section_envelope(&state)
1335 .await
1336 .expect("GET must retain the validated durable backup LKG");
1337 assert_eq!(degraded.revision, 1);
1338 assert_eq!(
1339 degraded.source_kind,
1340 bamboo_config::SectionSourceKind::Backup
1341 );
1342 assert_eq!(degraded.status, SectionStatus::Degraded);
1343 assert_eq!(
1344 degraded.last_error.as_deref(),
1345 Some("cluster configuration is unavailable")
1346 );
1347 assert_eq!(
1348 degraded.data["nodes"][0]["label"], "generation-one",
1349 "the degraded envelope must identify the exact backup generation"
1350 );
1351 assert_eq!(
1352 degraded.data["credential_status"]["coherent-node"]["password"]["state"], "missing",
1353 "the r1 backup metadata truthfully has no password configured"
1354 );
1355
1356 std::fs::write(dir.path().join("cluster-fabric.json"), cluster_primary).unwrap();
1357 for suffix in ["bak", "bak.1", "bak.2"] {
1358 let path = dir.path().join(format!("credentials.json.{suffix}"));
1359 match std::fs::remove_file(path) {
1360 Ok(()) => {}
1361 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1362 Err(error) => panic!("remove credential backup: {error}"),
1363 }
1364 }
1365 std::fs::write(
1366 dir.path().join("credentials.json"),
1367 b"{invalid-credential-primary",
1368 )
1369 .unwrap();
1370 let unavailable_credentials = cluster_section_envelope(&state)
1371 .await
1372 .expect("GET must remain available with redacted credential errors");
1373 assert_eq!(unavailable_credentials.revision, 2);
1374 assert_eq!(unavailable_credentials.status, SectionStatus::Healthy);
1375 assert_eq!(
1376 unavailable_credentials.data["credential_status"]["coherent-node"]["password"]["state"],
1377 "error"
1378 );
1379 drop(guard);
1380 }
1381
1382 #[actix_web::test]
1383 async fn node_api_returns_one_redacted_section_revision_and_canonical_conflicts() {
1384 let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
1385 let dir = tempfile::tempdir().unwrap();
1386 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1387 let app = test::init_service(
1388 App::new()
1389 .app_data(state.clone())
1390 .route("/nodes", web::post().to(create_node))
1391 .route("/nodes/{id}", web::put().to(update_node))
1392 .route("/nodes/{id}/stop", web::post().to(node_stop))
1393 .route("/clusters", web::post().to(create_cluster))
1394 .route("/clusters/{name}", web::put().to(update_cluster))
1395 .route("/clusters/{name}", web::delete().to(delete_cluster)),
1396 )
1397 .await;
1398 let create_secret = "create-request-only-secret";
1399 let created = test::call_service(
1400 &app,
1401 test::TestRequest::post()
1402 .uri("/nodes")
1403 .set_json(json!({
1404 "expected_revision": 0,
1405 "label": "node",
1406 "placement": {
1407 "type": "ssh",
1408 "host": "example.test",
1409 "username": "deploy",
1410 "auth": {"method": "password"}
1411 },
1412 "credential_changes": {
1413 "password": {"action": "replace", "value": create_secret},
1414 "private_key": {"action": "clear"},
1415 "passphrase": {"action": "clear"}
1416 },
1417 "membership": {"cluster_names": ["created-with-node"]}
1418 }))
1419 .to_request(),
1420 )
1421 .await;
1422 assert_eq!(created.status(), StatusCode::CREATED);
1423 let created_body = String::from_utf8(test::read_body(created).await.to_vec()).unwrap();
1424 assert!(!created_body.contains(create_secret));
1425 assert!(!created_body.contains("****"));
1426 assert!(!created_body.contains("credential_ref"));
1427 let created: Value = serde_json::from_str(&created_body).unwrap();
1428 assert_eq!(created["revision"], 1);
1429 let node_id = created["node_id"].as_str().unwrap();
1430 assert_eq!(
1431 created["data"]["credential_status"][node_id]["password"]["state"],
1432 "configured"
1433 );
1434 assert_eq!(created["data"]["clusters"][0]["node_ids"], json!([node_id]));
1435 let auth = &created["data"]["nodes"][0]["placement"]["auth"];
1436 assert_eq!(auth["method"], "password");
1437 assert!(auth.get("password").is_none());
1438
1439 let unrelated = CredentialRef::parse("custom.unrelated.cluster_test").unwrap();
1440 let credential_revision = state.credential_store.revision().unwrap();
1441 state
1442 .credential_store
1443 .replace(
1444 unrelated.clone(),
1445 "unrelated-secret",
1446 CredentialSource::User,
1447 credential_revision,
1448 )
1449 .unwrap();
1450 let updated = test::call_service(
1451 &app,
1452 test::TestRequest::put()
1453 .uri(&format!("/nodes/{node_id}"))
1454 .set_json(json!({
1455 "expected_revision": 1,
1456 "label": "updated",
1457 "placement": {
1458 "type": "ssh",
1459 "host": "example.test",
1460 "username": "deploy",
1461 "auth": {"method": "password"}
1462 },
1463 "credential_changes": {
1464 "password": {"action": "keep"},
1465 "private_key": {"action": "clear"},
1466 "passphrase": {"action": "clear"}
1467 },
1468 "membership": {"cluster_names": ["created-with-node"]}
1469 }))
1470 .to_request(),
1471 )
1472 .await;
1473 assert_eq!(
1474 updated.status(),
1475 StatusCode::OK,
1476 "unrelated credential revision must not create a false 409"
1477 );
1478 let updated: Value = test::read_body_json(updated).await;
1479 assert_eq!(updated["revision"], 2);
1480 assert_eq!(updated["data"]["nodes"][0]["label"], "updated");
1481 assert_eq!(
1482 state
1483 .credential_store
1484 .resolve(&unrelated)
1485 .unwrap()
1486 .unwrap()
1487 .expose(),
1488 "unrelated-secret"
1489 );
1490
1491 let stale = test::call_service(
1492 &app,
1493 test::TestRequest::put()
1494 .uri(&format!("/nodes/{node_id}"))
1495 .set_json(json!({
1496 "expected_revision": 1,
1497 "label": "stale",
1498 "placement": {
1499 "type": "ssh",
1500 "host": "example.test",
1501 "username": "deploy",
1502 "auth": {"method": "password"}
1503 },
1504 "credential_changes": {
1505 "password": {"action": "keep"},
1506 "private_key": {"action": "clear"},
1507 "passphrase": {"action": "clear"}
1508 }
1509 }))
1510 .to_request(),
1511 )
1512 .await;
1513 assert_eq!(stale.status(), StatusCode::CONFLICT);
1514 let stale: Value = test::read_body_json(stale).await;
1515 assert_eq!(stale["error"]["code"], "config_revision_conflict");
1516 assert!(stale["error"]["message"]
1517 .as_str()
1518 .unwrap()
1519 .contains("expected 1, actual 2"));
1520 assert_eq!(
1521 state
1522 .config
1523 .read()
1524 .await
1525 .cluster_fabric
1526 .node(node_id)
1527 .unwrap()
1528 .label,
1529 "updated"
1530 );
1531
1532 let stale_create = test::call_service(
1533 &app,
1534 test::TestRequest::post()
1535 .uri("/clusters")
1536 .set_json(json!({
1537 "expected_revision": 1,
1538 "name": "crud-cluster",
1539 "node_ids": [node_id]
1540 }))
1541 .to_request(),
1542 )
1543 .await;
1544 assert_eq!(stale_create.status(), StatusCode::CONFLICT);
1545 let stale_create: Value = test::read_body_json(stale_create).await;
1546 assert_eq!(
1547 stale_create["error"]["message"],
1548 "Configuration revision conflict: expected 1, actual 2"
1549 );
1550
1551 let created_cluster = test::call_service(
1552 &app,
1553 test::TestRequest::post()
1554 .uri("/clusters")
1555 .set_json(json!({
1556 "expected_revision": 2,
1557 "name": "crud-cluster",
1558 "node_ids": [node_id]
1559 }))
1560 .to_request(),
1561 )
1562 .await;
1563 assert_eq!(created_cluster.status(), StatusCode::CREATED);
1564 let created_cluster: Value = test::read_body_json(created_cluster).await;
1565 assert_eq!(created_cluster["revision"], 3);
1566 assert!(created_cluster["data"]["clusters"]
1567 .as_array()
1568 .unwrap()
1569 .iter()
1570 .any(|cluster| cluster["name"] == "crud-cluster"));
1571
1572 let stale_update = test::call_service(
1573 &app,
1574 test::TestRequest::put()
1575 .uri("/clusters/crud-cluster")
1576 .set_json(json!({
1577 "expected_revision": 2,
1578 "name": "crud-cluster",
1579 "description": "stale",
1580 "node_ids": [node_id]
1581 }))
1582 .to_request(),
1583 )
1584 .await;
1585 assert_eq!(stale_update.status(), StatusCode::CONFLICT);
1586 let stale_update: Value = test::read_body_json(stale_update).await;
1587 assert_eq!(
1588 stale_update["error"]["message"],
1589 "Configuration revision conflict: expected 2, actual 3"
1590 );
1591
1592 let updated_cluster = test::call_service(
1593 &app,
1594 test::TestRequest::put()
1595 .uri("/clusters/crud-cluster")
1596 .set_json(json!({
1597 "expected_revision": 3,
1598 "name": "crud-cluster",
1599 "description": "updated",
1600 "node_ids": [node_id]
1601 }))
1602 .to_request(),
1603 )
1604 .await;
1605 assert_eq!(updated_cluster.status(), StatusCode::OK);
1606 let updated_cluster: Value = test::read_body_json(updated_cluster).await;
1607 assert_eq!(updated_cluster["revision"], 4);
1608 assert_eq!(
1609 updated_cluster["data"]["clusters"]
1610 .as_array()
1611 .unwrap()
1612 .iter()
1613 .find(|cluster| cluster["name"] == "crud-cluster")
1614 .unwrap()["description"],
1615 "updated"
1616 );
1617
1618 let stale_delete = test::call_service(
1619 &app,
1620 test::TestRequest::delete()
1621 .uri("/clusters/crud-cluster?expected_revision=3")
1622 .to_request(),
1623 )
1624 .await;
1625 assert_eq!(stale_delete.status(), StatusCode::CONFLICT);
1626 let stale_delete: Value = test::read_body_json(stale_delete).await;
1627 assert_eq!(
1628 stale_delete["error"]["message"],
1629 "Configuration revision conflict: expected 3, actual 4"
1630 );
1631
1632 let deleted_cluster = test::call_service(
1633 &app,
1634 test::TestRequest::delete()
1635 .uri("/clusters/crud-cluster?expected_revision=4")
1636 .to_request(),
1637 )
1638 .await;
1639 assert_eq!(deleted_cluster.status(), StatusCode::OK);
1640 let deleted_cluster: Value = test::read_body_json(deleted_cluster).await;
1641 assert_eq!(deleted_cluster["revision"], 5);
1642 assert!(!deleted_cluster["data"]["clusters"]
1643 .as_array()
1644 .unwrap()
1645 .iter()
1646 .any(|cluster| cluster["name"] == "crud-cluster"));
1647
1648 let stale_stop = test::call_service(
1649 &app,
1650 test::TestRequest::post()
1651 .uri(&format!("/nodes/{node_id}/stop?expected_revision=4"))
1652 .to_request(),
1653 )
1654 .await;
1655 assert_eq!(stale_stop.status(), StatusCode::CONFLICT);
1656
1657 let stopped = test::call_service(
1658 &app,
1659 test::TestRequest::post()
1660 .uri(&format!("/nodes/{node_id}/stop?expected_revision=5"))
1661 .to_request(),
1662 )
1663 .await;
1664 let stopped_status = stopped.status();
1665 let stopped_body = String::from_utf8(test::read_body(stopped).await.to_vec()).unwrap();
1666 assert_eq!(stopped_status, StatusCode::OK, "{stopped_body}");
1667 assert!(!stopped_body.contains(create_secret));
1668 assert!(!stopped_body.contains("credential_ref"));
1669 assert!(!stopped_body.contains("****"));
1670 let stopped: Value = serde_json::from_str(&stopped_body).unwrap();
1671 assert_eq!(stopped["revision"], 6);
1672 assert_eq!(stopped["state"]["status"], "stopped");
1673 assert_eq!(stopped["data"]["nodes"][0]["state"]["status"], "stopped");
1674 }
1675}