Skip to main content

bamboo_server/handlers/settings/cluster_fabric/
mod.rs

1//! Remote Cluster Fabric operator API (RFC v2 §4): CRUD for nodes & clusters,
2//! plus lifecycle endpoints (test/deploy/stop/status/logs).
3//!
4//! P1 scope: the persisted registry + redacted round-trip. The lifecycle
5//! actions (`test`/`deploy`/`stop`/`logs`) are **stubbed `501 Not Implemented`**
6//! until the deploy engine lands in P2; `status` returns the persisted state
7//! (no live SSH probe yet).
8//!
9//! Secrets (SSH password / private key / passphrase) never enter ordinary node
10//! payloads or responses. Dedicated request-only keep/replace/clear actions are
11//! committed with node and membership metadata under one section revision.
12
13use 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// ─── Request / response types ──────────────────────────────────────────
36
37/// Combined inventory returned by `GET /nodes` (nodes are redacted).
38#[derive(Serialize)]
39pub struct FabricListResponse {
40    pub nodes: Vec<Value>,
41    pub clusters: Vec<Cluster>,
42}
43
44/// Create/replace payload for a node. `id`/`state` are server-owned and ignored.
45#[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/// Secret-free operator placement. SSH credential material is accepted only
63/// through `credential_changes`, never through the ordinary node document.
64#[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    /// Replace the node's complete cluster membership with these existing
110    /// or newly-created cluster names as part of the same node transaction.
111    #[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/// Create/replace payload for a cluster.
130#[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
141// ─── Validation ────────────────────────────────────────────────────────
142
143fn 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
297// ─── Secret-free section projection ────────────────────────────────────
298
299/// Serialize a node without any secret fields, ciphertext, or mask sentinel.
300fn 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
581// ─── Node handlers ─────────────────────────────────────────────────────
582
583/// `GET /v1/bamboo/settings/nodes` — list nodes (redacted) + clusters.
584pub 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
598/// `GET /v1/bamboo/settings/nodes/{id}` — one node (redacted).
599pub 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
612/// `POST /v1/bamboo/settings/nodes` — create a node.
613pub 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
658/// `PUT /v1/bamboo/settings/nodes/{id}` — update a node (secret-preserving).
659pub 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(), // engine-owned: preserve
695                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
711/// `DELETE /v1/bamboo/settings/nodes/{id}` — remove a node.
712pub 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                // Drop the node from any cluster membership too.
733                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
745// ─── Cluster handlers ──────────────────────────────────────────────────
746
747/// `POST /v1/bamboo/settings/clusters` — create a cluster.
748pub 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
778/// `PUT /v1/bamboo/settings/clusters/{name}` — update a cluster.
779pub 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
815/// `DELETE /v1/bamboo/settings/clusters/{name}` — remove a cluster (nodes kept).
816pub 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
838// ─── Lifecycle ─────────────────────────────────────────────────────────
839
840/// `GET /v1/bamboo/settings/nodes/{id}/status` — persisted state (no live probe yet).
841pub 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/// Query params for `node_deploy`.
859#[derive(Debug, Clone, Deserialize)]
860pub struct DeployQuery {
861    pub expected_revision: u64,
862    /// Deploy the no-LLM echo executor (connectivity smoke test).
863    #[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
890/// `POST /v1/bamboo/settings/nodes/{id}/deploy` — deploy a worker for the node.
891pub 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
906/// `POST /v1/bamboo/settings/nodes/{id}/stop` — stop the node's worker.
907pub 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
922/// `POST /v1/bamboo/settings/nodes/{id}/test` — connectivity preflight (no deploy).
923pub 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/// Query params for `node_logs`.
940#[derive(Debug, Clone, Deserialize)]
941pub struct LogsQuery {
942    /// Number of trailing lines to return (default 200).
943    #[serde(default = "default_log_lines")]
944    pub lines: usize,
945}
946
947fn default_log_lines() -> usize {
948    200
949}
950
951/// `GET /v1/bamboo/settings/nodes/{id}/logs` — tail the node worker's log.
952pub 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        // Credential hydration runs on the blocking pool, so use the stable
1202        // key-file path instead of the test-only thread-local key override.
1203        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        // Hold the process publication lock so the first facade/runtime stay
1229        // at r1 while a second facade commits r2 to durable storage.
1230        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}