1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use greentic_deploy_spec::{
21 EnvId, Environment, MIN_POLL_INTERVAL_SECS, OnNotifyAction, UpdateChannelConfig,
22};
23use greentic_distributor_client::{CachePolicy, DistClient, DistOptions, ResolvePolicy};
24use greentic_secrets_lib::core::rt;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use crate::environment::{
29 EnvironmentStore, LocalFsStore, restore_environment, snapshot_environment,
30 trust_root as store_trust_root,
31};
32
33use super::env_manifest::{ENV_MANIFEST_SCHEMA_V1, EnvManifest};
34use super::secrets::{get_env_secret, put_env_secret, require_secrets_pack};
35use super::{AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record};
36
37const NOUN: &str = "updates";
38
39const TLS_PACK: &str = "tls";
41const CERT_NAME: &str = "updater_cert";
44const KEY_NAME: &str = "updater_key";
45const CA_NAME: &str = "updater_ca";
46const CA_URL_NAME: &str = "updater_ca_url";
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct UpdatesEnrollPayload {
51 pub environment_id: String,
52 pub ca_url: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UpdatesStatusPayload {
60 pub environment_id: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct UpdatesGetPayload {
68 pub environment_id: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub plan_url: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub plan_file: Option<PathBuf>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub plan_sig_file: Option<PathBuf>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ApplyUpdatesPayload {
84 pub environment_id: String,
85 pub plan_id: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct RecoverUpdatesPayload {
95 pub environment_id: String,
96 pub plan_id: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct UpdateConfigSetPayload {
106 pub environment_id: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub enabled: Option<bool>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub on_notify: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub poll_interval_secs: Option<u64>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct UpdateConfigShowFilter {
125 pub environment_id: String,
126}
127
128fn tls_rel_path(tenant: &str, name: &str) -> String {
131 format!("{tenant}/_/{TLS_PACK}/{name}")
132}
133
134fn control_url_is_acceptable(raw: &str) -> bool {
142 let Ok(parsed) = url::Url::parse(raw) else {
143 return false;
144 };
145 match parsed.scheme() {
146 "https" => true,
147 "http" => match parsed.host() {
148 Some(url::Host::Domain(host)) => host == "localhost",
149 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
150 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
151 None => false,
152 },
153 _ => false,
154 }
155}
156
157fn require_tenant(env: &Environment, env_id: &EnvId) -> Result<String, OpError> {
162 env.host_config
163 .tenant_org_id
164 .clone()
165 .filter(|t| !t.trim().is_empty())
166 .ok_or_else(|| {
167 OpError::InvalidArgument(format!(
168 "env `{env_id}` must be tenant-owned before update-channel enrollment; \
169 set the owner with `op env update {env_id} --tenant-org <tenant>`"
170 ))
171 })
172}
173
174pub fn enroll(
178 store: &LocalFsStore,
179 flags: &OpFlags,
180 payload: Option<UpdatesEnrollPayload>,
181) -> Result<OpOutcome, OpError> {
182 if flags.schema_only {
183 return Ok(OpOutcome::new(NOUN, "enroll", enroll_schema()));
184 }
185 let payload = resolve_payload::<UpdatesEnrollPayload>(flags, payload)?;
186 let env_id = parse_env_id(&payload.environment_id)?;
187 let ca_url = payload.ca_url.trim().to_string();
189 if ca_url.is_empty() {
190 return Err(OpError::InvalidArgument(
191 "ca_url must not be empty".to_string(),
192 ));
193 }
194 if !control_url_is_acceptable(&ca_url) {
195 return Err(OpError::InvalidArgument(
196 "ca_url must be an https:// URL; plaintext http:// is accepted only for a loopback \
197 CA in local development. Enrollment establishes the update-channel trust anchor, so \
198 it must not bootstrap over an unauthenticated channel to a remote host."
199 .to_string(),
200 ));
201 }
202 let ctx = AuditCtx {
203 env_id: env_id.clone(),
204 noun: NOUN,
205 verb: "enroll",
206 target: json!({"environment_id": env_id.as_str(), "ca_url": ca_url}),
208 idempotency_key: None,
209 };
210 audit_and_record(store, ctx, |_committed| {
211 let env = store.load(&env_id)?;
212 let secrets = require_secrets_pack(&env, &env_id)?;
213 let kind_path = secrets.kind.path();
214 let tenant = require_tenant(&env, &env_id)?;
215
216 let enrollment = rt::sync_await(async {
220 let client = reqwest::Client::new();
221 greentic_update::enroll::enroll(&client, &ca_url, &tenant, env_id.as_str()).await
222 })
223 .map_err(|e| OpError::Conflict(format!("update-channel enrollment failed: {e}")))?;
224
225 greentic_update::tls::build_mtls_client(&greentic_update::tls::MtlsConfig {
231 ca_pem: enrollment.ca_pem.clone(),
232 client_cert_pem: enrollment.client_cert_pem.clone(),
233 client_key_pem: enrollment.client_key_pem.clone(),
234 })
235 .map_err(|e| {
236 OpError::Conflict(format!("CA response is not a usable mTLS identity: {e}"))
237 })?;
238
239 let stored = persist_enrollment(
240 store,
241 &env,
242 &env_id,
243 kind_path,
244 &tenant,
245 &ca_url,
246 &enrollment,
247 )?;
248
249 let outcome = OpOutcome::new(
250 NOUN,
251 "enroll",
252 json!({
253 "environment_id": env_id.as_str(),
254 "tenant": tenant,
255 "serial": enrollment.serial,
256 "not_after": enrollment.not_after,
257 "secrets_kind": secrets.kind.to_string(),
258 "stored": stored,
259 }),
260 );
261 Ok((outcome, super::AuditGens::NONE))
262 })
263}
264
265fn persist_enrollment(
269 store: &LocalFsStore,
270 env: &Environment,
271 env_id: &EnvId,
272 kind_path: &str,
273 tenant: &str,
274 ca_url: &str,
275 enrollment: &greentic_update::enroll::Enrollment,
276) -> Result<Vec<Value>, OpError> {
277 let items = [
283 (KEY_NAME, enrollment.client_key_pem.as_str()),
284 (CA_NAME, enrollment.ca_pem.as_str()),
285 (CA_URL_NAME, ca_url),
286 (CERT_NAME, enrollment.client_cert_pem.as_str()),
287 ];
288 let mut stored = Vec::with_capacity(items.len());
289 for (name, value) in items {
290 let rel_path = tls_rel_path(tenant, name);
291 let (store_uri, _extra) = put_env_secret(store, env, env_id, kind_path, &rel_path, value)?;
292 stored.push(json!({"name": name, "store_uri": store_uri}));
293 }
294 Ok(stored)
295}
296
297pub fn status(
301 store: &LocalFsStore,
302 flags: &OpFlags,
303 payload: Option<UpdatesStatusPayload>,
304) -> Result<OpOutcome, OpError> {
305 if flags.schema_only {
306 return Ok(OpOutcome::new(NOUN, "status", status_schema()));
307 }
308 let payload = resolve_payload::<UpdatesStatusPayload>(flags, payload)?;
309 let env_id = parse_env_id(&payload.environment_id)?;
310 let env = store.load(&env_id)?;
311 let secrets = require_secrets_pack(&env, &env_id)?;
312 let kind_path = secrets.kind.path();
313 let tenant = require_tenant(&env, &env_id)?;
314
315 let cert_rel = tls_rel_path(&tenant, CERT_NAME);
316 let (cert_pem, _store_uri, _extra) =
317 get_env_secret(store, &env, &env_id, kind_path, &cert_rel)?;
318
319 let body = match cert_pem {
320 None => json!({
321 "environment_id": env_id.as_str(),
322 "tenant": tenant,
323 "secrets_kind": secrets.kind.to_string(),
324 "enrolled": false,
325 }),
326 Some(pem) => {
327 let info = greentic_update::tls::parse_cert_info(&pem).map_err(|e| {
328 OpError::Conflict(format!(
329 "stored update-channel certificate is unparseable: {e}"
330 ))
331 })?;
332 json!({
333 "environment_id": env_id.as_str(),
334 "tenant": tenant,
335 "secrets_kind": secrets.kind.to_string(),
336 "enrolled": true,
337 "serial": info.serial_hex,
338 "not_before_epoch": info.not_before_epoch,
339 "not_after_epoch": info.not_after_epoch,
340 })
341 }
342 };
343 Ok(OpOutcome::new(NOUN, "status", body))
344}
345
346pub fn get(
361 store: &LocalFsStore,
362 flags: &OpFlags,
363 payload: Option<UpdatesGetPayload>,
364) -> Result<OpOutcome, OpError> {
365 get_impl(store, flags, payload, None)
366}
367
368fn get_impl(
372 store: &LocalFsStore,
373 flags: &OpFlags,
374 payload: Option<UpdatesGetPayload>,
375 updates_root_override: Option<&std::path::Path>,
376) -> Result<OpOutcome, OpError> {
377 if flags.schema_only {
378 return Ok(OpOutcome::new(NOUN, "get", get_schema()));
379 }
380 let payload = resolve_payload::<UpdatesGetPayload>(flags, payload)?;
381 let env_id = parse_env_id(&payload.environment_id)?;
382 let env = store.load(&env_id)?;
383
384 let (plan_bytes, envelope_bytes) = load_plan_source(store, &env, &env_id, &payload)?;
386
387 let env_dir = store.env_dir(&env_id)?;
390 let trust = store_trust_root::load(&env_dir)?;
391 let verified = greentic_update::plan::verify_update_plan(&plan_bytes, &envelope_bytes, &trust)
392 .map_err(|e| OpError::Conflict(format!("update plan failed verification: {e}")))?;
393
394 if verified.plan.env_id != env_id.as_str() {
401 return Err(OpError::InvalidArgument(format!(
402 "plan targets env `{}`, not `{env_id}`",
403 verified.plan.env_id
404 )));
405 }
406 let manifest: EnvManifest =
407 serde_json::from_value(verified.plan.target.clone()).map_err(|e| {
408 OpError::InvalidArgument(format!(
409 "plan target is not a valid {ENV_MANIFEST_SCHEMA_V1}: {e}"
410 ))
411 })?;
412 if manifest.environment.id != env_id.as_str() {
413 return Err(OpError::InvalidArgument(format!(
414 "plan target manifest names env `{}`, not `{env_id}`",
415 manifest.environment.id
416 )));
417 }
418
419 let root = open_updates_root(&env_id, updates_root_override)?;
434 let staged = admit_or_resume(&root, &verified, &plan_bytes, &envelope_bytes)?;
435
436 let artifacts_total = verified.plan.artifacts.len();
442 let final_stage = if artifacts_total == 0 {
443 advance_to_staged(&staged)?
444 } else {
445 let fetcher = DistArtifactFetcher::new();
446 download_and_stage(
447 &staged,
448 &verified.plan.artifacts,
449 &fetcher,
450 RetryPolicy::default(),
451 )?
452 };
453
454 Ok(OpOutcome::new(
455 NOUN,
456 "get",
457 json!({
458 "environment_id": env_id.as_str(),
459 "plan_id": verified.plan.plan_id,
460 "sequence": verified.plan.sequence,
461 "plan_sha256": verified.plan_sha256,
462 "verified_key_ids": verified.verified_key_ids,
463 "stage": final_stage.as_str(),
464 "artifacts_total": artifacts_total,
465 "plan_dir": staged.dir().display().to_string(),
466 }),
467 ))
468}
469
470fn open_updates_root(
473 env_id: &EnvId,
474 root_override: Option<&std::path::Path>,
475) -> Result<greentic_update::staging::UpdatesRoot, OpError> {
476 let opened = match root_override {
477 Some(root) => greentic_update::staging::UpdatesRoot::open_in(root, env_id.as_str()),
478 None => greentic_update::staging::UpdatesRoot::open(env_id.as_str()),
479 };
480 opened.map_err(|e| OpError::Conflict(format!("open update staging root: {e}")))
481}
482
483fn admit_plan(
491 verified: &greentic_update::plan::VerifiedUpdatePlan,
492 facts: &greentic_update::staging::AdmissionFacts,
493) -> Result<(), OpError> {
494 greentic_update::plan::ensure_not_downgrade(&verified.plan, facts.latest_applied_sequence)
497 .map_err(|e| OpError::Conflict(format!("update plan rejected: {e}")))?;
498 let runtime_facts = greentic_update::plan::RuntimeFacts {
500 runtime_version: Some(env!("CARGO_PKG_VERSION")),
503 abi: None,
507 applied_plan_ids: &facts.applied_plan_ids,
508 };
509 greentic_update::plan::check_compat(&verified.plan.compat, &runtime_facts)
510 .map_err(|e| OpError::Conflict(format!("update plan incompatible: {e}")))
511}
512
513fn admit_or_resume(
529 root: &greentic_update::staging::UpdatesRoot,
530 verified: &greentic_update::plan::VerifiedUpdatePlan,
531 plan_bytes: &[u8],
532 envelope_bytes: &[u8],
533) -> Result<greentic_update::staging::StagedPlan, OpError> {
534 use greentic_update::staging::UpdateStage;
535 match root
536 .load(&verified.plan.plan_id)
537 .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
538 {
539 Some(existing) => {
540 if existing.plan_sha256() != verified.plan_sha256 {
541 return Err(OpError::Conflict(format!(
542 "a different plan is already staged under id `{}`",
543 verified.plan.plan_id
544 )));
545 }
546 let stage = existing
547 .stage()
548 .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
549 match stage {
550 UpdateStage::Failed | UpdateStage::Rejected => Err(OpError::Conflict(format!(
552 "plan `{}` is already `{stage}`; not resuming",
553 verified.plan.plan_id
554 ))),
555 UpdateStage::Downloading | UpdateStage::Inbox => {
558 admit_plan(verified, ¤t_admission_facts(root)?)?;
559 Ok(existing)
560 }
561 UpdateStage::Staged | UpdateStage::Applying | UpdateStage::Applied => Ok(existing),
563 }
564 }
565 None => root
566 .begin_checked(verified, plan_bytes, envelope_bytes, |facts| {
567 admit_plan(verified, facts)
568 })
569 .map_err(|e| match e {
570 greentic_update::staging::BeginCheckedError::Rejected(op) => op,
571 greentic_update::staging::BeginCheckedError::Staging(s) => {
572 OpError::Conflict(format!("stage update plan: {s}"))
573 }
574 }),
575 }
576}
577
578fn current_admission_facts(
581 root: &greentic_update::staging::UpdatesRoot,
582) -> Result<greentic_update::staging::AdmissionFacts, OpError> {
583 let applied: Vec<_> = root
584 .list()
585 .map_err(|e| OpError::Conflict(format!("list staged update plans: {e}")))?
586 .into_iter()
587 .filter(|s| s.stage == greentic_update::staging::UpdateStage::Applied)
588 .collect();
589 Ok(greentic_update::staging::AdmissionFacts {
590 latest_applied_sequence: applied.iter().map(|s| s.sequence).max(),
591 applied_plan_ids: applied.into_iter().map(|s| s.plan_id).collect(),
592 })
593}
594
595fn advance_to_staged(
601 staged: &greentic_update::staging::StagedPlan,
602) -> Result<greentic_update::staging::UpdateStage, OpError> {
603 use greentic_update::staging::UpdateStage;
604 let mut stage = staged
605 .stage()
606 .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
607 if stage == UpdateStage::Downloading {
608 stage = staged
609 .transition(UpdateStage::Inbox)
610 .map_err(|e| OpError::Conflict(format!("advance update staging: {e}")))?
611 .stage;
612 }
613 if stage == UpdateStage::Inbox {
614 stage = staged
615 .transition(UpdateStage::Staged)
616 .map_err(|e| OpError::Conflict(format!("advance update staging: {e}")))?
617 .stage;
618 }
619 Ok(stage)
620}
621
622pub fn apply_updates(
658 store: &LocalFsStore,
659 flags: &OpFlags,
660 payload: Option<ApplyUpdatesPayload>,
661) -> Result<OpOutcome, OpError> {
662 apply_updates_impl(store, flags, payload, None)
663}
664
665fn apply_updates_impl(
668 store: &LocalFsStore,
669 flags: &OpFlags,
670 payload: Option<ApplyUpdatesPayload>,
671 updates_root_override: Option<&std::path::Path>,
672) -> Result<OpOutcome, OpError> {
673 use greentic_update::staging::{RetentionPolicy, UpdateStage};
674
675 if flags.schema_only {
676 return Ok(OpOutcome::new(NOUN, "apply", apply_updates_schema()));
677 }
678 let payload = resolve_payload::<ApplyUpdatesPayload>(flags, payload)?;
679 let env_id = parse_env_id(&payload.environment_id)?;
680
681 let root = open_updates_root(&env_id, updates_root_override)?;
684 let staged = root
685 .load(&payload.plan_id)
686 .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
687 .ok_or_else(|| {
688 OpError::NotFound(format!(
689 "no staged plan `{}` under env `{env_id}`; run `op updates get` first",
690 payload.plan_id
691 ))
692 })?;
693
694 let stage = staged
704 .stage()
705 .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
706 match stage {
707 UpdateStage::Staged => {}
708 UpdateStage::Applying => {
709 return Err(OpError::Conflict(format!(
710 "plan `{}` is already `applying` on env `{env_id}` (another apply may be in \
711 progress, or a prior one did not finish); retry once it settles",
712 payload.plan_id
713 )));
714 }
715 other => {
716 return Err(OpError::InvalidArgument(format!(
717 "plan `{}` is `{other}`, not `staged`; only a staged plan can be applied",
718 payload.plan_id
719 )));
720 }
721 }
722
723 let verified = match reverify_staged(store, &staged, &env_id) {
726 Ok(v) => v,
727 Err(e) => {
728 let _ = staged.transition(UpdateStage::Rejected);
729 return Err(e);
730 }
731 };
732
733 if !verified.plan.binaries.is_empty() {
739 tracing::warn!(
740 env_id = %env_id,
741 binary_count = verified.plan.binaries.len(),
742 "update plan carries binary artifact(s) that `op updates apply` does not \
743 install; binary self-update is applied by the greentic-start update receiver"
744 );
745 }
746
747 let mut staged_blobs: BTreeMap<String, PathBuf> = BTreeMap::new();
757 for artifact in &verified.plan.artifacts {
758 if let Err(e) = staged.verify_artifact_on_disk(artifact) {
759 let _ = staged.transition(UpdateStage::Rejected);
760 return Err(OpError::Conflict(format!(
761 "staged artifact `{}` failed integrity re-check: {e}",
762 artifact.name
763 )));
764 }
765 let blob = staged.artifact_blob_path(artifact).map_err(|e| {
768 OpError::Conflict(format!(
769 "resolve staged blob path for artifact `{}`: {e}",
770 artifact.name
771 ))
772 })?;
773 staged_blobs.insert(artifact.digest.clone(), blob);
774 }
775
776 let target = materialize_bundles(&verified.plan.target, &staged_blobs);
780 let ctx = AuditCtx {
781 env_id: env_id.clone(),
782 noun: NOUN,
783 verb: "apply",
784 target: json!({
785 "environment_id": env_id.as_str(),
786 "plan_id": verified.plan.plan_id,
787 "sequence": verified.plan.sequence,
788 "plan_sha256": verified.plan_sha256,
789 }),
790 idempotency_key: Some(verified.plan.plan_id.clone()),
793 };
794 audit_and_record(store, ctx, |committed| {
795 match root.begin_apply_checked(&verified.plan.plan_id, |facts| admit_plan(&verified, facts))
803 {
804 Ok(_applying) => {}
805 Err(greentic_update::staging::BeginApplyError::Rejected(e)) => {
806 let _ = staged.transition(UpdateStage::Rejected);
809 return Err(e);
810 }
811 Err(greentic_update::staging::BeginApplyError::AlreadyApplying {
812 applying, ..
813 }) => {
814 return Err(OpError::Conflict(format!(
815 "another update plan (`{applying}`) is already applying to env `{env_id}`; \
816 apply is single-flight per environment"
817 )));
818 }
819 Err(greentic_update::staging::BeginApplyError::Staging(s)) => {
820 if staged
829 .stage()
830 .map(|st| st == UpdateStage::Applying)
831 .unwrap_or(false)
832 {
833 committed.mark_committed();
834 }
835 return Err(OpError::Conflict(format!("apply admission failed: {s}")));
836 }
837 }
838 committed.mark_committed();
841
842 let snap_id = match snapshot_environment(store, &env_id) {
845 Ok(id) => id,
846 Err(e) => {
847 let _ = staged.transition(UpdateStage::Failed);
848 return Err(e.into());
849 }
850 };
851
852 match run_manifest_apply(store, &target) {
854 Ok(apply_outcome) => {
855 let build_success_outcome = || {
859 let mut body = json!({
860 "environment_id": env_id.as_str(),
861 "plan_id": verified.plan.plan_id,
862 "sequence": verified.plan.sequence,
863 "plan_sha256": verified.plan_sha256,
864 "snapshot_id": snap_id.to_string(),
865 "stage": UpdateStage::Applied.as_str(),
866 "apply_result": apply_outcome.result,
867 });
868 if !verified.plan.binaries.is_empty() {
873 let not_applied: Vec<Value> = verified
874 .plan
875 .binaries
876 .iter()
877 .map(|b| {
878 json!({"name": b.name, "version": b.version, "target": b.target})
879 })
880 .collect();
881 body["binaries_not_applied"] = Value::Array(not_applied);
882 }
883 let outcome = OpOutcome::new(NOUN, "apply", body);
884 Ok((outcome, super::AuditGens::NONE))
885 };
886
887 #[cfg(test)]
891 run_pre_applied_transition_hook();
892
893 for attempt in 0..2u8 {
901 match staged.transition(UpdateStage::Applied) {
902 Ok(_) => {
903 let _ = root.apply_retention(&RetentionPolicy { keep_terminal: 5 });
905 return build_success_outcome();
906 }
907 Err(e) => {
908 if attempt == 0 {
909 tracing::warn!(
910 plan_id = %verified.plan.plan_id,
911 error = %e,
912 "applying -> applied transition failed; retrying in 200ms"
913 );
914 std::thread::sleep(std::time::Duration::from_millis(200));
915 }
916 }
918 }
919 }
920
921 match staged.stage() {
925 Ok(UpdateStage::Applied) => {
926 let _ = root.apply_retention(&RetentionPolicy { keep_terminal: 5 });
930 build_success_outcome()
931 }
932 stage_result => {
933 let detail = match stage_result {
940 Ok(s) => format!(" (current stage: `{}`)", s.as_str()),
941 Err(e) => format!(" and the current stage is unreadable: {e}"),
942 };
943 Err(OpError::Conflict(format!(
944 "plan `{}` content was applied successfully, but the staging \
945 marker could not advance to `applied`{}; \
946 the environment is correct — run \
947 `op updates recover --force` to un-stick the marker, then \
948 re-stage with `op updates get` if sequence tracking matters",
949 verified.plan.plan_id, detail,
950 )))
951 }
952 }
953 }
954 Err(apply_err) => {
955 let restored = restore_environment(store, &env_id, &snap_id);
961 let _ = staged.transition(UpdateStage::Failed);
962 match restored {
963 Ok(()) => Err(OpError::Conflict(format!(
964 "apply of plan `{}` failed; environment rolled back to snapshot `{snap_id}`: \
965 {apply_err}",
966 verified.plan.plan_id
967 ))),
968 Err(restore_err) => {
969 tracing::error!(
970 env_id = %env_id,
971 snapshot_id = %snap_id,
972 apply_error = %apply_err,
973 restore_error = %restore_err,
974 "apply-updates rollback FAILED; environment may be partially applied"
975 );
976 Err(OpError::Conflict(format!(
977 "apply of plan `{}` failed AND automatic rollback FAILED; the \
978 environment may be partially applied — manual recovery is required \
979 from snapshot `{snap_id}`. apply error: {apply_err}; rollback error: \
980 {restore_err}",
981 verified.plan.plan_id
982 )))
983 }
984 }
985 }
986 }
987 })
988}
989
990pub fn recover_updates(
1010 store: &LocalFsStore,
1011 flags: &OpFlags,
1012 payload: Option<RecoverUpdatesPayload>,
1013 force: bool,
1014) -> Result<OpOutcome, OpError> {
1015 recover_updates_impl(store, flags, payload, force, None)
1016}
1017
1018fn recover_updates_impl(
1021 store: &LocalFsStore,
1022 flags: &OpFlags,
1023 payload: Option<RecoverUpdatesPayload>,
1024 force: bool,
1025 updates_root_override: Option<&std::path::Path>,
1026) -> Result<OpOutcome, OpError> {
1027 use greentic_update::staging::UpdateStage;
1028
1029 if flags.schema_only {
1030 return Ok(OpOutcome::new(NOUN, "recover", recover_schema()));
1031 }
1032 let payload = resolve_payload::<RecoverUpdatesPayload>(flags, payload)?;
1033 let env_id = parse_env_id(&payload.environment_id)?;
1034
1035 let root = open_updates_root(&env_id, updates_root_override)?;
1038 let staged = root
1039 .load(&payload.plan_id)
1040 .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
1041 .ok_or_else(|| {
1042 OpError::NotFound(format!(
1043 "no staged plan `{}` under env `{env_id}`; nothing to recover",
1044 payload.plan_id
1045 ))
1046 })?;
1047
1048 let state = staged
1052 .state()
1053 .map_err(|e| OpError::Conflict(format!("read update staging state: {e}")))?;
1054 match state.stage {
1055 UpdateStage::Applying => {}
1056 UpdateStage::Staged => {
1057 return Err(OpError::InvalidArgument(format!(
1058 "plan `{}` is `staged`, not `applying`; nothing to recover — apply it with \
1059 `op updates apply`",
1060 payload.plan_id
1061 )));
1062 }
1063 terminal @ (UpdateStage::Applied | UpdateStage::Failed | UpdateStage::Rejected) => {
1064 return Err(OpError::InvalidArgument(format!(
1065 "plan `{}` is already `{terminal}` (terminal); nothing to recover",
1066 payload.plan_id
1067 )));
1068 }
1069 staging @ (UpdateStage::Downloading | UpdateStage::Inbox) => {
1070 return Err(OpError::InvalidArgument(format!(
1071 "plan `{}` is `{staging}` (still staging); nothing was applied, so there is \
1072 nothing to recover — re-run `op updates get`",
1073 payload.plan_id
1074 )));
1075 }
1076 }
1077
1078 let applying_since = state.updated_at.to_rfc3339();
1081
1082 if !force {
1087 return Err(OpError::Conflict(format!(
1088 "plan `{}` is `applying` on env `{env_id}` (since {applying_since}); recover \
1089 force-fails it to `failed`, which is UNSAFE if an apply is genuinely in progress. \
1090 If you have confirmed no apply is running for this plan, re-run with `--force`",
1091 payload.plan_id
1092 )));
1093 }
1094
1095 let ctx = AuditCtx {
1096 env_id: env_id.clone(),
1097 noun: NOUN,
1098 verb: "recover",
1099 target: json!({
1100 "environment_id": env_id.as_str(),
1101 "plan_id": payload.plan_id,
1102 "previous_stage": UpdateStage::Applying.as_str(),
1103 "applying_since": applying_since,
1104 }),
1105 idempotency_key: Some(payload.plan_id.clone()),
1108 };
1109 audit_and_record(store, ctx, |committed| {
1110 if let Err(e) = staged.transition(UpdateStage::Failed) {
1121 if staged
1122 .stage()
1123 .map(|s| s == UpdateStage::Failed)
1124 .unwrap_or(false)
1125 {
1126 committed.mark_committed();
1127 }
1128 return Err(OpError::Conflict(format!(
1129 "force-fail plan (applying → failed): {e}"
1130 )));
1131 }
1132 committed.mark_committed();
1133 let outcome = OpOutcome::new(
1134 NOUN,
1135 "recover",
1136 json!({
1137 "environment_id": env_id.as_str(),
1138 "plan_id": payload.plan_id,
1139 "previous_stage": UpdateStage::Applying.as_str(),
1140 "stage": UpdateStage::Failed.as_str(),
1141 "applying_since": applying_since,
1142 "note": "recover un-stuck the update FSM (applying → failed); it did NOT roll \
1143 back any partial environment change from the interrupted apply. Inspect \
1144 the environment and restore from a snapshot under <env_dir>/snapshots/ if \
1145 needed. This plan id is now terminal (`failed`) and cannot be re-staged; \
1146 retry by fetching a fresh plan with `op updates get`.",
1147 }),
1148 );
1149 Ok((outcome, super::AuditGens::NONE))
1150 })
1151}
1152
1153pub fn config_set(
1158 store: &LocalFsStore,
1159 flags: &OpFlags,
1160 payload: Option<UpdateConfigSetPayload>,
1161) -> Result<OpOutcome, OpError> {
1162 if flags.schema_only {
1163 return Ok(OpOutcome::new(NOUN, "config-set", config_set_schema()));
1164 }
1165 let payload = resolve_payload::<UpdateConfigSetPayload>(flags, payload)?;
1166 let env_id = parse_env_id(&payload.environment_id)?;
1167
1168 let parsed_on_notify = payload
1171 .on_notify
1172 .as_deref()
1173 .map(|raw| {
1174 OnNotifyAction::parse(raw).ok_or_else(|| {
1175 OpError::InvalidArgument(format!(
1176 "on_notify {raw:?} is not a valid action (expected `record-only` or `stage`)"
1177 ))
1178 })
1179 })
1180 .transpose()?;
1181 if let Some(secs) = payload.poll_interval_secs
1182 && secs < MIN_POLL_INTERVAL_SECS
1183 {
1184 return Err(OpError::InvalidArgument(format!(
1185 "poll_interval_secs {secs} is below the {MIN_POLL_INTERVAL_SECS}s floor"
1186 )));
1187 }
1188 if !store.exists(&env_id)? {
1189 return Err(OpError::NotFound(format!(
1190 "environment `{env_id}` not found"
1191 )));
1192 }
1193
1194 let mut fields = Vec::new();
1195 if payload.enabled.is_some() {
1196 fields.push("enabled");
1197 }
1198 if parsed_on_notify.is_some() {
1199 fields.push("on_notify");
1200 }
1201 if payload.poll_interval_secs.is_some() {
1202 fields.push("poll_interval_secs");
1203 }
1204
1205 let ctx = AuditCtx {
1206 env_id: env_id.clone(),
1207 noun: NOUN,
1208 verb: "config-set",
1209 target: json!({ "fields": fields }),
1210 idempotency_key: None,
1211 };
1212 audit_and_record(store, ctx, |_committed| {
1213 let cfg = store.transact(&env_id, |locked| -> Result<UpdateChannelConfig, OpError> {
1219 locked.load()?;
1221 let mut cfg = locked
1222 .load_update_channel()?
1223 .unwrap_or_else(|| UpdateChannelConfig::disabled(env_id.clone()));
1224 if let Some(enabled) = payload.enabled {
1225 cfg.enabled = Some(enabled);
1226 }
1227 if let Some(on_notify) = parsed_on_notify {
1228 cfg.on_notify = Some(on_notify);
1229 }
1230 if let Some(secs) = payload.poll_interval_secs {
1231 cfg.poll_interval_secs = Some(secs);
1232 }
1233 locked.save_update_channel(&cfg)?;
1234 Ok(cfg)
1235 })?;
1236 let outcome = OpOutcome::new(NOUN, "config-set", config_view(&cfg));
1237 Ok((outcome, super::AuditGens::NONE))
1238 })
1239}
1240
1241pub fn config_show(
1244 store: &LocalFsStore,
1245 flags: &OpFlags,
1246 payload: Option<UpdateConfigShowFilter>,
1247) -> Result<OpOutcome, OpError> {
1248 if flags.schema_only {
1249 return Ok(OpOutcome::new(NOUN, "config-show", config_show_schema()));
1250 }
1251 let payload = resolve_payload::<UpdateConfigShowFilter>(flags, payload)?;
1252 let env_id = parse_env_id(&payload.environment_id)?;
1253 if !store.exists(&env_id)? {
1254 return Err(OpError::NotFound(format!(
1255 "environment `{env_id}` not found"
1256 )));
1257 }
1258 let cfg = store
1259 .load_update_channel(&env_id)?
1260 .unwrap_or_else(|| UpdateChannelConfig::disabled(env_id.clone()));
1261 Ok(OpOutcome::new(NOUN, "config-show", config_view(&cfg)))
1262}
1263
1264fn config_view(cfg: &UpdateChannelConfig) -> Value {
1268 json!({
1269 "environment_id": cfg.environment_id.as_str(),
1270 "enabled": cfg.enabled,
1271 "on_notify": cfg.on_notify.map(|a| a.as_str()),
1272 "poll_interval_secs": cfg.poll_interval_secs,
1273 "resolved": {
1274 "enabled": cfg.resolved_enabled(),
1275 "on_notify": cfg.resolved_on_notify().as_str(),
1276 "poll_interval_secs": cfg.resolved_poll_interval_secs(),
1277 }
1278 })
1279}
1280
1281fn reverify_staged(
1287 store: &LocalFsStore,
1288 staged: &greentic_update::staging::StagedPlan,
1289 env_id: &EnvId,
1290) -> Result<greentic_update::plan::VerifiedUpdatePlan, OpError> {
1291 let plan_bytes = staged
1292 .plan_bytes()
1293 .map_err(|e| OpError::Conflict(format!("read staged plan bytes: {e}")))?;
1294 let envelope_bytes = staged
1295 .envelope_bytes()
1296 .map_err(|e| OpError::Conflict(format!("read staged plan envelope: {e}")))?;
1297
1298 let env_dir = store.env_dir(env_id)?;
1299 let trust = store_trust_root::load(&env_dir)?;
1300 let verified = greentic_update::plan::verify_update_plan(&plan_bytes, &envelope_bytes, &trust)
1301 .map_err(|e| OpError::Conflict(format!("staged plan failed re-verification: {e}")))?;
1302
1303 if verified.plan_sha256 != staged.plan_sha256() {
1306 return Err(OpError::Conflict(format!(
1307 "staged plan `{}` hash changed since staging (tampered on disk?)",
1308 verified.plan.plan_id
1309 )));
1310 }
1311 if verified.plan.env_id != env_id.as_str() {
1314 return Err(OpError::InvalidArgument(format!(
1315 "staged plan targets env `{}`, not `{env_id}`",
1316 verified.plan.env_id
1317 )));
1318 }
1319 let manifest: EnvManifest =
1320 serde_json::from_value(verified.plan.target.clone()).map_err(|e| {
1321 OpError::InvalidArgument(format!(
1322 "plan target is not a valid {ENV_MANIFEST_SCHEMA_V1}: {e}"
1323 ))
1324 })?;
1325 if manifest.environment.id != env_id.as_str() {
1326 return Err(OpError::InvalidArgument(format!(
1327 "plan target manifest names env `{}`, not `{env_id}`",
1328 manifest.environment.id
1329 )));
1330 }
1331 let env = store.load(env_id)?;
1336 let dev_secrets_path_override =
1337 std::env::var_os(super::secrets::DEV_SECRETS_PATH_ENV).is_some();
1338 check_applyable_manifest(&env, &env_dir, &manifest, dev_secrets_path_override)?;
1339 Ok(verified)
1340}
1341
1342fn check_applyable_manifest(
1364 env: &Environment,
1365 env_dir: &Path,
1366 manifest: &EnvManifest,
1367 dev_secrets_path_override: bool,
1368) -> Result<(), OpError> {
1369 if (!manifest.secrets.is_empty() || !manifest.messaging_endpoints.is_empty())
1373 && let Err(reason) =
1374 dev_store_secret_sink_is_snapshotted(env, env_dir, manifest, dev_secrets_path_override)
1375 {
1376 return Err(dev_store_secret_err(reason));
1377 }
1378 for bundle in &manifest.bundles {
1379 match &bundle.revisions {
1380 Some(revisions) => {
1381 for rev in revisions {
1382 if rev.bundle_digest.is_none() {
1383 return Err(unpinned_bundle_err(&bundle.bundle_id, Some(&rev.name)));
1384 }
1385 }
1386 }
1387 None => {
1388 if bundle.bundle_digest.is_none() {
1389 return Err(unpinned_bundle_err(&bundle.bundle_id, None));
1390 }
1391 }
1392 }
1393 }
1394 Ok(())
1395}
1396
1397fn dev_store_secret_sink_is_snapshotted(
1421 env: &Environment,
1422 env_dir: &Path,
1423 manifest: &EnvManifest,
1424 dev_secrets_path_override: bool,
1425) -> Result<(), &'static str> {
1426 if !crate::cli::env::secrets_backend_is_dev_store(env) {
1427 return Err("the env's Secrets slot is bound to a non-dev-store backend");
1428 }
1429 if manifest_rebinds_secrets_off_dev_store(manifest) {
1430 return Err("the manifest rebinds the Secrets slot to a non-dev-store backend");
1431 }
1432 if dev_secrets_path_override {
1433 return Err(
1434 "GREENTIC_DEV_SECRETS_PATH redirects the dev-store off the snapshotted env tree",
1435 );
1436 }
1437 for rel in [
1441 crate::cli::secrets::DEV_STORE_RELATIVE,
1442 crate::cli::secrets::DEV_STORE_STATE_RELATIVE,
1443 ] {
1444 if crate::path_safety::assert_no_symlink_ancestors(env_dir, &env_dir.join(rel)).is_err() {
1445 return Err("a dev-store secrets file resolves through a symlink outside the env tree");
1446 }
1447 }
1448 Ok(())
1449}
1450
1451fn manifest_rebinds_secrets_off_dev_store(manifest: &EnvManifest) -> bool {
1455 manifest.packs.iter().any(|p| {
1456 p.slot == greentic_deploy_spec::CapabilitySlot::Secrets
1457 && greentic_deploy_spec::PackDescriptor::try_new(&p.kind)
1458 .map(|d| d.path() != crate::defaults::DEV_STORE_SECRETS_PATH)
1459 .unwrap_or(true)
1460 })
1461}
1462
1463fn dev_store_secret_err(reason: &str) -> OpError {
1464 OpError::InvalidArgument(format!(
1465 "update plan target declares secrets[] or messaging_endpoints[], but {reason}; env_apply \
1466 writes dev-store secret material that the environment snapshot would not cover, so a \
1467 rollback could not undo it"
1468 ))
1469}
1470
1471fn unpinned_bundle_err(bundle_id: &str, revision: Option<&str>) -> OpError {
1472 let target = match revision {
1473 Some(r) => format!("bundle `{bundle_id}` revision `{r}`"),
1474 None => format!("bundle `{bundle_id}`"),
1475 };
1476 OpError::InvalidArgument(format!(
1477 "update plan target {target} has no bundle_digest; update-plan bundles must be \
1478 digest-pinned so the applied content is verified against the signed plan"
1479 ))
1480}
1481
1482fn materialize_bundles(target: &Value, staged_blobs: &BTreeMap<String, PathBuf>) -> Value {
1493 let mut target = target.clone();
1494 let Some(bundles) = target.get_mut("bundles").and_then(Value::as_array_mut) else {
1495 return target;
1496 };
1497 for bundle in bundles {
1498 match bundle.get_mut("revisions").and_then(Value::as_array_mut) {
1499 Some(revisions) => {
1501 for rev in revisions {
1502 materialize_entry(rev, staged_blobs);
1503 }
1504 }
1505 None => materialize_entry(bundle, staged_blobs),
1507 }
1508 }
1509 target
1510}
1511
1512fn materialize_entry(entry: &mut Value, staged_blobs: &BTreeMap<String, PathBuf>) {
1518 let Some(digest) = entry.get("bundle_digest").and_then(Value::as_str) else {
1519 return;
1520 };
1521 match staged_blobs.get(digest) {
1522 Some(blob) => {
1523 entry["bundle_path"] = Value::String(blob.to_string_lossy().into_owned());
1526 }
1527 None => {
1528 tracing::warn!(
1529 bundle_digest = %digest,
1530 "update bundle digest not in the staged set; apply will fall back to its \
1531 declared remote source"
1532 );
1533 }
1534 }
1535}
1536
1537fn run_manifest_apply(store: &LocalFsStore, target: &Value) -> Result<OpOutcome, OpError> {
1541 use std::io::Write as _;
1542
1543 let bytes = serde_json::to_vec(target)
1544 .map_err(|e| OpError::InvalidArgument(format!("serialize plan target manifest: {e}")))?;
1545 let mut tmp = tempfile::Builder::new()
1546 .prefix("greentic-update-target-")
1547 .suffix(".json")
1548 .tempfile()
1549 .map_err(|source| OpError::Io {
1550 path: PathBuf::from("<tempfile>"),
1551 source,
1552 })?;
1553 tmp.write_all(&bytes).map_err(|source| OpError::Io {
1554 path: tmp.path().to_path_buf(),
1555 source,
1556 })?;
1557 tmp.flush().map_err(|source| OpError::Io {
1558 path: tmp.path().to_path_buf(),
1559 source,
1560 })?;
1561
1562 let apply_flags = OpFlags {
1563 schema_only: false,
1564 answers: Some(tmp.path().to_path_buf()),
1565 };
1566 let opts = super::env_apply::ApplyOptions {
1567 mode: super::env_apply::ApplyMode::Apply,
1568 updated_by: Some("apply-updates".to_string()),
1569 yes: true,
1570 non_interactive: true,
1571 ..Default::default()
1572 };
1573 super::env_apply::apply(store, &apply_flags, opts)
1574}
1575
1576trait ArtifactFetcher {
1579 fn fetch(&self, artifact: &greentic_update::plan::PlanArtifact) -> Result<Vec<u8>, OpError>;
1580}
1581
1582#[derive(Clone, Copy)]
1585struct RetryPolicy {
1586 attempts: u32,
1587 base_delay: std::time::Duration,
1588}
1589
1590impl Default for RetryPolicy {
1591 fn default() -> Self {
1592 Self {
1593 attempts: 3,
1594 base_delay: std::time::Duration::from_millis(500),
1595 }
1596 }
1597}
1598
1599struct DistArtifactFetcher {
1606 client: DistClient,
1607}
1608
1609impl DistArtifactFetcher {
1610 fn new() -> Self {
1611 Self {
1612 client: DistClient::new(DistOptions::default()),
1613 }
1614 }
1615}
1616
1617impl ArtifactFetcher for DistArtifactFetcher {
1618 fn fetch(&self, artifact: &greentic_update::plan::PlanArtifact) -> Result<Vec<u8>, OpError> {
1619 let source = artifact.source.as_deref().ok_or_else(|| {
1620 OpError::InvalidArgument(format!(
1621 "artifact `{}` declares no source to download (in-band airgap \
1622 artifacts are not supported by `op updates get`)",
1623 artifact.name
1624 ))
1625 })?;
1626 if !(source.starts_with("https://") || source.starts_with("oci://")) {
1632 return Err(OpError::InvalidArgument(format!(
1633 "artifact `{}` source `{source}` is not an allowed remote scheme \
1634 (expected `https://` or `oci://`)",
1635 artifact.name
1636 )));
1637 }
1638 rt::sync_await(async {
1639 let parsed = self
1640 .client
1641 .parse_source(source)
1642 .map_err(|e| OpError::Fetch(format!("parse artifact source `{source}`: {e}")))?;
1643 let descriptor = self
1644 .client
1645 .resolve(parsed, ResolvePolicy)
1646 .await
1647 .map_err(|e| {
1648 OpError::Fetch(format!("resolve artifact `{}`: {e}", artifact.name))
1649 })?;
1650 reject_oversize(artifact, descriptor.size_bytes)?;
1653 let resolved = self
1654 .client
1655 .fetch(&descriptor, CachePolicy)
1656 .await
1657 .map_err(|e| OpError::Fetch(format!("fetch artifact `{}`: {e}", artifact.name)))?;
1658 let len = std::fs::metadata(&resolved.local_path)
1661 .map_err(|e| {
1662 OpError::Fetch(format!(
1663 "stat fetched artifact `{}` at {}: {e}",
1664 artifact.name,
1665 resolved.local_path.display()
1666 ))
1667 })?
1668 .len();
1669 reject_oversize(artifact, len)?;
1670 std::fs::read(&resolved.local_path).map_err(|e| {
1671 OpError::Fetch(format!(
1672 "read fetched artifact `{}` at {}: {e}",
1673 artifact.name,
1674 resolved.local_path.display()
1675 ))
1676 })
1677 })
1678 }
1679}
1680
1681const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
1686
1687fn reject_oversize(
1688 artifact: &greentic_update::plan::PlanArtifact,
1689 size: u64,
1690) -> Result<(), OpError> {
1691 if size > MAX_ARTIFACT_BYTES {
1692 return Err(OpError::Fetch(format!(
1693 "artifact `{}` is {size} bytes, over the {MAX_ARTIFACT_BYTES}-byte cap",
1694 artifact.name
1695 )));
1696 }
1697 Ok(())
1698}
1699
1700fn fetch_with_retry(
1704 fetcher: &dyn ArtifactFetcher,
1705 artifact: &greentic_update::plan::PlanArtifact,
1706 retry: RetryPolicy,
1707) -> Result<Vec<u8>, OpError> {
1708 let attempts = retry.attempts.max(1);
1709 let mut delay = retry.base_delay;
1710 let mut last_err = None;
1711 for attempt in 1..=attempts {
1712 match fetcher.fetch(artifact) {
1713 Ok(bytes) => return Ok(bytes),
1714 Err(e) => {
1715 last_err = Some(e);
1716 if attempt < attempts {
1717 if !delay.is_zero() {
1718 std::thread::sleep(delay);
1719 }
1720 delay = delay.saturating_mul(2);
1721 }
1722 }
1723 }
1724 }
1725 Err(last_err.expect("retry loop runs at least once"))
1726}
1727
1728fn download_and_stage(
1735 staged: &greentic_update::staging::StagedPlan,
1736 artifacts: &[greentic_update::plan::PlanArtifact],
1737 fetcher: &dyn ArtifactFetcher,
1738 retry: RetryPolicy,
1739) -> Result<greentic_update::staging::UpdateStage, OpError> {
1740 use greentic_update::staging::UpdateStage;
1741 let stage = staged
1742 .stage()
1743 .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
1744 if stage != UpdateStage::Downloading {
1747 return Ok(stage);
1748 }
1749 for artifact in artifacts {
1750 let bytes = fetch_with_retry(fetcher, artifact, retry)?;
1751 staged
1752 .put_artifact(artifact, &bytes)
1753 .map_err(|e| OpError::Conflict(format!("stage artifact `{}`: {e}", artifact.name)))?;
1754 }
1755 advance_to_staged(staged)
1757}
1758
1759fn load_plan_source(
1763 store: &LocalFsStore,
1764 env: &Environment,
1765 env_id: &EnvId,
1766 payload: &UpdatesGetPayload,
1767) -> Result<(Vec<u8>, Vec<u8>), OpError> {
1768 match (
1769 &payload.plan_url,
1770 &payload.plan_file,
1771 &payload.plan_sig_file,
1772 ) {
1773 (Some(url), None, None) => {
1774 if !control_url_is_acceptable(url) {
1779 return Err(OpError::InvalidArgument(
1780 "plan_url must be an https:// URL; plaintext http:// is accepted only for a \
1781 loopback dev server. The enrolled mTLS client identity is presented only over \
1782 TLS, so a plaintext fetch would bypass it."
1783 .to_string(),
1784 ));
1785 }
1786 fetch_plan_over_mtls(store, env, env_id, url)
1787 }
1788 (None, Some(plan), Some(sig)) => {
1789 let plan_bytes = std::fs::read(plan).map_err(|source| OpError::Io {
1790 path: plan.clone(),
1791 source,
1792 })?;
1793 let sig_bytes = std::fs::read(sig).map_err(|source| OpError::Io {
1794 path: sig.clone(),
1795 source,
1796 })?;
1797 Ok((plan_bytes, sig_bytes))
1798 }
1799 _ => Err(OpError::InvalidArgument(
1800 "exactly one plan source is required: `plan_url`, or `plan_file` with `plan_sig_file`"
1801 .to_string(),
1802 )),
1803 }
1804}
1805
1806fn fetch_plan_over_mtls(
1813 store: &LocalFsStore,
1814 env: &Environment,
1815 env_id: &EnvId,
1816 plan_url: &str,
1817) -> Result<(Vec<u8>, Vec<u8>), OpError> {
1818 let secrets = require_secrets_pack(env, env_id)?;
1819 let kind_path = secrets.kind.path();
1820 let tenant = require_tenant(env, env_id)?;
1821
1822 let read_enrolled = |name: &str| -> Result<String, OpError> {
1823 let rel = tls_rel_path(&tenant, name);
1824 let (value, _uri, _extra) = get_env_secret(store, env, env_id, kind_path, &rel)?;
1825 value.ok_or_else(|| {
1826 OpError::NotFound(format!(
1827 "env `{env_id}` is not enrolled for updates (missing `{name}`); \
1828 run `op updates enroll` first"
1829 ))
1830 })
1831 };
1832 let cert_pem = read_enrolled(CERT_NAME)?;
1833 let key_pem = read_enrolled(KEY_NAME)?;
1834 let ca_pem = read_enrolled(CA_NAME)?;
1835
1836 let sig_url = {
1839 let mut u = url::Url::parse(plan_url)
1840 .map_err(|e| OpError::InvalidArgument(format!("plan_url: {e}")))?;
1841 let sig_path = format!("{}.sig", u.path());
1842 u.set_path(&sig_path);
1843 u.to_string()
1844 };
1845 rt::sync_await(async {
1846 let client = greentic_update::tls::build_mtls_client(&greentic_update::tls::MtlsConfig {
1847 ca_pem,
1848 client_cert_pem: cert_pem,
1849 client_key_pem: key_pem,
1850 })
1851 .map_err(|e| OpError::Conflict(format!("stored mTLS identity is unusable: {e}")))?;
1852 let plan_bytes = mtls_get(&client, plan_url).await?;
1853 let sig_bytes = mtls_get(&client, &sig_url).await?;
1854 Ok::<(Vec<u8>, Vec<u8>), OpError>((plan_bytes, sig_bytes))
1855 })
1856}
1857
1858async fn mtls_get(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, OpError> {
1861 let resp = client
1862 .get(url)
1863 .send()
1864 .await
1865 .and_then(reqwest::Response::error_for_status)
1866 .map_err(|e| OpError::Fetch(format!("GET {url}: {e}")))?;
1867 let bytes = resp
1868 .bytes()
1869 .await
1870 .map_err(|e| OpError::Fetch(format!("GET {url}: reading body: {e}")))?;
1871 Ok(bytes.to_vec())
1872}
1873
1874fn require_non_empty(value: Option<String>, key: &str) -> Result<String, OpError> {
1883 let value = value.ok_or_else(|| {
1884 OpError::InvalidArgument(format!("--binary: missing required key `{key}`"))
1885 })?;
1886 if value.is_empty() {
1887 return Err(OpError::InvalidArgument(format!(
1888 "--binary: `{key}` must not be empty"
1889 )));
1890 }
1891 Ok(value)
1892}
1893
1894fn parse_binary_spec(spec: &str) -> Result<greentic_update::plan::BinaryArtifact, OpError> {
1895 let mut name: Option<String> = None;
1896 let mut version: Option<String> = None;
1897 let mut target: Option<String> = None;
1898 let mut digest: Option<String> = None;
1899 let mut source: Option<String> = None;
1900
1901 for part in spec.split(',') {
1902 let part = part.trim();
1903 if part.is_empty() {
1904 continue;
1905 }
1906 let (key, value) = part.split_once('=').ok_or_else(|| {
1907 OpError::InvalidArgument(format!("--binary: expected key=value pair, got `{part}`"))
1908 })?;
1909 match key {
1910 "name" => {
1911 if name.is_some() {
1912 return Err(OpError::InvalidArgument(
1913 "--binary: duplicate key `name`".to_string(),
1914 ));
1915 }
1916 name = Some(value.to_string());
1917 }
1918 "version" => {
1919 if version.is_some() {
1920 return Err(OpError::InvalidArgument(
1921 "--binary: duplicate key `version`".to_string(),
1922 ));
1923 }
1924 version = Some(value.to_string());
1925 }
1926 "target" => {
1927 if target.is_some() {
1928 return Err(OpError::InvalidArgument(
1929 "--binary: duplicate key `target`".to_string(),
1930 ));
1931 }
1932 target = Some(value.to_string());
1933 }
1934 "digest" => {
1935 if digest.is_some() {
1936 return Err(OpError::InvalidArgument(
1937 "--binary: duplicate key `digest`".to_string(),
1938 ));
1939 }
1940 digest = Some(value.to_string());
1941 }
1942 "source" => {
1943 if source.is_some() {
1944 return Err(OpError::InvalidArgument(
1945 "--binary: duplicate key `source`".to_string(),
1946 ));
1947 }
1948 source = Some(value.to_string());
1949 }
1950 unknown => {
1951 return Err(OpError::InvalidArgument(format!(
1952 "--binary: unknown key `{unknown}` (expected name, version, target, digest, source)"
1953 )));
1954 }
1955 }
1956 }
1957
1958 let name = require_non_empty(name, "name")?;
1959 let version = require_non_empty(version, "version")?;
1960 let target = require_non_empty(target, "target")?;
1961 let digest = require_non_empty(digest, "digest")?;
1962
1963 Ok(greentic_update::plan::BinaryArtifact {
1964 name,
1965 version,
1966 target,
1967 digest,
1968 source,
1969 })
1970}
1971
1972pub fn plan_build(
1983 store: &LocalFsStore,
1984 flags: &OpFlags,
1985 args: crate::cli::dispatch::UpdatesPlanBuildArgs,
1986) -> Result<OpOutcome, OpError> {
1987 use chrono::Utc;
1988 use greentic_update::plan::{
1989 BinaryArtifact, CompatRequirements, OnFail, RollbackKind, RollbackPolicy,
1990 UPDATE_PLAN_SCHEMA_V1, UpdatePlan,
1991 };
1992
1993 if flags.schema_only {
1994 return Ok(OpOutcome::new(NOUN, "plan-build", plan_build_schema()));
1995 }
1996
1997 let env_id_raw = args.env_id.ok_or_else(|| {
1998 OpError::InvalidArgument("env_id is required (positional argument)".to_string())
1999 })?;
2000 let env_id = parse_env_id(&env_id_raw)?;
2001
2002 let sequence = args
2003 .sequence
2004 .ok_or_else(|| OpError::InvalidArgument("--sequence is required".to_string()))?;
2005
2006 let binaries: Vec<BinaryArtifact> = args
2008 .binaries
2009 .iter()
2010 .map(|s| parse_binary_spec(s))
2011 .collect::<Result<Vec<_>, _>>()?;
2012
2013 if binaries.is_empty() && args.target_file.is_none() {
2018 return Err(OpError::InvalidArgument(
2019 "at least one --binary or a --target-file is required".to_string(),
2020 ));
2021 }
2022
2023 let (priv_pem, key_id) = match &args.signing_key {
2025 Some(key_path) => crate::operator_key::read_signing_key_at(key_path)?,
2026 None => {
2027 let op_key = crate::operator_key::load_existing_only().map_err(|e| {
2028 OpError::InvalidArgument(format!(
2029 "no --signing-key provided and the global operator key is unavailable: {e}. \
2030 Create or bootstrap the operator key first, or pass --signing-key <path>."
2031 ))
2032 })?;
2033 (op_key.private_pem, op_key.key_id)
2034 }
2035 };
2036
2037 let env_dir = store.env_dir(&env_id)?;
2039 let trust = store_trust_root::load(&env_dir)?;
2040
2041 let target: serde_json::Value = match &args.target_file {
2043 Some(path) => {
2044 let bytes = std::fs::read(path).map_err(|source| OpError::Io {
2045 path: path.clone(),
2046 source,
2047 })?;
2048 serde_json::from_slice(&bytes).map_err(|e| {
2049 OpError::InvalidArgument(format!(
2050 "target file {} is not valid JSON: {e}",
2051 path.display()
2052 ))
2053 })?
2054 }
2055 None => json!({
2056 "schema": super::env_manifest::ENV_MANIFEST_SCHEMA_V1,
2057 "environment": { "id": env_id.as_str() },
2058 }),
2059 };
2060
2061 let mut compat = CompatRequirements::default();
2063 if let Some(min_rt) = args.min_runtime {
2064 compat.min_runtime = Some(min_rt);
2065 }
2066
2067 let plan = UpdatePlan {
2068 schema: UPDATE_PLAN_SCHEMA_V1.to_string(),
2069 plan_id: ulid::Ulid::new().to_string(),
2070 env_id: env_id.to_string(),
2071 sequence,
2072 created_at: Utc::now(),
2073 nonce: ulid::Ulid::new().to_string(),
2074 target,
2075 artifacts: vec![],
2076 binaries,
2077 compat,
2078 rollback: RollbackPolicy {
2079 policy: RollbackKind::Auto,
2080 health_timeout_s: 120,
2081 on_fail: OnFail::Restore,
2082 },
2083 };
2084
2085 let built = greentic_update::plan::build_update_plan(&plan, &priv_pem, &key_id, &trust)
2086 .map_err(|e| {
2087 OpError::Conflict(format!(
2088 "build + sign update plan failed (is the signing key trusted by the env \
2089 trust root?): {e}"
2090 ))
2091 })?;
2092
2093 let out_dir = args.out_dir.unwrap_or_else(|| PathBuf::from("."));
2095 std::fs::create_dir_all(&out_dir).map_err(|source| OpError::Io {
2096 path: out_dir.clone(),
2097 source,
2098 })?;
2099 let plan_path = out_dir.join("plan.json");
2100 let sig_path = out_dir.join("plan.json.sig");
2101 std::fs::write(&plan_path, &built.plan_bytes).map_err(|source| OpError::Io {
2102 path: plan_path.clone(),
2103 source,
2104 })?;
2105 std::fs::write(&sig_path, &built.envelope_bytes).map_err(|source| OpError::Io {
2106 path: sig_path.clone(),
2107 source,
2108 })?;
2109
2110 Ok(OpOutcome::new(
2111 NOUN,
2112 "plan-build",
2113 json!({
2114 "environment_id": env_id.as_str(),
2115 "plan_id": plan.plan_id,
2116 "sequence": plan.sequence,
2117 "plan_sha256": built.plan_sha256,
2118 "key_id": built.key_id,
2119 "plan_path": plan_path.display().to_string(),
2120 "sig_path": sig_path.display().to_string(),
2121 }),
2122 ))
2123}
2124
2125fn plan_build_schema() -> Value {
2126 json!({
2127 "$schema": "https://json-schema.org/draft/2020-12/schema",
2128 "title": "UpdatesPlanBuildArgs",
2129 "type": "object",
2130 "required": ["env_id", "sequence"],
2131 "additionalProperties": false,
2132 "anyOf": [
2133 {"required": ["binaries"]},
2134 {"required": ["target_file"]}
2135 ],
2136 "properties": {
2137 "env_id": {"type": "string"},
2138 "sequence": {"type": "integer", "description": "Monotonic plan sequence (anti-rollback)."},
2139 "binaries": {"type": "array", "items": {"type": "string"}, "description": "Binary artifact specs (comma-separated key=value). Required unless target_file is set."},
2140 "signing_key": {"type": ["string", "null"], "description": "PKCS#8 Ed25519 private key PEM path. Default: global operator key."},
2141 "target_file": {"type": ["string", "null"], "description": "JSON file for the plan target (env-manifest.v1). Required unless binaries is set. Default: minimal manifest with schema + env id."},
2142 "min_runtime": {"type": ["string", "null"], "description": "Minimum runtime version (semver) for compat.min_runtime."},
2143 "out_dir": {"type": ["string", "null"], "description": "Output directory for plan.json + plan.json.sig. Default: current dir."}
2144 }
2145 })
2146}
2147
2148fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
2149 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
2150}
2151
2152fn resolve_payload<T: serde::de::DeserializeOwned>(
2153 flags: &OpFlags,
2154 payload: Option<T>,
2155) -> Result<T, OpError> {
2156 if let Some(p) = payload {
2157 return Ok(p);
2158 }
2159 if let Some(path) = &flags.answers {
2160 return super::load_answers::<T>(path);
2161 }
2162 Err(OpError::InvalidArgument(
2163 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
2164 ))
2165}
2166
2167fn enroll_schema() -> Value {
2168 json!({
2169 "$schema": "https://json-schema.org/draft/2020-12/schema",
2170 "title": "UpdatesEnrollPayload",
2171 "type": "object",
2172 "required": ["environment_id", "ca_url"],
2173 "additionalProperties": false,
2174 "properties": {
2175 "environment_id": {"type": "string"},
2176 "ca_url": {"type": "string", "description": "Base URL of the Cert-CA (greentic-updates-server); `/v1/enroll` is appended."}
2177 }
2178 })
2179}
2180
2181fn status_schema() -> Value {
2182 json!({
2183 "$schema": "https://json-schema.org/draft/2020-12/schema",
2184 "title": "UpdatesStatusPayload",
2185 "type": "object",
2186 "required": ["environment_id"],
2187 "additionalProperties": false,
2188 "properties": {
2189 "environment_id": {"type": "string"}
2190 }
2191 })
2192}
2193
2194fn get_schema() -> Value {
2195 json!({
2196 "$schema": "https://json-schema.org/draft/2020-12/schema",
2197 "title": "UpdatesGetPayload",
2198 "type": "object",
2199 "required": ["environment_id"],
2200 "additionalProperties": false,
2201 "properties": {
2202 "environment_id": {"type": "string"},
2203 "plan_url": {"type": "string", "description": "Fetch the signed plan (+ `.sig` sidecar) from this URL over the enrolled mTLS channel."},
2204 "plan_file": {"type": "string", "description": "Local plan document (airgap import / testing); requires plan_sig_file."},
2205 "plan_sig_file": {"type": "string", "description": "DSSE envelope sidecar for plan_file."}
2206 }
2207 })
2208}
2209
2210fn apply_updates_schema() -> Value {
2211 json!({
2212 "$schema": "https://json-schema.org/draft/2020-12/schema",
2213 "title": "ApplyUpdatesPayload",
2214 "type": "object",
2215 "required": ["environment_id", "plan_id"],
2216 "additionalProperties": false,
2217 "properties": {
2218 "environment_id": {"type": "string"},
2219 "plan_id": {"type": "string", "description": "Plan id of the staged plan to apply (from `op updates get`)."}
2220 }
2221 })
2222}
2223
2224fn recover_schema() -> Value {
2225 json!({
2226 "$schema": "https://json-schema.org/draft/2020-12/schema",
2227 "title": "RecoverUpdatesPayload",
2228 "type": "object",
2229 "required": ["environment_id", "plan_id"],
2230 "additionalProperties": false,
2231 "properties": {
2232 "environment_id": {"type": "string"},
2233 "plan_id": {"type": "string", "description": "Plan id of the `applying` plan to force-fail (from `op updates get`). Pass `--force` on the CLI to attest the applier is dead — recover refuses without it."}
2234 }
2235 })
2236}
2237
2238fn config_set_schema() -> Value {
2239 json!({
2240 "$schema": "https://json-schema.org/draft/2020-12/schema",
2241 "title": "UpdateConfigSetPayload",
2242 "type": "object",
2243 "required": ["environment_id"],
2244 "additionalProperties": false,
2245 "properties": {
2246 "environment_id": {"type": "string"},
2247 "enabled": {"type": ["boolean", "null"], "description": "master switch for the update-channel notification machinery; null leaves the stored value unchanged (absent = disabled, deny-by-default)"},
2248 "on_notify": {"type": ["string", "null"], "enum": [null, "record-only", "record_only", "stage"], "description": "action on a verified notification; null leaves the stored value unchanged (unset resolves to `stage`; full self-update is not offered)"},
2249 "poll_interval_secs": {"type": ["integer", "null"], "minimum": MIN_POLL_INTERVAL_SECS, "description": "fallback poll interval in seconds; null leaves the stored value unchanged (unset resolves to 3600)"}
2250 }
2251 })
2252}
2253
2254fn config_show_schema() -> Value {
2255 json!({
2256 "$schema": "https://json-schema.org/draft/2020-12/schema",
2257 "title": "UpdateConfigShowFilter",
2258 "type": "object",
2259 "required": ["environment_id"],
2260 "additionalProperties": false,
2261 "properties": {
2262 "environment_id": {"type": "string"}
2263 }
2264 })
2265}
2266
2267#[cfg(test)]
2273thread_local! {
2274 static PRE_APPLIED_TRANSITION_HOOK: std::cell::RefCell<Option<Box<dyn Fn()>>> =
2275 const { std::cell::RefCell::new(None) };
2276}
2277
2278#[cfg(test)]
2279fn run_pre_applied_transition_hook() {
2280 PRE_APPLIED_TRANSITION_HOOK.with(|h| {
2281 if let Some(f) = h.borrow().as_ref() {
2282 f();
2283 }
2284 });
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289 use super::*;
2290 use crate::cli::secrets::{DEV_STORE_KIND_PATH, get_env_secret, put_env_secret};
2291 use crate::cli::tests_common::{make_binding, make_env};
2292 use greentic_deploy_spec::CapabilitySlot;
2293 use tempfile::tempdir;
2294
2295 fn store_with_env(dir: &std::path::Path, env_id: &str) -> (LocalFsStore, EnvId) {
2298 let store = LocalFsStore::new(dir);
2299 store.save(&make_env(env_id)).unwrap();
2300 (store, EnvId::try_from(env_id).unwrap())
2301 }
2302
2303 #[test]
2304 fn config_show_defaults_to_disabled() {
2305 let dir = tempdir().unwrap();
2306 let (store, env_id) = store_with_env(dir.path(), "local");
2307 let out = config_show(
2308 &store,
2309 &OpFlags::default(),
2310 Some(UpdateConfigShowFilter {
2311 environment_id: "local".into(),
2312 }),
2313 )
2314 .unwrap();
2315 let resolved = &out.result["resolved"];
2316 assert_eq!(resolved["enabled"].as_bool(), Some(false));
2317 assert_eq!(resolved["on_notify"].as_str(), Some("stage"));
2318 assert_eq!(resolved["poll_interval_secs"].as_u64(), Some(3600));
2319 assert!(store.load_update_channel(&env_id).unwrap().is_none());
2321 }
2322
2323 #[test]
2324 fn config_set_persists_and_round_trips() {
2325 let dir = tempdir().unwrap();
2326 let (store, env_id) = store_with_env(dir.path(), "local");
2327 config_set(
2328 &store,
2329 &OpFlags::default(),
2330 Some(UpdateConfigSetPayload {
2331 environment_id: "local".into(),
2332 enabled: Some(true),
2333 on_notify: Some("record-only".into()),
2334 poll_interval_secs: Some(120),
2335 }),
2336 )
2337 .unwrap();
2338 let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2339 assert_eq!(cfg.enabled, Some(true));
2340 assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2341 assert_eq!(cfg.poll_interval_secs, Some(120));
2342 assert!(cfg.resolved_enabled());
2343 }
2344
2345 #[test]
2346 fn config_set_partial_update_preserves_other_fields() {
2347 let dir = tempdir().unwrap();
2348 let (store, env_id) = store_with_env(dir.path(), "local");
2349 let set = |p: UpdateConfigSetPayload| {
2350 config_set(&store, &OpFlags::default(), Some(p)).unwrap();
2351 };
2352 set(UpdateConfigSetPayload {
2353 environment_id: "local".into(),
2354 enabled: Some(true),
2355 on_notify: None,
2356 poll_interval_secs: None,
2357 });
2358 set(UpdateConfigSetPayload {
2359 environment_id: "local".into(),
2360 enabled: None,
2361 on_notify: Some("record-only".into()),
2362 poll_interval_secs: None,
2363 });
2364 let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2365 assert_eq!(cfg.enabled, Some(true)); assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2367 }
2368
2369 #[test]
2370 fn config_set_rejects_invalid_on_notify() {
2371 let dir = tempdir().unwrap();
2372 let (store, env_id) = store_with_env(dir.path(), "local");
2373 let err = config_set(
2374 &store,
2375 &OpFlags::default(),
2376 Some(UpdateConfigSetPayload {
2377 environment_id: "local".into(),
2378 enabled: None,
2379 on_notify: Some("apply".into()),
2380 poll_interval_secs: None,
2381 }),
2382 )
2383 .unwrap_err();
2384 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
2385 assert!(store.load_update_channel(&env_id).unwrap().is_none());
2387 }
2388
2389 #[test]
2390 fn config_set_rejects_poll_interval_below_floor() {
2391 let dir = tempdir().unwrap();
2392 let (store, _) = store_with_env(dir.path(), "local");
2393 let err = config_set(
2394 &store,
2395 &OpFlags::default(),
2396 Some(UpdateConfigSetPayload {
2397 environment_id: "local".into(),
2398 enabled: None,
2399 on_notify: None,
2400 poll_interval_secs: Some(10),
2401 }),
2402 )
2403 .unwrap_err();
2404 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
2405 }
2406
2407 #[test]
2408 fn config_set_unknown_env_is_not_found() {
2409 let dir = tempdir().unwrap();
2410 let store = LocalFsStore::new(dir.path()); let err = config_set(
2412 &store,
2413 &OpFlags::default(),
2414 Some(UpdateConfigSetPayload {
2415 environment_id: "ghost".into(),
2416 enabled: Some(true),
2417 on_notify: None,
2418 poll_interval_secs: None,
2419 }),
2420 )
2421 .unwrap_err();
2422 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
2423 }
2424
2425 #[test]
2426 fn config_schema_only_returns_schemas() {
2427 let flags = OpFlags {
2428 schema_only: true,
2429 ..OpFlags::default()
2430 };
2431 let dir = tempdir().unwrap();
2432 let store = LocalFsStore::new(dir.path());
2433 let s = config_set(&store, &flags, None).unwrap();
2434 assert_eq!(s.op, "config-set");
2435 assert!(s.result["properties"]["enabled"].is_object());
2436 let sh = config_show(&store, &flags, None).unwrap();
2437 assert_eq!(sh.op, "config-show");
2438 }
2439
2440 #[test]
2441 fn config_set_concurrent_disjoint_updates_both_survive() {
2442 let dir = tempdir().unwrap();
2443 let (store, env_id) = store_with_env(dir.path(), "local");
2444 std::thread::scope(|s| {
2448 let a = store.clone();
2449 s.spawn(move || {
2450 config_set(
2451 &a,
2452 &OpFlags::default(),
2453 Some(UpdateConfigSetPayload {
2454 environment_id: "local".into(),
2455 enabled: Some(true),
2456 on_notify: None,
2457 poll_interval_secs: None,
2458 }),
2459 )
2460 .unwrap();
2461 });
2462 let b = store.clone();
2463 s.spawn(move || {
2464 config_set(
2465 &b,
2466 &OpFlags::default(),
2467 Some(UpdateConfigSetPayload {
2468 environment_id: "local".into(),
2469 enabled: None,
2470 on_notify: Some("record-only".into()),
2471 poll_interval_secs: None,
2472 }),
2473 )
2474 .unwrap();
2475 });
2476 });
2477 let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2478 assert_eq!(cfg.enabled, Some(true));
2479 assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2480 }
2481
2482 #[test]
2483 fn config_set_rejects_corrupt_environment() {
2484 let dir = tempdir().unwrap();
2488 let store = LocalFsStore::new(dir.path());
2489 let env_id = EnvId::try_from("local").unwrap();
2490 let env_dir = dir.path().join("local");
2491 std::fs::create_dir_all(&env_dir).unwrap();
2492 std::fs::write(
2493 env_dir.join("environment.json"),
2494 b"{ not-valid environment ]",
2495 )
2496 .unwrap();
2497 assert!(store.exists(&env_id).unwrap());
2499 config_set(
2502 &store,
2503 &OpFlags::default(),
2504 Some(UpdateConfigSetPayload {
2505 environment_id: "local".into(),
2506 enabled: Some(true),
2507 on_notify: None,
2508 poll_interval_secs: None,
2509 }),
2510 )
2511 .unwrap_err();
2512 assert!(
2513 !env_dir.join("update-channel.json").exists(),
2514 "sidecar must not be written for a corrupt env"
2515 );
2516 }
2517
2518 const TEST_CERT_PEM: &str = r"-----BEGIN CERTIFICATE-----
2521MIIDITCCAgmgAwIBAgIUYapGXgtZrRNo/AWjUTX7ECfZenIwDQYJKoZIhvcNAQEL
2522BQAwIDEeMBwGA1UEAwwVZ3JlZW50aWMtdXBkYXRlci10ZXN0MB4XDTI2MDcwMjA4
2523MjkzNVoXDTM2MDYyOTA4MjkzNVowIDEeMBwGA1UEAwwVZ3JlZW50aWMtdXBkYXRl
2524ci10ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtIvlVwfBZr7V
2525GuUjcIgn4Uk+ONcdK2yraA3jhVulpYBepqhsN3bLE/XRPEOWeWdXcpfW/RQSx+sC
2526VFx2HWa0Ogh9pu75TnIxXlNPD/puEpWxJ9JcuLbujeAX1iGecKFUgfdKVFs3vAGG
2527MjN4ntvPt884TeoRlWoFdqY7xzHpWjnV4H/VLGGPo+7QaZKBLk7dCWfkGUTLFQSQ
2528p5utU4xLFdwB7dadhv6ZVp3aOAmfkYu3UuY7/YIYoYGZ6E2dg57UEv9sjbhdLBeO
2529wUpG7zisBhVcYwA9MwK65VzrCD32HCFX99XMf5Gd5VW03j2qHLyQuh4dQqKw2yCG
2530R2143vo4iQIDAQABo1MwUTAdBgNVHQ4EFgQUYIT+qBjsmFV4LvkTOd4NaXxNoGIw
2531HwYDVR0jBBgwFoAUYIT+qBjsmFV4LvkTOd4NaXxNoGIwDwYDVR0TAQH/BAUwAwEB
2532/zANBgkqhkiG9w0BAQsFAAOCAQEABHXHVVGIsmYL0LaQPvRafHqsjVCh8kiLh62b
2533qrCeqSAeXQ7YgQVmmLGV/ZzL+nbC3SoLtT0HrYcOLHsuDLbl534w6M8U7ysliZdf
2534tRtAPghtrI0zcQyXVaq1fPFB0zc/ALB8oq6I7oAwHBs+9n76nfcVRKifsrYqJm6E
25358XeewuLxi7lCULA/FfWteIE4kbx3HqzAG98eGbVebOApyMEAnf111PwjW0VTW4QB
2536L/P4PeKwohc0l4sRjlkvy+o9gnnvgjsTcMPGx1UXFXM/d8AoY1WC20cofmn0RlEd
2537uVbcKfZbU024RZ5zYGS0n3L4l6TVqpqQzrDfXjZNzyq0r/TK8g==
2538-----END CERTIFICATE-----
2539";
2540
2541 fn dev_store_env_with_tenant() -> greentic_deploy_spec::Environment {
2542 let mut env = make_env("local");
2543 env.packs.push(make_binding(
2544 CapabilitySlot::Secrets,
2545 "greentic.secrets.dev-store@1.0.0",
2546 ));
2547 env.host_config.tenant_org_id = Some("acme".to_string());
2548 env
2549 }
2550
2551 #[test]
2554 fn materialize_uri_only_bundle_gets_local_path_and_keeps_uri() {
2555 let target = json!({
2560 "bundles": [{
2561 "bundle_id": "b1",
2562 "bundle_source_uri": "oci://registry/example:1",
2563 "bundle_digest": "sha256:aaa",
2564 }],
2565 });
2566 let mut staged = BTreeMap::new();
2567 staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2568
2569 let out = materialize_bundles(&target, &staged);
2570 assert_eq!(out["bundles"][0]["bundle_path"], json!("/staged/aaa/blob"));
2571 assert_eq!(
2572 out["bundles"][0]["bundle_source_uri"],
2573 json!("oci://registry/example:1"),
2574 "the boot-time pull ref must survive materialization"
2575 );
2576 }
2577
2578 #[test]
2579 fn materialize_single_revision_with_path_and_uri_overwrites_path_keeps_uri() {
2580 let target = json!({
2584 "bundles": [{
2585 "bundle_id": "b1",
2586 "bundle_path": "orig.gtbundle",
2587 "bundle_source_uri": "oci://registry/example:1",
2588 "bundle_digest": "sha256:aaa",
2589 }],
2590 });
2591 let mut staged = BTreeMap::new();
2592 staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2593
2594 let out = materialize_bundles(&target, &staged);
2595 assert_eq!(out["bundles"][0]["bundle_path"], json!("/staged/aaa/blob"));
2596 assert_eq!(
2597 out["bundles"][0]["bundle_source_uri"],
2598 json!("oci://registry/example:1"),
2599 "the boot-time pull ref must survive materialization"
2600 );
2601 }
2602
2603 #[test]
2604 fn materialize_leaves_unmatched_digest_untouched() {
2605 let target = json!({
2608 "bundles": [{
2609 "bundle_id": "b1",
2610 "bundle_path": "orig.gtbundle",
2611 "bundle_digest": "sha256:zzz",
2612 }],
2613 });
2614 let mut staged = BTreeMap::new();
2615 staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2616
2617 let out = materialize_bundles(&target, &staged);
2618 assert_eq!(out["bundles"][0]["bundle_path"], json!("orig.gtbundle"));
2619 }
2620
2621 #[test]
2622 fn materialize_rewrites_each_revision_and_leaves_bundle_level_alone() {
2623 let target = json!({
2624 "bundles": [{
2625 "bundle_id": "b1",
2626 "revisions": [
2627 { "name": "blue", "bundle_path": "blue.gtbundle", "bundle_digest": "sha256:aaa" },
2628 { "name": "green", "bundle_path": "green.gtbundle", "bundle_digest": "sha256:bbb",
2629 "bundle_source_uri": "oci://registry/green:1" },
2630 ],
2631 }],
2632 });
2633 let mut staged = BTreeMap::new();
2634 staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2635 staged.insert("sha256:bbb".to_string(), PathBuf::from("/staged/bbb/blob"));
2636
2637 let out = materialize_bundles(&target, &staged);
2638 let revs = &out["bundles"][0]["revisions"];
2639 assert_eq!(revs[0]["bundle_path"], json!("/staged/aaa/blob"));
2640 assert_eq!(revs[1]["bundle_path"], json!("/staged/bbb/blob"));
2641 assert_eq!(
2642 revs[1]["bundle_source_uri"],
2643 json!("oci://registry/green:1")
2644 );
2645 assert!(out["bundles"][0].get("bundle_path").is_none());
2647 }
2648
2649 #[test]
2650 fn materialize_target_without_bundles_is_a_noop() {
2651 let target = json!({ "environment": { "id": "local" } });
2652 let out = materialize_bundles(&target, &BTreeMap::new());
2653 assert_eq!(out, target);
2654 }
2655
2656 #[test]
2657 fn enroll_schema_only_returns_payload_schema() {
2658 let dir = tempdir().unwrap();
2659 let store = LocalFsStore::new(dir.path());
2660 let out = enroll(
2661 &store,
2662 &OpFlags {
2663 schema_only: true,
2664 ..OpFlags::default()
2665 },
2666 None,
2667 )
2668 .unwrap();
2669 assert_eq!(out.op, "enroll");
2670 assert_eq!(out.noun, NOUN);
2671 assert!(out.result["properties"]["ca_url"].is_object());
2672 }
2673
2674 #[test]
2675 fn status_schema_only_returns_payload_schema() {
2676 let dir = tempdir().unwrap();
2677 let store = LocalFsStore::new(dir.path());
2678 let out = status(
2679 &store,
2680 &OpFlags {
2681 schema_only: true,
2682 ..OpFlags::default()
2683 },
2684 None,
2685 )
2686 .unwrap();
2687 assert_eq!(out.op, "status");
2688 assert!(out.result["properties"]["environment_id"].is_object());
2689 }
2690
2691 #[test]
2692 fn enroll_rejects_empty_ca_url_before_network() {
2693 let dir = tempdir().unwrap();
2694 let store = LocalFsStore::new(dir.path());
2695 store.save(&dev_store_env_with_tenant()).unwrap();
2696 let err = enroll(
2697 &store,
2698 &OpFlags::default(),
2699 Some(UpdatesEnrollPayload {
2700 environment_id: "local".into(),
2701 ca_url: " ".into(),
2702 }),
2703 )
2704 .unwrap_err();
2705 assert!(matches!(err, OpError::InvalidArgument(_)));
2706 }
2707
2708 #[test]
2709 fn enroll_rejects_non_http_ca_url() {
2710 let dir = tempdir().unwrap();
2711 let store = LocalFsStore::new(dir.path());
2712 store.save(&dev_store_env_with_tenant()).unwrap();
2713 let err = enroll(
2714 &store,
2715 &OpFlags::default(),
2716 Some(UpdatesEnrollPayload {
2717 environment_id: "local".into(),
2718 ca_url: "ftp://ca.example".into(),
2719 }),
2720 )
2721 .unwrap_err();
2722 assert!(matches!(err, OpError::InvalidArgument(_)));
2723 }
2724
2725 #[test]
2726 fn control_url_is_acceptable_requires_https_or_loopback_http() {
2727 assert!(control_url_is_acceptable("https://ca.example"));
2729 assert!(control_url_is_acceptable(
2730 "https://ca.example:8443/v1/enroll"
2731 ));
2732 assert!(control_url_is_acceptable("http://localhost"));
2734 assert!(control_url_is_acceptable("http://localhost:8080/enroll"));
2735 assert!(control_url_is_acceptable("http://127.0.0.1:9000"));
2736 assert!(control_url_is_acceptable("http://127.5.5.5"));
2737 assert!(control_url_is_acceptable("http://[::1]:8080"));
2738 assert!(!control_url_is_acceptable("http://ca.example"));
2740 assert!(!control_url_is_acceptable("http://ca.example:8080/enroll"));
2741 assert!(!control_url_is_acceptable("http://127.0.0.1.evil.com"));
2743 assert!(!control_url_is_acceptable("ftp://ca.example"));
2745 assert!(!control_url_is_acceptable("ca.example"));
2746 assert!(!control_url_is_acceptable("https://"));
2747 assert!(!control_url_is_acceptable(""));
2748 }
2749
2750 #[test]
2751 fn enroll_rejects_plaintext_remote_ca_url() {
2752 let dir = tempdir().unwrap();
2753 let store = LocalFsStore::new(dir.path());
2754 store.save(&dev_store_env_with_tenant()).unwrap();
2755 let err = enroll(
2756 &store,
2757 &OpFlags::default(),
2758 Some(UpdatesEnrollPayload {
2759 environment_id: "local".into(),
2760 ca_url: "http://ca.example/enroll".into(),
2761 }),
2762 )
2763 .unwrap_err();
2764 assert!(matches!(err, OpError::InvalidArgument(_)));
2765 }
2766
2767 #[test]
2768 fn enroll_requires_tenant_owner() {
2769 let dir = tempdir().unwrap();
2772 let store = LocalFsStore::new(dir.path());
2773 let mut env = make_env("local");
2774 env.packs.push(make_binding(
2775 CapabilitySlot::Secrets,
2776 "greentic.secrets.dev-store@1.0.0",
2777 ));
2778 store.save(&env).unwrap();
2779 let err = enroll(
2780 &store,
2781 &OpFlags::default(),
2782 Some(UpdatesEnrollPayload {
2783 environment_id: "local".into(),
2784 ca_url: "https://ca.example".into(),
2785 }),
2786 )
2787 .unwrap_err();
2788 assert!(matches!(err, OpError::InvalidArgument(_)));
2789 }
2790
2791 #[test]
2792 fn status_reports_not_enrolled_when_no_cert_stored() {
2793 let dir = tempdir().unwrap();
2794 let store = LocalFsStore::new(dir.path());
2795 store.save(&dev_store_env_with_tenant()).unwrap();
2796 let out = status(
2797 &store,
2798 &OpFlags::default(),
2799 Some(UpdatesStatusPayload {
2800 environment_id: "local".into(),
2801 }),
2802 )
2803 .unwrap();
2804 assert_eq!(out.result["enrolled"], false);
2805 }
2806
2807 #[test]
2808 fn status_reports_serial_and_validity_for_stored_cert() {
2809 let dir = tempdir().unwrap();
2810 let store = LocalFsStore::new(dir.path());
2811 let env = dev_store_env_with_tenant();
2812 store.save(&env).unwrap();
2813 let env_id = EnvId::try_from("local").unwrap();
2814 put_env_secret(
2816 &store,
2817 &env,
2818 &env_id,
2819 DEV_STORE_KIND_PATH,
2820 "acme/_/tls/updater_cert",
2821 TEST_CERT_PEM,
2822 )
2823 .unwrap();
2824 let out = status(
2825 &store,
2826 &OpFlags::default(),
2827 Some(UpdatesStatusPayload {
2828 environment_id: "local".into(),
2829 }),
2830 )
2831 .unwrap();
2832 assert_eq!(out.result["enrolled"], true);
2833 let info = greentic_update::tls::parse_cert_info(TEST_CERT_PEM).unwrap();
2835 assert_eq!(out.result["serial"].as_str().unwrap(), info.serial_hex);
2836 assert_eq!(
2837 out.result["not_after_epoch"].as_i64().unwrap(),
2838 info.not_after_epoch
2839 );
2840 }
2841
2842 #[test]
2843 fn status_requires_tenant_owner() {
2844 let dir = tempdir().unwrap();
2845 let store = LocalFsStore::new(dir.path());
2846 let mut env = make_env("local");
2847 env.packs.push(make_binding(
2848 CapabilitySlot::Secrets,
2849 "greentic.secrets.dev-store@1.0.0",
2850 ));
2851 store.save(&env).unwrap();
2852 let err = status(
2853 &store,
2854 &OpFlags::default(),
2855 Some(UpdatesStatusPayload {
2856 environment_id: "local".into(),
2857 }),
2858 )
2859 .unwrap_err();
2860 assert!(matches!(err, OpError::InvalidArgument(_)));
2861 }
2862
2863 #[test]
2864 fn persist_enrollment_writes_all_four_secrets_then_status_reads_them() {
2865 let dir = tempdir().unwrap();
2869 let store = LocalFsStore::new(dir.path());
2870 let env = dev_store_env_with_tenant();
2871 store.save(&env).unwrap();
2872 let env_id = EnvId::try_from("local").unwrap();
2873
2874 let enrollment = greentic_update::enroll::Enrollment {
2875 client_key_pem: "-----BEGIN PRIVATE KEY-----\nKEYMATERIAL\n-----END PRIVATE KEY-----\n"
2876 .to_string(),
2877 client_cert_pem: TEST_CERT_PEM.to_string(),
2878 ca_pem: "-----BEGIN CERTIFICATE-----\nCAMATERIAL\n-----END CERTIFICATE-----\n"
2879 .to_string(),
2880 serial: "61aa465e0b59ad1368fc05a35135fb1027d97a72".to_string(),
2881 not_after: "2036-06-29T08:29:35Z".to_string(),
2882 };
2883 let ca_url = "https://ca.example";
2884
2885 let stored = persist_enrollment(
2886 &store,
2887 &env,
2888 &env_id,
2889 DEV_STORE_KIND_PATH,
2890 "acme",
2891 ca_url,
2892 &enrollment,
2893 )
2894 .unwrap();
2895
2896 let names: Vec<&str> = stored.iter().map(|e| e["name"].as_str().unwrap()).collect();
2898 assert_eq!(names, vec![KEY_NAME, CA_NAME, CA_URL_NAME, CERT_NAME]);
2899 assert_eq!(
2900 stored[3]["store_uri"].as_str().unwrap(),
2901 "secrets://local/acme/_/tls/updater_cert"
2902 );
2903
2904 let read = |name: &str| {
2906 get_env_secret(
2907 &store,
2908 &env,
2909 &env_id,
2910 DEV_STORE_KIND_PATH,
2911 &tls_rel_path("acme", name),
2912 )
2913 .unwrap()
2914 .0
2915 };
2916 assert_eq!(
2917 read(KEY_NAME).as_deref(),
2918 Some(enrollment.client_key_pem.as_str())
2919 );
2920 assert_eq!(read(CERT_NAME).as_deref(), Some(TEST_CERT_PEM));
2921 assert_eq!(read(CA_NAME).as_deref(), Some(enrollment.ca_pem.as_str()));
2922 assert_eq!(read(CA_URL_NAME).as_deref(), Some(ca_url));
2923
2924 let out = status(
2926 &store,
2927 &OpFlags::default(),
2928 Some(UpdatesStatusPayload {
2929 environment_id: "local".into(),
2930 }),
2931 )
2932 .unwrap();
2933 assert_eq!(out.result["enrolled"], true);
2934 let info = greentic_update::tls::parse_cert_info(TEST_CERT_PEM).unwrap();
2935 assert_eq!(out.result["serial"].as_str().unwrap(), info.serial_hex);
2936 }
2937
2938 use greentic_distributor_client::signing::{TrustRoot, TrustedKey};
2941
2942 fn key_pair(seed: u8) -> (String, TrustedKey) {
2944 use ed25519_dalek::SigningKey;
2945 use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
2946 use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
2947 use greentic_distributor_client::signing::key_id_for_public_key_pem;
2948
2949 let sk = SigningKey::from_bytes(&[seed; 32]);
2950 let priv_pem = sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
2951 let pub_pem = sk
2952 .verifying_key()
2953 .to_public_key_pem(LineEnding::LF)
2954 .unwrap();
2955 let key_id = key_id_for_public_key_pem(&pub_pem).unwrap();
2956 (
2957 priv_pem,
2958 TrustedKey {
2959 key_id,
2960 public_key_pem: pub_pem,
2961 },
2962 )
2963 }
2964
2965 #[allow(clippy::too_many_arguments)]
2968 fn signed_plan(
2969 env_id: &str,
2970 plan_id: &str,
2971 sequence: u64,
2972 artifacts: Value,
2973 compat: Value,
2974 priv_pem: &str,
2975 key_id: &str,
2976 build_trust: &TrustRoot,
2977 ) -> (Vec<u8>, Vec<u8>) {
2978 let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
2979 "schema": "greentic.update-plan.v1",
2980 "plan_id": plan_id,
2981 "env_id": env_id,
2982 "sequence": sequence,
2983 "created_at": "2026-07-02T00:00:00Z",
2984 "nonce": format!("nonce-{plan_id}"),
2985 "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": env_id}},
2986 "artifacts": artifacts,
2987 "compat": compat,
2988 "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
2989 }))
2990 .unwrap();
2991 let built =
2992 greentic_update::plan::build_update_plan(&plan, priv_pem, key_id, build_trust).unwrap();
2993 (built.plan_bytes, built.envelope_bytes)
2994 }
2995
2996 fn env_trusting(store: &LocalFsStore, tk: &TrustedKey) -> EnvId {
2998 env_trusting_secrets(store, tk, None)
2999 }
3000
3001 fn env_trusting_secrets(store: &LocalFsStore, tk: &TrustedKey, kind: Option<&str>) -> EnvId {
3006 let mut env = make_env("local");
3007 if let Some(k) = kind {
3008 env.packs.push(make_binding(CapabilitySlot::Secrets, k));
3009 }
3010 store.save(&env).unwrap();
3011 let env_id = EnvId::try_from("local").unwrap();
3012 let env_dir = store.env_dir(&env_id).unwrap();
3013 store_trust_root::add_trusted_key(&env_dir, tk.clone()).unwrap();
3014 env_id
3015 }
3016
3017 #[test]
3018 fn get_schema_only_returns_payload_schema() {
3019 let dir = tempdir().unwrap();
3020 let store = LocalFsStore::new(dir.path());
3021 let out = get(
3022 &store,
3023 &OpFlags {
3024 schema_only: true,
3025 ..OpFlags::default()
3026 },
3027 None,
3028 )
3029 .unwrap();
3030 assert_eq!(out.op, "get");
3031 assert_eq!(out.noun, NOUN);
3032 assert!(out.result["properties"]["plan_url"].is_object());
3033 }
3034
3035 #[test]
3036 fn get_rejects_missing_plan_source() {
3037 let dir = tempdir().unwrap();
3038 let store = LocalFsStore::new(dir.path());
3039 store.save(&make_env("local")).unwrap();
3040 let err = get(
3041 &store,
3042 &OpFlags::default(),
3043 Some(UpdatesGetPayload {
3044 environment_id: "local".into(),
3045 plan_url: None,
3046 plan_file: None,
3047 plan_sig_file: None,
3048 }),
3049 )
3050 .unwrap_err();
3051 assert!(matches!(err, OpError::InvalidArgument(_)));
3052 }
3053
3054 #[test]
3055 fn get_rejects_plan_signed_by_untrusted_key() {
3056 let dir = tempdir().unwrap();
3058 let store = LocalFsStore::new(dir.path());
3059 let (_priv7, tk7) = key_pair(7);
3060 let env_id = env_trusting(&store, &tk7);
3061
3062 let (priv9, tk9) = key_pair(9);
3063 let build_trust = TrustRoot::new(vec![tk9.clone()]);
3064 let (plan_b, sig_b) = signed_plan(
3065 "local",
3066 "plan-x",
3067 1,
3068 json!([]),
3069 json!({}),
3070 &priv9,
3071 &tk9.key_id,
3072 &build_trust,
3073 );
3074 let plan_file = dir.path().join("plan.json");
3075 let sig_file = dir.path().join("plan.json.sig");
3076 std::fs::write(&plan_file, &plan_b).unwrap();
3077 std::fs::write(&sig_file, &sig_b).unwrap();
3078
3079 let err = get(
3080 &store,
3081 &OpFlags::default(),
3082 Some(UpdatesGetPayload {
3083 environment_id: env_id.to_string(),
3084 plan_url: None,
3085 plan_file: Some(plan_file),
3086 plan_sig_file: Some(sig_file),
3087 }),
3088 )
3089 .unwrap_err();
3090 assert!(matches!(err, OpError::Conflict(_)));
3092 }
3093
3094 #[test]
3095 fn get_rejects_plan_targeting_another_env() {
3096 let dir = tempdir().unwrap();
3097 let store = LocalFsStore::new(dir.path());
3098 let (priv7, tk7) = key_pair(7);
3099 let env_id = env_trusting(&store, &tk7);
3100
3101 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3102 let (plan_b, sig_b) = signed_plan(
3104 "other",
3105 "plan-x",
3106 1,
3107 json!([]),
3108 json!({}),
3109 &priv7,
3110 &tk7.key_id,
3111 &build_trust,
3112 );
3113 let plan_file = dir.path().join("plan.json");
3114 let sig_file = dir.path().join("plan.json.sig");
3115 std::fs::write(&plan_file, &plan_b).unwrap();
3116 std::fs::write(&sig_file, &sig_b).unwrap();
3117
3118 let err = get(
3119 &store,
3120 &OpFlags::default(),
3121 Some(UpdatesGetPayload {
3122 environment_id: env_id.to_string(),
3123 plan_url: None,
3124 plan_file: Some(plan_file),
3125 plan_sig_file: Some(sig_file),
3126 }),
3127 )
3128 .unwrap_err();
3129 assert!(matches!(err, OpError::InvalidArgument(_)));
3130 }
3131
3132 #[test]
3133 fn get_stages_zero_artifact_plan_to_staged() {
3134 let dir = tempdir().unwrap();
3135 let updates_dir = tempdir().unwrap();
3136
3137 let store = LocalFsStore::new(dir.path());
3138 let (priv7, tk7) = key_pair(7);
3139 let env_id = env_trusting(&store, &tk7);
3140
3141 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3142 let (plan_b, sig_b) = signed_plan(
3144 "local",
3145 "plan-happy",
3146 1,
3147 json!([]),
3148 json!({}),
3149 &priv7,
3150 &tk7.key_id,
3151 &build_trust,
3152 );
3153 let plan_file = dir.path().join("plan.json");
3154 let sig_file = dir.path().join("plan.json.sig");
3155 std::fs::write(&plan_file, &plan_b).unwrap();
3156 std::fs::write(&sig_file, &sig_b).unwrap();
3157
3158 let out = get_impl(
3160 &store,
3161 &OpFlags::default(),
3162 Some(UpdatesGetPayload {
3163 environment_id: env_id.to_string(),
3164 plan_url: None,
3165 plan_file: Some(plan_file),
3166 plan_sig_file: Some(sig_file),
3167 }),
3168 Some(updates_dir.path()),
3169 )
3170 .unwrap();
3171
3172 assert_eq!(out.op, "get");
3173 assert_eq!(out.result["stage"], "staged");
3174 assert_eq!(out.result["plan_id"], "plan-happy");
3175 assert_eq!(out.result["artifacts_total"], 0);
3176 assert_eq!(out.result["sequence"], 1);
3177 }
3178
3179 #[test]
3180 fn get_rejects_target_manifest_naming_another_env() {
3181 let dir = tempdir().unwrap();
3184 let store = LocalFsStore::new(dir.path());
3185 let (priv7, tk7) = key_pair(7);
3186 let env_id = env_trusting(&store, &tk7);
3187 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3188
3189 let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3190 "schema": "greentic.update-plan.v1",
3191 "plan_id": "plan-mismatch",
3192 "env_id": "local",
3193 "sequence": 1,
3194 "created_at": "2026-07-02T00:00:00Z",
3195 "nonce": "n",
3196 "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": "other"}},
3197 "artifacts": [],
3198 "compat": {},
3199 "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3200 }))
3201 .unwrap();
3202 let built =
3203 greentic_update::plan::build_update_plan(&plan, &priv7, &tk7.key_id, &build_trust)
3204 .unwrap();
3205 let plan_file = dir.path().join("plan.json");
3206 let sig_file = dir.path().join("plan.json.sig");
3207 std::fs::write(&plan_file, &built.plan_bytes).unwrap();
3208 std::fs::write(&sig_file, &built.envelope_bytes).unwrap();
3209
3210 let err = get(
3212 &store,
3213 &OpFlags::default(),
3214 Some(UpdatesGetPayload {
3215 environment_id: env_id.to_string(),
3216 plan_url: None,
3217 plan_file: Some(plan_file),
3218 plan_sig_file: Some(sig_file),
3219 }),
3220 )
3221 .unwrap_err();
3222 assert!(matches!(err, OpError::InvalidArgument(_)));
3223 }
3224
3225 #[test]
3226 fn get_rejects_plaintext_remote_plan_url() {
3227 let dir = tempdir().unwrap();
3230 let store = LocalFsStore::new(dir.path());
3231 store.save(&make_env("local")).unwrap();
3232 let err = get(
3233 &store,
3234 &OpFlags::default(),
3235 Some(UpdatesGetPayload {
3236 environment_id: "local".into(),
3237 plan_url: Some("http://updates.example/plan".into()),
3238 plan_file: None,
3239 plan_sig_file: None,
3240 }),
3241 )
3242 .unwrap_err();
3243 assert!(matches!(err, OpError::InvalidArgument(_)));
3244 }
3245
3246 #[test]
3247 fn get_is_idempotent_on_reget() {
3248 let dir = tempdir().unwrap();
3250 let updates_dir = tempdir().unwrap();
3251 let store = LocalFsStore::new(dir.path());
3252 let (priv7, tk7) = key_pair(7);
3253 let env_id = env_trusting(&store, &tk7);
3254 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3255 let (plan_b, sig_b) = signed_plan(
3256 "local",
3257 "plan-idem",
3258 1,
3259 json!([]),
3260 json!({}),
3261 &priv7,
3262 &tk7.key_id,
3263 &build_trust,
3264 );
3265 let plan_file = dir.path().join("plan.json");
3266 let sig_file = dir.path().join("plan.json.sig");
3267 std::fs::write(&plan_file, &plan_b).unwrap();
3268 std::fs::write(&sig_file, &sig_b).unwrap();
3269
3270 let payload = || UpdatesGetPayload {
3271 environment_id: env_id.to_string(),
3272 plan_url: None,
3273 plan_file: Some(plan_file.clone()),
3274 plan_sig_file: Some(sig_file.clone()),
3275 };
3276 let first = get_impl(
3277 &store,
3278 &OpFlags::default(),
3279 Some(payload()),
3280 Some(updates_dir.path()),
3281 )
3282 .unwrap();
3283 assert_eq!(first.result["stage"], "staged");
3284
3285 let second = get_impl(
3286 &store,
3287 &OpFlags::default(),
3288 Some(payload()),
3289 Some(updates_dir.path()),
3290 )
3291 .unwrap();
3292 assert_eq!(second.result["stage"], "staged");
3293 assert_eq!(second.result["plan_id"], "plan-idem");
3294 }
3295
3296 struct StubFetcher {
3301 bytes: std::collections::HashMap<String, Vec<u8>>,
3302 fail_times: std::cell::Cell<u32>,
3303 calls: std::cell::Cell<u32>,
3304 }
3305
3306 impl StubFetcher {
3307 fn serving(entries: &[(&str, &[u8])]) -> Self {
3308 Self {
3309 bytes: entries
3310 .iter()
3311 .map(|(n, b)| (n.to_string(), b.to_vec()))
3312 .collect(),
3313 fail_times: std::cell::Cell::new(0),
3314 calls: std::cell::Cell::new(0),
3315 }
3316 }
3317 }
3318
3319 impl ArtifactFetcher for StubFetcher {
3320 fn fetch(
3321 &self,
3322 artifact: &greentic_update::plan::PlanArtifact,
3323 ) -> Result<Vec<u8>, OpError> {
3324 self.calls.set(self.calls.get() + 1);
3325 let remaining = self.fail_times.get();
3326 if remaining > 0 {
3327 self.fail_times.set(remaining - 1);
3328 return Err(OpError::Fetch("transient".into()));
3329 }
3330 self.bytes
3331 .get(&artifact.name)
3332 .cloned()
3333 .ok_or_else(|| OpError::Fetch(format!("no stub bytes for `{}`", artifact.name)))
3334 }
3335 }
3336
3337 fn digest_of(bytes: &[u8]) -> String {
3338 format!("sha256:{}", greentic_update::plan::sha256_hex(bytes))
3339 }
3340
3341 fn downloading_plan(
3344 updates_dir: &std::path::Path,
3345 artifacts: Value,
3346 ) -> greentic_update::staging::StagedPlan {
3347 let (priv9, tk9) = key_pair(9);
3348 let build_trust = TrustRoot::new(vec![tk9.clone()]);
3349 let (plan_b, sig_b) = signed_plan(
3350 "local",
3351 "plan-dl",
3352 1,
3353 artifacts,
3354 json!({}),
3355 &priv9,
3356 &tk9.key_id,
3357 &build_trust,
3358 );
3359 let verify_trust = TrustRoot::new(vec![tk9]);
3360 let verified =
3361 greentic_update::plan::verify_update_plan(&plan_b, &sig_b, &verify_trust).unwrap();
3362 let root = greentic_update::staging::UpdatesRoot::open_in(updates_dir, "local").unwrap();
3363 root.begin(&verified, &plan_b, &sig_b).unwrap()
3364 }
3365
3366 fn no_delay(attempts: u32) -> RetryPolicy {
3367 RetryPolicy {
3368 attempts,
3369 base_delay: std::time::Duration::ZERO,
3370 }
3371 }
3372
3373 #[test]
3374 fn download_and_stage_fetches_all_and_promotes() {
3375 use greentic_update::staging::UpdateStage;
3376 let updates_dir = tempdir().unwrap();
3377 let (a1, a2) = (b"alpha-bytes".as_slice(), b"beta-bytes".as_slice());
3378 let staged = downloading_plan(
3379 updates_dir.path(),
3380 json!([
3381 {"name": "a1", "version": "1.0.0", "digest": digest_of(a1), "source": "file:///a1"},
3382 {"name": "a2", "version": "1.0.0", "digest": digest_of(a2), "source": "file:///a2"},
3383 ]),
3384 );
3385 let stub = StubFetcher::serving(&[("a1", a1), ("a2", a2)]);
3386 let arts = staged.plan().artifacts.to_vec();
3387
3388 let stage = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap();
3389
3390 assert_eq!(stage, UpdateStage::Staged);
3391 assert_eq!(stub.calls.get(), 2, "both artifacts fetched");
3392 assert_eq!(staged.stage().unwrap(), UpdateStage::Staged);
3394 }
3395
3396 #[test]
3397 fn download_and_stage_digest_mismatch_fails_closed() {
3398 use greentic_update::staging::UpdateStage;
3399 let updates_dir = tempdir().unwrap();
3400 let staged = downloading_plan(
3402 updates_dir.path(),
3403 json!([
3404 {"name": "a1", "version": "1.0.0", "digest": digest_of(b"correct"), "source": "file:///a1"},
3405 ]),
3406 );
3407 let stub = StubFetcher::serving(&[("a1", b"wrong")]);
3408 let arts = staged.plan().artifacts.to_vec();
3409
3410 let err = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap_err();
3411
3412 assert!(matches!(err, OpError::Conflict(m) if m.contains("digest mismatch")));
3413 assert_eq!(staged.stage().unwrap(), UpdateStage::Downloading);
3415 }
3416
3417 #[test]
3418 fn download_and_stage_resumes_without_refetching() {
3419 use greentic_update::staging::UpdateStage;
3420 let updates_dir = tempdir().unwrap();
3421 let staged = downloading_plan(
3422 updates_dir.path(),
3423 json!([{"name": "a1", "version": "1.0.0", "digest": digest_of(b"x"), "source": "file:///a1"}]),
3424 );
3425 staged.transition(UpdateStage::Inbox).unwrap();
3427 staged.transition(UpdateStage::Staged).unwrap();
3428
3429 let stub = StubFetcher::serving(&[("a1", b"x")]);
3430 let arts = staged.plan().artifacts.to_vec();
3431 let stage = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap();
3432
3433 assert_eq!(stage, UpdateStage::Staged);
3434 assert_eq!(stub.calls.get(), 0, "already-staged plan must not re-fetch");
3435 }
3436
3437 #[test]
3438 fn fetch_with_retry_retries_transient_then_succeeds() {
3439 let stub = StubFetcher::serving(&[("a1", b"ok")]);
3440 stub.fail_times.set(2); let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3442 json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"ok"), "source": "file:///a1"}),
3443 )
3444 .unwrap();
3445
3446 let bytes = fetch_with_retry(&stub, &artifact, no_delay(3)).unwrap();
3447
3448 assert_eq!(bytes, b"ok");
3449 assert_eq!(stub.calls.get(), 3);
3450 }
3451
3452 #[test]
3453 fn fetch_with_retry_exhausts_attempts_and_returns_last_error() {
3454 let stub = StubFetcher::serving(&[("a1", b"ok")]);
3455 stub.fail_times.set(99); let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3457 json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"ok"), "source": "file:///a1"}),
3458 )
3459 .unwrap();
3460
3461 let err = fetch_with_retry(&stub, &artifact, no_delay(2)).unwrap_err();
3462
3463 assert!(matches!(err, OpError::Fetch(_)));
3464 assert_eq!(stub.calls.get(), 2, "exactly `attempts` tries");
3465 }
3466
3467 #[test]
3468 fn dist_fetcher_rejects_artifact_without_source() {
3469 let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3472 json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"x")}),
3473 )
3474 .unwrap();
3475 let err = DistArtifactFetcher::new().fetch(&artifact).unwrap_err();
3476 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("no source")));
3477 }
3478
3479 #[test]
3480 fn dist_fetcher_rejects_disallowed_scheme() {
3481 for src in ["file:///etc/passwd", "/etc/passwd", "repo://x", "store://y"] {
3484 let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3485 json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"x"), "source": src}),
3486 )
3487 .unwrap();
3488 let err = DistArtifactFetcher::new().fetch(&artifact).unwrap_err();
3489 assert!(
3490 matches!(err, OpError::InvalidArgument(m) if m.contains("allowed remote scheme")),
3491 "source `{src}` should be rejected by scheme"
3492 );
3493 }
3494 }
3495
3496 #[test]
3497 fn reject_oversize_caps_large_artifacts() {
3498 let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3499 json!({"name": "big", "version": "1.0.0", "digest": digest_of(b"x")}),
3500 )
3501 .unwrap();
3502 assert!(reject_oversize(&artifact, MAX_ARTIFACT_BYTES).is_ok());
3503 assert!(matches!(
3504 reject_oversize(&artifact, MAX_ARTIFACT_BYTES + 1),
3505 Err(OpError::Fetch(_))
3506 ));
3507 }
3508
3509 fn verify_with(
3512 plan_b: &[u8],
3513 sig_b: &[u8],
3514 tk: &TrustedKey,
3515 ) -> greentic_update::plan::VerifiedUpdatePlan {
3516 greentic_update::plan::verify_update_plan(plan_b, sig_b, &TrustRoot::new(vec![tk.clone()]))
3517 .unwrap()
3518 }
3519
3520 fn signed_local(
3522 plan_id: &str,
3523 sequence: u64,
3524 priv_pem: &str,
3525 tk: &TrustedKey,
3526 ) -> (Vec<u8>, Vec<u8>, greentic_update::plan::VerifiedUpdatePlan) {
3527 let build_trust = TrustRoot::new(vec![tk.clone()]);
3528 let (p, s) = signed_plan(
3529 "local",
3530 plan_id,
3531 sequence,
3532 json!([]),
3533 json!({}),
3534 priv_pem,
3535 &tk.key_id,
3536 &build_trust,
3537 );
3538 let v = verify_with(&p, &s, tk);
3539 (p, s, v)
3540 }
3541
3542 #[test]
3543 fn admit_or_resume_regates_stranded_downgrade() {
3544 use greentic_update::staging::UpdateStage;
3545 let updates_dir = tempdir().unwrap();
3546 let (priv9, tk9) = key_pair(9);
3547 let root =
3548 greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3549
3550 let (pa, sa, va) = signed_local("applied", 6, &priv9, &tk9);
3552 let applied = root.begin(&va, &pa, &sa).unwrap();
3553 applied.transition(UpdateStage::Inbox).unwrap();
3554 applied.transition(UpdateStage::Staged).unwrap();
3555 applied.transition(UpdateStage::Applying).unwrap();
3556 applied.transition(UpdateStage::Applied).unwrap();
3557
3558 let (ps, ss, vs) = signed_local("stale", 5, &priv9, &tk9);
3560 root.begin(&vs, &ps, &ss).unwrap();
3561 assert_eq!(
3562 root.load("stale").unwrap().unwrap().stage().unwrap(),
3563 UpdateStage::Downloading
3564 );
3565
3566 let err = admit_or_resume(&root, &vs, &ps, &ss).unwrap_err();
3568 assert!(matches!(err, OpError::Conflict(m) if m.contains("rejected")));
3569 }
3570
3571 #[test]
3572 fn admit_or_resume_refuses_terminal_plan() {
3573 use greentic_update::staging::UpdateStage;
3574 let updates_dir = tempdir().unwrap();
3575 let (priv9, tk9) = key_pair(9);
3576 let root =
3577 greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3578
3579 let (p, s, v) = signed_local("term", 1, &priv9, &tk9);
3580 let staged = root.begin(&v, &p, &s).unwrap();
3581 staged.transition(UpdateStage::Rejected).unwrap();
3582
3583 let err = admit_or_resume(&root, &v, &p, &s).unwrap_err();
3584 assert!(matches!(err, OpError::Conflict(m) if m.contains("not resuming")));
3585 }
3586
3587 #[test]
3588 fn admit_or_resume_returns_promoted_plan_as_is() {
3589 use greentic_update::staging::UpdateStage;
3590 let updates_dir = tempdir().unwrap();
3591 let (priv9, tk9) = key_pair(9);
3592 let root =
3593 greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3594
3595 let (p, s, v) = signed_local("done", 1, &priv9, &tk9);
3598 let staged = root.begin(&v, &p, &s).unwrap();
3599 staged.transition(UpdateStage::Inbox).unwrap();
3600 staged.transition(UpdateStage::Staged).unwrap();
3601
3602 let resumed = admit_or_resume(&root, &v, &p, &s).unwrap();
3603 assert_eq!(resumed.stage().unwrap(), UpdateStage::Staged);
3604 }
3605
3606 #[allow(clippy::too_many_arguments)]
3611 fn signed_plan_target(
3612 env_id: &str,
3613 plan_id: &str,
3614 sequence: u64,
3615 target: Value,
3616 priv_pem: &str,
3617 key_id: &str,
3618 build_trust: &TrustRoot,
3619 ) -> (Vec<u8>, Vec<u8>) {
3620 let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3621 "schema": "greentic.update-plan.v1",
3622 "plan_id": plan_id,
3623 "env_id": env_id,
3624 "sequence": sequence,
3625 "created_at": "2026-07-02T00:00:00Z",
3626 "nonce": format!("nonce-{plan_id}"),
3627 "target": target,
3628 "artifacts": [],
3629 "compat": {},
3630 "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3631 }))
3632 .unwrap();
3633 let built =
3634 greentic_update::plan::build_update_plan(&plan, priv_pem, key_id, build_trust).unwrap();
3635 (built.plan_bytes, built.envelope_bytes)
3636 }
3637
3638 fn stage_local(
3642 updates_root: &std::path::Path,
3643 plan_id: &str,
3644 sequence: u64,
3645 priv_pem: &str,
3646 tk: &TrustedKey,
3647 ) {
3648 let (p, s, v) = signed_local(plan_id, sequence, priv_pem, tk);
3649 let root = greentic_update::staging::UpdatesRoot::open_in(updates_root, "local").unwrap();
3650 let staged = root.begin(&v, &p, &s).unwrap();
3651 advance_to_staged(&staged).unwrap();
3652 }
3653
3654 fn on_disk_stage(
3656 updates_root: &std::path::Path,
3657 plan_id: &str,
3658 ) -> greentic_update::staging::UpdateStage {
3659 greentic_update::staging::UpdatesRoot::open_in(updates_root, "local")
3660 .unwrap()
3661 .load(plan_id)
3662 .unwrap()
3663 .unwrap()
3664 .stage()
3665 .unwrap()
3666 }
3667
3668 #[test]
3669 fn apply_schema_only_returns_payload_schema() {
3670 let dir = tempdir().unwrap();
3671 let store = LocalFsStore::new(dir.path());
3672 let out = apply_updates(
3673 &store,
3674 &OpFlags {
3675 schema_only: true,
3676 ..OpFlags::default()
3677 },
3678 None,
3679 )
3680 .unwrap();
3681 assert_eq!(out.op, "apply");
3682 assert_eq!(out.noun, NOUN);
3683 assert!(out.result["properties"]["plan_id"].is_object());
3684 }
3685
3686 #[test]
3687 fn apply_plan_not_found_is_not_found() {
3688 let dir = tempdir().unwrap();
3689 let updates_dir = tempdir().unwrap();
3690 let store = LocalFsStore::new(dir.path());
3691 let (_priv7, tk7) = key_pair(7);
3692 env_trusting(&store, &tk7);
3693 let err = apply_updates_impl(
3694 &store,
3695 &OpFlags::default(),
3696 Some(ApplyUpdatesPayload {
3697 environment_id: "local".into(),
3698 plan_id: "ghost".into(),
3699 }),
3700 Some(updates_dir.path()),
3701 )
3702 .unwrap_err();
3703 assert!(matches!(err, OpError::NotFound(_)));
3704 }
3705
3706 #[test]
3707 fn apply_happy_path_zero_artifact_converges_and_marks_applied() {
3708 use greentic_update::staging::UpdateStage;
3709 let dir = tempdir().unwrap();
3710 let updates_dir = tempdir().unwrap();
3711 let store = LocalFsStore::new(dir.path());
3712 let (priv7, tk7) = key_pair(7);
3713 let env_id = env_trusting(&store, &tk7);
3714 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3715
3716 let out = apply_updates_impl(
3717 &store,
3718 &OpFlags::default(),
3719 Some(ApplyUpdatesPayload {
3720 environment_id: "local".into(),
3721 plan_id: "plan-1".into(),
3722 }),
3723 Some(updates_dir.path()),
3724 )
3725 .unwrap();
3726
3727 assert_eq!(out.op, "apply");
3728 assert_eq!(out.result["stage"], "applied");
3729 assert_eq!(out.result["plan_id"], "plan-1");
3730 assert!(out.result["snapshot_id"].as_str().is_some());
3731
3732 assert_eq!(
3734 on_disk_stage(updates_dir.path(), "plan-1"),
3735 UpdateStage::Applied
3736 );
3737 let env_dir = store.env_dir(&env_id).unwrap();
3740 assert!(env_dir.join("snapshots").is_dir(), "snapshot must exist");
3741 let audit = std::fs::read_to_string(env_dir.join("audit").join("events.jsonl")).unwrap();
3742 assert!(
3743 audit.contains("plan-1"),
3744 "audit must record the apply: {audit}"
3745 );
3746 }
3747
3748 #[test]
3749 fn apply_annotates_binaries_it_does_not_install() {
3750 use greentic_update::staging::{UpdateStage, UpdatesRoot};
3751 let dir = tempdir().unwrap();
3752 let updates_dir = tempdir().unwrap();
3753 let store = LocalFsStore::new(dir.path());
3754 let (priv7, tk7) = key_pair(7);
3755 env_trusting(&store, &tk7);
3756
3757 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3762 let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3763 "schema": "greentic.update-plan.v1",
3764 "plan_id": "plan-bin",
3765 "env_id": "local",
3766 "sequence": 1,
3767 "created_at": "2026-07-02T00:00:00Z",
3768 "nonce": "nonce-plan-bin",
3769 "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": "local"}},
3770 "artifacts": [],
3771 "binaries": [{
3772 "name": "greentic-start",
3773 "version": "1.1.9",
3774 "target": "x86_64-unknown-linux-gnu",
3775 "digest": "sha256:abc123",
3776 "source": "https://example.test/greentic-start.tgz"
3777 }],
3778 "compat": {},
3779 "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3780 }))
3781 .unwrap();
3782 let built =
3783 greentic_update::plan::build_update_plan(&plan, &priv7, &tk7.key_id, &build_trust)
3784 .unwrap();
3785 let verified = verify_with(&built.plan_bytes, &built.envelope_bytes, &tk7);
3786 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3787 let staged = root
3788 .begin(&verified, &built.plan_bytes, &built.envelope_bytes)
3789 .unwrap();
3790 advance_to_staged(&staged).unwrap();
3791
3792 let out = apply_updates_impl(
3793 &store,
3794 &OpFlags::default(),
3795 Some(ApplyUpdatesPayload {
3796 environment_id: "local".into(),
3797 plan_id: "plan-bin".into(),
3798 }),
3799 Some(updates_dir.path()),
3800 )
3801 .unwrap();
3802
3803 assert_eq!(out.result["stage"], "applied");
3805 assert_eq!(
3806 on_disk_stage(updates_dir.path(), "plan-bin"),
3807 UpdateStage::Applied
3808 );
3809 let bins = out.result["binaries_not_applied"]
3811 .as_array()
3812 .expect("binaries_not_applied must be present when the plan carries binaries");
3813 assert_eq!(bins.len(), 1);
3814 assert_eq!(bins[0]["name"], "greentic-start");
3815 assert_eq!(bins[0]["version"], "1.1.9");
3816 assert_eq!(bins[0]["target"], "x86_64-unknown-linux-gnu");
3817 }
3818
3819 #[test]
3820 fn apply_transition_failure_returns_honest_recover_error() {
3821 use greentic_update::staging::UpdateStage;
3825 use std::os::unix::fs::PermissionsExt;
3826
3827 let dir = tempdir().unwrap();
3828 let updates_dir = tempdir().unwrap();
3829 let store = LocalFsStore::new(dir.path());
3830 let (priv7, tk7) = key_pair(7);
3831 let env_id = env_trusting(&store, &tk7);
3832 stage_local(updates_dir.path(), "plan-stuck", 1, &priv7, &tk7);
3833
3834 let plan_dir = greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3837 .unwrap()
3838 .load("plan-stuck")
3839 .unwrap()
3840 .unwrap()
3841 .dir()
3842 .to_path_buf();
3843
3844 let hook_dir = plan_dir.clone();
3849 PRE_APPLIED_TRANSITION_HOOK.with(|h| {
3850 *h.borrow_mut() = Some(Box::new(move || {
3851 std::fs::set_permissions(&hook_dir, std::fs::Permissions::from_mode(0o500))
3852 .expect("chmod plan dir read-only");
3853 }));
3854 });
3855
3856 let err = apply_updates_impl(
3857 &store,
3858 &OpFlags::default(),
3859 Some(ApplyUpdatesPayload {
3860 environment_id: "local".into(),
3861 plan_id: "plan-stuck".into(),
3862 }),
3863 Some(updates_dir.path()),
3864 )
3865 .unwrap_err();
3866
3867 PRE_APPLIED_TRANSITION_HOOK.with(|h| {
3869 *h.borrow_mut() = None;
3870 });
3871 std::fs::set_permissions(&plan_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
3873
3874 let msg = format!("{err}");
3876 assert!(
3877 msg.contains("recover"),
3878 "error must point at `op updates recover`: {msg}"
3879 );
3880 assert!(
3881 msg.contains("applied successfully"),
3882 "error must state content was applied: {msg}"
3883 );
3884
3885 assert_eq!(
3887 on_disk_stage(updates_dir.path(), "plan-stuck"),
3888 UpdateStage::Applying
3889 );
3890
3891 let env_dir = store.env_dir(&env_id).unwrap();
3893 assert!(
3894 env_dir.join("snapshots").is_dir(),
3895 "snapshot must exist (env was mutated)"
3896 );
3897 }
3898
3899 #[test]
3900 fn apply_rejects_already_applied_plan() {
3901 let dir = tempdir().unwrap();
3902 let updates_dir = tempdir().unwrap();
3903 let store = LocalFsStore::new(dir.path());
3904 let (priv7, tk7) = key_pair(7);
3905 env_trusting(&store, &tk7);
3906 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3907
3908 let payload = ApplyUpdatesPayload {
3909 environment_id: "local".into(),
3910 plan_id: "plan-1".into(),
3911 };
3912 apply_updates_impl(
3913 &store,
3914 &OpFlags::default(),
3915 Some(payload.clone()),
3916 Some(updates_dir.path()),
3917 )
3918 .unwrap();
3919 let err = apply_updates_impl(
3921 &store,
3922 &OpFlags::default(),
3923 Some(payload),
3924 Some(updates_dir.path()),
3925 )
3926 .unwrap_err();
3927 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("not `staged`")));
3928 }
3929
3930 #[test]
3931 fn apply_rejects_retryably_when_plan_already_applying() {
3932 use greentic_update::staging::UpdateStage;
3933 let dir = tempdir().unwrap();
3934 let updates_dir = tempdir().unwrap();
3935 let store = LocalFsStore::new(dir.path());
3936 let (priv7, tk7) = key_pair(7);
3937 env_trusting(&store, &tk7);
3938 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3939 greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3942 .unwrap()
3943 .load("plan-1")
3944 .unwrap()
3945 .unwrap()
3946 .transition(UpdateStage::Applying)
3947 .unwrap();
3948
3949 let err = apply_updates_impl(
3950 &store,
3951 &OpFlags::default(),
3952 Some(ApplyUpdatesPayload {
3953 environment_id: "local".into(),
3954 plan_id: "plan-1".into(),
3955 }),
3956 Some(updates_dir.path()),
3957 )
3958 .unwrap_err();
3959 assert!(matches!(err, OpError::Conflict(m) if m.contains("already `applying`")));
3963 assert_eq!(
3965 on_disk_stage(updates_dir.path(), "plan-1"),
3966 UpdateStage::Applying
3967 );
3968 }
3969
3970 #[test]
3971 fn apply_rejects_swapped_plan_via_hash_cross_check() {
3972 use greentic_update::staging::UpdateStage;
3973 let dir = tempdir().unwrap();
3974 let updates_dir = tempdir().unwrap();
3975 let store = LocalFsStore::new(dir.path());
3976 let (priv7, tk7) = key_pair(7);
3977 env_trusting(&store, &tk7);
3978 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3979
3980 let build_trust = TrustRoot::new(vec![tk7.clone()]);
3984 let (p2, s2) = signed_plan(
3985 "local",
3986 "plan-1",
3987 2,
3988 json!([]),
3989 json!({}),
3990 &priv7,
3991 &tk7.key_id,
3992 &build_trust,
3993 );
3994 let plan_dir = greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3995 .unwrap()
3996 .load("plan-1")
3997 .unwrap()
3998 .unwrap()
3999 .dir()
4000 .to_path_buf();
4001 std::fs::write(plan_dir.join("plan.json"), &p2).unwrap();
4002 std::fs::write(plan_dir.join("plan.json.sig"), &s2).unwrap();
4003
4004 let err = apply_updates_impl(
4005 &store,
4006 &OpFlags::default(),
4007 Some(ApplyUpdatesPayload {
4008 environment_id: "local".into(),
4009 plan_id: "plan-1".into(),
4010 }),
4011 Some(updates_dir.path()),
4012 )
4013 .unwrap_err();
4014 assert!(matches!(err, OpError::Conflict(m) if m.contains("hash changed")));
4015 assert_eq!(
4016 on_disk_stage(updates_dir.path(), "plan-1"),
4017 UpdateStage::Rejected
4018 );
4019 }
4020
4021 #[test]
4022 fn apply_rejects_tampered_artifact_blob() {
4023 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4024 let dir = tempdir().unwrap();
4025 let updates_dir = tempdir().unwrap();
4026 let store = LocalFsStore::new(dir.path());
4027 let (priv7, tk7) = key_pair(7);
4028 env_trusting(&store, &tk7);
4029
4030 let payload = b"the-artifact-bytes";
4031 let art_digest = format!("sha256:{}", greentic_update::plan::sha256_hex(payload));
4032 let build_trust = TrustRoot::new(vec![tk7.clone()]);
4033 let (p, s) = signed_plan(
4034 "local",
4035 "plan-art",
4036 1,
4037 json!([{"name": "pack-a", "version": "1.0.0", "digest": art_digest, "source": "oci://x/y:1"}]),
4038 json!({}),
4039 &priv7,
4040 &tk7.key_id,
4041 &build_trust,
4042 );
4043 let v = verify_with(&p, &s, &tk7);
4044 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4045 let staged = root.begin(&v, &p, &s).unwrap();
4046 staged.put_artifact(&v.plan.artifacts[0], payload).unwrap();
4047 advance_to_staged(&staged).unwrap();
4048 let blob = staged.artifact_blob_path(&v.plan.artifacts[0]).unwrap();
4050 std::fs::write(&blob, b"corrupted").unwrap();
4051
4052 let err = apply_updates_impl(
4053 &store,
4054 &OpFlags::default(),
4055 Some(ApplyUpdatesPayload {
4056 environment_id: "local".into(),
4057 plan_id: "plan-art".into(),
4058 }),
4059 Some(updates_dir.path()),
4060 )
4061 .unwrap_err();
4062 assert!(matches!(err, OpError::Conflict(m) if m.contains("integrity")));
4063 assert_eq!(
4064 on_disk_stage(updates_dir.path(), "plan-art"),
4065 UpdateStage::Rejected
4066 );
4067 }
4068
4069 #[test]
4070 fn apply_regates_downgrade_against_applied_set() {
4071 use greentic_update::staging::UpdateStage;
4072 let dir = tempdir().unwrap();
4073 let updates_dir = tempdir().unwrap();
4074 let store = LocalFsStore::new(dir.path());
4075 let (priv7, tk7) = key_pair(7);
4076 env_trusting(&store, &tk7);
4077
4078 stage_local(updates_dir.path(), "plan-a", 2, &priv7, &tk7);
4080 stage_local(updates_dir.path(), "plan-b", 1, &priv7, &tk7);
4081
4082 apply_updates_impl(
4084 &store,
4085 &OpFlags::default(),
4086 Some(ApplyUpdatesPayload {
4087 environment_id: "local".into(),
4088 plan_id: "plan-a".into(),
4089 }),
4090 Some(updates_dir.path()),
4091 )
4092 .unwrap();
4093
4094 let err = apply_updates_impl(
4096 &store,
4097 &OpFlags::default(),
4098 Some(ApplyUpdatesPayload {
4099 environment_id: "local".into(),
4100 plan_id: "plan-b".into(),
4101 }),
4102 Some(updates_dir.path()),
4103 )
4104 .unwrap_err();
4105 assert!(matches!(err, OpError::Conflict(_)));
4106 assert_eq!(
4107 on_disk_stage(updates_dir.path(), "plan-b"),
4108 UpdateStage::Rejected
4109 );
4110 }
4111
4112 #[test]
4113 fn apply_rejects_concurrent_applying_plan() {
4114 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4115 let dir = tempdir().unwrap();
4116 let updates_dir = tempdir().unwrap();
4117 let store = LocalFsStore::new(dir.path());
4118 let (priv7, tk7) = key_pair(7);
4119 env_trusting(&store, &tk7);
4120
4121 stage_local(updates_dir.path(), "plan-a", 1, &priv7, &tk7);
4125 stage_local(updates_dir.path(), "plan-b", 2, &priv7, &tk7);
4126 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4127 root.load("plan-a")
4128 .unwrap()
4129 .unwrap()
4130 .transition(UpdateStage::Applying)
4131 .unwrap();
4132
4133 let err = apply_updates_impl(
4134 &store,
4135 &OpFlags::default(),
4136 Some(ApplyUpdatesPayload {
4137 environment_id: "local".into(),
4138 plan_id: "plan-b".into(),
4139 }),
4140 Some(updates_dir.path()),
4141 )
4142 .unwrap_err();
4143 assert!(matches!(err, OpError::Conflict(m) if m.contains("single-flight")));
4144 assert_eq!(
4147 on_disk_stage(updates_dir.path(), "plan-b"),
4148 UpdateStage::Staged
4149 );
4150 assert_eq!(
4151 on_disk_stage(updates_dir.path(), "plan-a"),
4152 UpdateStage::Applying
4153 );
4154 }
4155
4156 #[test]
4157 fn apply_rolls_back_and_fails_plan_on_apply_error() {
4158 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4159 let dir = tempdir().unwrap();
4160 let updates_dir = tempdir().unwrap();
4161 let store = LocalFsStore::new(dir.path());
4162 let (priv7, tk7) = key_pair(7);
4163 let env_id = env_trusting(&store, &tk7);
4164
4165 let bad_target = json!({
4168 "schema": "greentic.env-manifest.v1",
4169 "environment": {"id": "local"},
4170 "bundles": [{
4171 "bundle_id": "b1",
4172 "bundle_path": "/nonexistent/missing.gtbundle",
4173 "bundle_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
4174 }]
4175 });
4176 let build_trust = TrustRoot::new(vec![tk7.clone()]);
4177 let (p, s) = signed_plan_target(
4178 "local",
4179 "plan-bad",
4180 1,
4181 bad_target,
4182 &priv7,
4183 &tk7.key_id,
4184 &build_trust,
4185 );
4186 let v = verify_with(&p, &s, &tk7);
4187 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4188 let staged = root.begin(&v, &p, &s).unwrap();
4189 advance_to_staged(&staged).unwrap();
4190
4191 let err = apply_updates_impl(
4192 &store,
4193 &OpFlags::default(),
4194 Some(ApplyUpdatesPayload {
4195 environment_id: "local".into(),
4196 plan_id: "plan-bad".into(),
4197 }),
4198 Some(updates_dir.path()),
4199 )
4200 .unwrap_err();
4201 assert!(matches!(err, OpError::Conflict(m) if m.contains("rolled back")));
4203 assert_eq!(
4204 on_disk_stage(updates_dir.path(), "plan-bad"),
4205 UpdateStage::Failed
4206 );
4207 assert!(store.env_dir(&env_id).unwrap().join("snapshots").is_dir());
4208 }
4209
4210 #[test]
4211 fn apply_rejects_secrets_when_backend_not_dev_store() {
4212 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4213 let dir = tempdir().unwrap();
4214 let updates_dir = tempdir().unwrap();
4215 let store = LocalFsStore::new(dir.path());
4216 let (priv7, tk7) = key_pair(7);
4217 env_trusting_secrets(&store, &tk7, Some(crate::defaults::VAULT_SECRETS_PACK));
4221
4222 let target = json!({
4223 "schema": "greentic.env-manifest.v1",
4224 "environment": {"id": "local"},
4225 "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4226 });
4227 let build_trust = TrustRoot::new(vec![tk7.clone()]);
4228 let (p, s) = signed_plan_target(
4229 "local",
4230 "plan-sec",
4231 1,
4232 target,
4233 &priv7,
4234 &tk7.key_id,
4235 &build_trust,
4236 );
4237 let v = verify_with(&p, &s, &tk7);
4238 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4239 let staged = root.begin(&v, &p, &s).unwrap();
4240 advance_to_staged(&staged).unwrap();
4241
4242 let err = apply_updates_impl(
4243 &store,
4244 &OpFlags::default(),
4245 Some(ApplyUpdatesPayload {
4246 environment_id: "local".into(),
4247 plan_id: "plan-sec".into(),
4248 }),
4249 Some(updates_dir.path()),
4250 )
4251 .unwrap_err();
4252 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4253 assert_eq!(
4255 on_disk_stage(updates_dir.path(), "plan-sec"),
4256 UpdateStage::Rejected
4257 );
4258 }
4259
4260 #[test]
4261 fn apply_rejects_endpoints_when_backend_not_dev_store() {
4262 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4263 let dir = tempdir().unwrap();
4264 let updates_dir = tempdir().unwrap();
4265 let store = LocalFsStore::new(dir.path());
4266 let (priv7, tk7) = key_pair(7);
4267 env_trusting_secrets(&store, &tk7, Some(crate::defaults::VAULT_SECRETS_PACK));
4270
4271 let target = json!({
4272 "schema": "greentic.env-manifest.v1",
4273 "environment": {"id": "local"},
4274 "messaging_endpoints": [{"name": "tg", "provider_type": "messaging.telegram.bot"}]
4275 });
4276 let build_trust = TrustRoot::new(vec![tk7.clone()]);
4277 let (p, s) = signed_plan_target(
4278 "local",
4279 "plan-ep",
4280 1,
4281 target,
4282 &priv7,
4283 &tk7.key_id,
4284 &build_trust,
4285 );
4286 let v = verify_with(&p, &s, &tk7);
4287 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4288 let staged = root.begin(&v, &p, &s).unwrap();
4289 advance_to_staged(&staged).unwrap();
4290
4291 let err = apply_updates_impl(
4292 &store,
4293 &OpFlags::default(),
4294 Some(ApplyUpdatesPayload {
4295 environment_id: "local".into(),
4296 plan_id: "plan-ep".into(),
4297 }),
4298 Some(updates_dir.path()),
4299 )
4300 .unwrap_err();
4301 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4302 assert_eq!(
4303 on_disk_stage(updates_dir.path(), "plan-ep"),
4304 UpdateStage::Rejected
4305 );
4306 }
4307
4308 #[test]
4309 fn apply_rejects_unpinned_bundle() {
4310 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4311 let dir = tempdir().unwrap();
4312 let updates_dir = tempdir().unwrap();
4313 let store = LocalFsStore::new(dir.path());
4314 let (priv7, tk7) = key_pair(7);
4315 env_trusting(&store, &tk7);
4316
4317 let target = json!({
4320 "schema": "greentic.env-manifest.v1",
4321 "environment": {"id": "local"},
4322 "bundles": [{"bundle_id": "b1", "bundle_path": "/some/local.gtbundle"}]
4323 });
4324 let build_trust = TrustRoot::new(vec![tk7.clone()]);
4325 let (p, s) = signed_plan_target(
4326 "local",
4327 "plan-unpinned",
4328 1,
4329 target,
4330 &priv7,
4331 &tk7.key_id,
4332 &build_trust,
4333 );
4334 let v = verify_with(&p, &s, &tk7);
4335 let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4336 let staged = root.begin(&v, &p, &s).unwrap();
4337 advance_to_staged(&staged).unwrap();
4338
4339 let err = apply_updates_impl(
4340 &store,
4341 &OpFlags::default(),
4342 Some(ApplyUpdatesPayload {
4343 environment_id: "local".into(),
4344 plan_id: "plan-unpinned".into(),
4345 }),
4346 Some(updates_dir.path()),
4347 )
4348 .unwrap_err();
4349 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("bundle_digest")));
4350 assert_eq!(
4351 on_disk_stage(updates_dir.path(), "plan-unpinned"),
4352 UpdateStage::Rejected
4353 );
4354 }
4355
4356 fn parse_manifest(v: Value) -> EnvManifest {
4359 serde_json::from_value(v).expect("valid env-manifest")
4360 }
4361
4362 fn secrets_manifest() -> EnvManifest {
4363 parse_manifest(json!({
4364 "schema": "greentic.env-manifest.v1",
4365 "environment": {"id": "local"},
4366 "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4367 }))
4368 }
4369
4370 #[test]
4371 fn guard_accepts_secret_writes_on_dev_store_env() {
4372 let env = make_env("local");
4376 let td = tempdir().unwrap();
4377 check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap();
4378
4379 let endpoints = parse_manifest(json!({
4380 "schema": "greentic.env-manifest.v1",
4381 "environment": {"id": "local"},
4382 "messaging_endpoints": [{"name": "tg", "provider_type": "messaging.telegram.bot"}]
4383 }));
4384 check_applyable_manifest(&env, td.path(), &endpoints, false).unwrap();
4385 }
4386
4387 #[test]
4388 fn guard_rejects_secret_writes_when_env_backend_is_vault() {
4389 let mut env = make_env("local");
4390 env.packs.push(make_binding(
4391 CapabilitySlot::Secrets,
4392 crate::defaults::VAULT_SECRETS_PACK,
4393 ));
4394 let td = tempdir().unwrap();
4395 let err =
4396 check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap_err();
4397 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4398 }
4399
4400 #[test]
4401 fn guard_rejects_secret_writes_when_manifest_rebinds_secrets_off_dev_store() {
4402 let env = make_env("local");
4405 let td = tempdir().unwrap();
4406 let m = parse_manifest(json!({
4407 "schema": "greentic.env-manifest.v1",
4408 "environment": {"id": "local"},
4409 "packs": [{"slot": "secrets", "kind": "greentic.secrets.vault@1.0.0", "pack_ref": "vault"}],
4410 "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4411 }));
4412 let err = check_applyable_manifest(&env, td.path(), &m, false).unwrap_err();
4413 assert!(
4414 matches!(err, OpError::InvalidArgument(msg) if msg.contains("rebinds the Secrets slot"))
4415 );
4416 }
4417
4418 #[test]
4419 fn guard_accepts_manifest_rebinding_secrets_to_dev_store() {
4420 let env = make_env("local");
4423 let td = tempdir().unwrap();
4424 let m = parse_manifest(json!({
4425 "schema": "greentic.env-manifest.v1",
4426 "environment": {"id": "local"},
4427 "packs": [{"slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "local"}],
4428 "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4429 }));
4430 check_applyable_manifest(&env, td.path(), &m, false).unwrap();
4431 }
4432
4433 #[test]
4434 fn guard_rejects_secret_writes_under_dev_secrets_path_override() {
4435 let env = make_env("local");
4439 let td = tempdir().unwrap();
4440 let err = check_applyable_manifest(&env, td.path(), &secrets_manifest(), true).unwrap_err();
4441 assert!(
4442 matches!(err, OpError::InvalidArgument(m) if m.contains("GREENTIC_DEV_SECRETS_PATH"))
4443 );
4444 }
4445
4446 #[test]
4447 fn guard_rejects_secret_writes_when_dev_store_file_is_symlinked() {
4448 use std::os::unix::fs::symlink;
4452 let env = make_env("local");
4453 let td = tempdir().unwrap();
4454 let dev = td.path().join(crate::cli::secrets::DEV_STORE_RELATIVE);
4455 std::fs::create_dir_all(dev.parent().unwrap()).unwrap();
4456 let external = td.path().join("external.env");
4457 std::fs::write(&external, b"x").unwrap();
4458 symlink(&external, &dev).unwrap();
4459 let err =
4460 check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap_err();
4461 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("symlink")));
4462 }
4463
4464 #[test]
4465 fn guard_ignores_sink_when_manifest_writes_no_secrets() {
4466 let mut env = make_env("local");
4470 env.packs.push(make_binding(
4471 CapabilitySlot::Secrets,
4472 crate::defaults::VAULT_SECRETS_PACK,
4473 ));
4474 let td = tempdir().unwrap();
4475 let m = parse_manifest(json!({
4476 "schema": "greentic.env-manifest.v1",
4477 "environment": {"id": "local"},
4478 "bundles": [{"bundle_id": "b1", "bundle_path": "/x.gtbundle", "bundle_digest": "sha256:aa"}]
4479 }));
4480 check_applyable_manifest(&env, td.path(), &m, false).unwrap();
4481 }
4482
4483 #[test]
4486 fn recover_schema_only_returns_payload_schema() {
4487 let dir = tempdir().unwrap();
4488 let store = LocalFsStore::new(dir.path());
4489 let out = recover_updates(
4490 &store,
4491 &OpFlags {
4492 schema_only: true,
4493 ..OpFlags::default()
4494 },
4495 None,
4496 false,
4497 )
4498 .unwrap();
4499 assert_eq!(out.op, "recover");
4500 assert_eq!(out.noun, NOUN);
4501 assert!(out.result["properties"]["plan_id"].is_object());
4502 }
4503
4504 #[test]
4505 fn recover_plan_not_found_is_not_found() {
4506 let dir = tempdir().unwrap();
4507 let updates_dir = tempdir().unwrap();
4508 let store = LocalFsStore::new(dir.path());
4509 let (_priv7, tk7) = key_pair(7);
4510 env_trusting(&store, &tk7);
4511 let err = recover_updates_impl(
4512 &store,
4513 &OpFlags::default(),
4514 Some(RecoverUpdatesPayload {
4515 environment_id: "local".into(),
4516 plan_id: "ghost".into(),
4517 }),
4518 true,
4519 Some(updates_dir.path()),
4520 )
4521 .unwrap_err();
4522 assert!(matches!(err, OpError::NotFound(_)));
4523 }
4524
4525 #[test]
4526 fn recover_forces_applying_to_failed_and_audits() {
4527 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4528 let dir = tempdir().unwrap();
4529 let updates_dir = tempdir().unwrap();
4530 let store = LocalFsStore::new(dir.path());
4531 let (priv7, tk7) = key_pair(7);
4532 let env_id = env_trusting(&store, &tk7);
4533 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4534 UpdatesRoot::open_in(updates_dir.path(), "local")
4536 .unwrap()
4537 .load("plan-1")
4538 .unwrap()
4539 .unwrap()
4540 .transition(UpdateStage::Applying)
4541 .unwrap();
4542
4543 let out = recover_updates_impl(
4544 &store,
4545 &OpFlags::default(),
4546 Some(RecoverUpdatesPayload {
4547 environment_id: "local".into(),
4548 plan_id: "plan-1".into(),
4549 }),
4550 true,
4551 Some(updates_dir.path()),
4552 )
4553 .unwrap();
4554
4555 assert_eq!(out.op, "recover");
4556 assert_eq!(out.result["previous_stage"], "applying");
4557 assert_eq!(out.result["stage"], "failed");
4558 assert!(out.result["applying_since"].as_str().is_some());
4559
4560 assert_eq!(
4562 on_disk_stage(updates_dir.path(), "plan-1"),
4563 UpdateStage::Failed
4564 );
4565 let env_dir = store.env_dir(&env_id).unwrap();
4567 let audit = std::fs::read_to_string(env_dir.join("audit").join("events.jsonl")).unwrap();
4568 assert!(
4569 audit.contains("recover") && audit.contains("plan-1"),
4570 "audit must record the recover: {audit}"
4571 );
4572 }
4573
4574 #[test]
4575 fn recover_refuses_without_force() {
4576 use greentic_update::staging::{UpdateStage, UpdatesRoot};
4577 let dir = tempdir().unwrap();
4578 let updates_dir = tempdir().unwrap();
4579 let store = LocalFsStore::new(dir.path());
4580 let (priv7, tk7) = key_pair(7);
4581 env_trusting(&store, &tk7);
4582 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4583 UpdatesRoot::open_in(updates_dir.path(), "local")
4584 .unwrap()
4585 .load("plan-1")
4586 .unwrap()
4587 .unwrap()
4588 .transition(UpdateStage::Applying)
4589 .unwrap();
4590
4591 let err = recover_updates_impl(
4592 &store,
4593 &OpFlags::default(),
4594 Some(RecoverUpdatesPayload {
4595 environment_id: "local".into(),
4596 plan_id: "plan-1".into(),
4597 }),
4598 false,
4599 Some(updates_dir.path()),
4600 )
4601 .unwrap_err();
4602 assert!(matches!(err, OpError::Conflict(m) if m.contains("--force")));
4603 assert_eq!(
4605 on_disk_stage(updates_dir.path(), "plan-1"),
4606 UpdateStage::Applying
4607 );
4608 }
4609
4610 #[test]
4611 fn recover_rejects_staged_plan() {
4612 use greentic_update::staging::UpdateStage;
4613 let dir = tempdir().unwrap();
4614 let updates_dir = tempdir().unwrap();
4615 let store = LocalFsStore::new(dir.path());
4616 let (priv7, tk7) = key_pair(7);
4617 env_trusting(&store, &tk7);
4618 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4619
4620 let err = recover_updates_impl(
4621 &store,
4622 &OpFlags::default(),
4623 Some(RecoverUpdatesPayload {
4624 environment_id: "local".into(),
4625 plan_id: "plan-1".into(),
4626 }),
4627 true,
4628 Some(updates_dir.path()),
4629 )
4630 .unwrap_err();
4631 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("not `applying`")));
4632 assert_eq!(
4633 on_disk_stage(updates_dir.path(), "plan-1"),
4634 UpdateStage::Staged
4635 );
4636 }
4637
4638 #[test]
4639 fn recover_rejects_terminal_plan() {
4640 use greentic_update::staging::UpdateStage;
4641 let dir = tempdir().unwrap();
4642 let updates_dir = tempdir().unwrap();
4643 let store = LocalFsStore::new(dir.path());
4644 let (priv7, tk7) = key_pair(7);
4645 env_trusting(&store, &tk7);
4646 stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4647 apply_updates_impl(
4649 &store,
4650 &OpFlags::default(),
4651 Some(ApplyUpdatesPayload {
4652 environment_id: "local".into(),
4653 plan_id: "plan-1".into(),
4654 }),
4655 Some(updates_dir.path()),
4656 )
4657 .unwrap();
4658
4659 let err = recover_updates_impl(
4660 &store,
4661 &OpFlags::default(),
4662 Some(RecoverUpdatesPayload {
4663 environment_id: "local".into(),
4664 plan_id: "plan-1".into(),
4665 }),
4666 true,
4667 Some(updates_dir.path()),
4668 )
4669 .unwrap_err();
4670 assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("terminal")));
4671 assert_eq!(
4672 on_disk_stage(updates_dir.path(), "plan-1"),
4673 UpdateStage::Applied
4674 );
4675 }
4676
4677 fn write_ephemeral_key(dir: &std::path::Path) -> (PathBuf, TrustedKey) {
4683 let (priv_pem, tk) = key_pair(42);
4684 let key_path = dir.join("key.pem");
4685 std::fs::write(&key_path, &priv_pem).unwrap();
4686 #[cfg(unix)]
4687 {
4688 use std::os::unix::fs::PermissionsExt;
4689 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
4690 }
4691 (key_path, tk)
4692 }
4693
4694 fn plan_build_args(
4695 env_id: &str,
4696 sequence: u64,
4697 binaries: Vec<String>,
4698 signing_key: Option<PathBuf>,
4699 out_dir: PathBuf,
4700 ) -> crate::cli::dispatch::UpdatesPlanBuildArgs {
4701 crate::cli::dispatch::UpdatesPlanBuildArgs {
4702 env_id: Some(env_id.to_string()),
4703 sequence: Some(sequence),
4704 binaries,
4705 signing_key,
4706 target_file: None,
4707 min_runtime: None,
4708 out_dir: Some(out_dir),
4709 }
4710 }
4711
4712 #[test]
4713 fn parse_binary_spec_happy_path() {
4714 let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:abc123";
4715 let ba = parse_binary_spec(spec).unwrap();
4716 assert_eq!(ba.name, "greentic-start");
4717 assert_eq!(ba.version, "1.1.9");
4718 assert_eq!(ba.target, "x86_64-unknown-linux-gnu");
4719 assert_eq!(ba.digest, "sha256:abc123");
4720 assert_eq!(ba.source, None);
4721 }
4722
4723 #[test]
4724 fn parse_binary_spec_with_source() {
4725 let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:abc,source=https://example.com/bin.tar.gz";
4726 let ba = parse_binary_spec(spec).unwrap();
4727 assert_eq!(ba.source.as_deref(), Some("https://example.com/bin.tar.gz"));
4728 }
4729
4730 #[test]
4731 fn parse_binary_spec_missing_required_key() {
4732 let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu";
4734 let err = parse_binary_spec(spec).unwrap_err();
4735 match err {
4736 OpError::InvalidArgument(msg) => assert!(
4737 msg.contains("digest"),
4738 "error should name the missing key `digest`, got: {msg}"
4739 ),
4740 other => panic!("expected InvalidArgument, got {other:?}"),
4741 }
4742 }
4743
4744 #[test]
4745 fn parse_binary_spec_unknown_key() {
4746 let spec = "name=x,version=1,target=t,digest=d,flavor=sweet";
4747 let err = parse_binary_spec(spec).unwrap_err();
4748 match err {
4749 OpError::InvalidArgument(msg) => assert!(
4750 msg.contains("flavor"),
4751 "error should name the unknown key `flavor`, got: {msg}"
4752 ),
4753 other => panic!("expected InvalidArgument, got {other:?}"),
4754 }
4755 }
4756
4757 #[test]
4758 fn parse_binary_spec_source_omitted_is_none() {
4759 let spec = "name=x,version=1,target=t,digest=d";
4760 let ba = parse_binary_spec(spec).unwrap();
4761 assert!(ba.source.is_none());
4762 }
4763
4764 #[test]
4765 fn plan_build_round_trip_verifies() {
4766 let dir = tempdir().unwrap();
4767 let out_dir = tempdir().unwrap();
4768 let store = LocalFsStore::new(dir.path());
4769 let (key_path, tk) = write_ephemeral_key(dir.path());
4770 env_trusting(&store, &tk);
4771
4772 let args = plan_build_args(
4773 "local",
4774 1,
4775 vec![
4776 "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:deadbeef,source=https://example.com/bin.tar.gz".to_string(),
4777 ],
4778 Some(key_path),
4779 out_dir.path().to_path_buf(),
4780 );
4781 let outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4782 assert_eq!(outcome.noun, NOUN);
4783 assert_eq!(outcome.op, "plan-build");
4784
4785 let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4787 let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4788 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4789 let trust = store_trust_root::load(&env_dir).unwrap();
4790 let verified =
4791 greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4792
4793 assert_eq!(verified.plan.binaries.len(), 1);
4795 let b = &verified.plan.binaries[0];
4796 assert_eq!(b.name, "greentic-start");
4797 assert_eq!(b.version, "1.1.9");
4798 assert_eq!(b.target, "x86_64-unknown-linux-gnu");
4799 assert_eq!(b.digest, "sha256:deadbeef");
4800 assert_eq!(b.source.as_deref(), Some("https://example.com/bin.tar.gz"));
4801
4802 assert!(verified.plan.artifacts.is_empty());
4804 assert_eq!(verified.plan.sequence, 1);
4806 }
4807
4808 #[test]
4809 fn plan_build_fail_closed_untrusted_key() {
4810 let dir = tempdir().unwrap();
4811 let out_dir = tempdir().unwrap();
4812 let store = LocalFsStore::new(dir.path());
4813 let (_priv7, tk7) = key_pair(7);
4815 env_trusting(&store, &tk7);
4816 let (key_path_42, _tk42) = write_ephemeral_key(dir.path());
4817
4818 let args = plan_build_args(
4819 "local",
4820 1,
4821 vec!["name=x,version=1,target=t,digest=d".to_string()],
4822 Some(key_path_42),
4823 out_dir.path().to_path_buf(),
4824 );
4825 let err = plan_build(&store, &OpFlags::default(), args).unwrap_err();
4826 assert!(
4827 matches!(err, OpError::Conflict(_)),
4828 "expected Conflict for untrusted key, got {err:?}"
4829 );
4830 assert!(!out_dir.path().join("plan.json").exists());
4832 }
4833
4834 #[test]
4835 fn plan_build_min_runtime_threaded_through() {
4836 let dir = tempdir().unwrap();
4837 let out_dir = tempdir().unwrap();
4838 let store = LocalFsStore::new(dir.path());
4839 let (key_path, tk) = write_ephemeral_key(dir.path());
4840 env_trusting(&store, &tk);
4841
4842 let mut args = plan_build_args(
4843 "local",
4844 1,
4845 vec!["name=x,version=1,target=t,digest=d".to_string()],
4846 Some(key_path),
4847 out_dir.path().to_path_buf(),
4848 );
4849 args.min_runtime = Some("1.1.5".to_string());
4850 let _outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4851
4852 let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4853 let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4854 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4855 let trust = store_trust_root::load(&env_dir).unwrap();
4856 let verified =
4857 greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4858 assert_eq!(verified.plan.compat.min_runtime.as_deref(), Some("1.1.5"));
4859 }
4860
4861 #[test]
4862 fn plan_build_target_file_threaded_through() {
4863 let dir = tempdir().unwrap();
4864 let out_dir = tempdir().unwrap();
4865 let store = LocalFsStore::new(dir.path());
4866 let (key_path, tk) = write_ephemeral_key(dir.path());
4867 env_trusting(&store, &tk);
4868
4869 let target_file = dir.path().join("target.json");
4870 std::fs::write(
4871 &target_file,
4872 r#"{"schema":"greentic.env-manifest.v1","environment":{"id":"local"}}"#,
4873 )
4874 .unwrap();
4875
4876 let mut args = plan_build_args(
4877 "local",
4878 1,
4879 vec!["name=x,version=1,target=t,digest=d".to_string()],
4880 Some(key_path),
4881 out_dir.path().to_path_buf(),
4882 );
4883 args.target_file = Some(target_file);
4884 let _outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4885
4886 let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4887 let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4888 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4889 let trust = store_trust_root::load(&env_dir).unwrap();
4890 let verified =
4891 greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4892 assert_eq!(
4893 verified.plan.target["environment"]["id"].as_str(),
4894 Some("local")
4895 );
4896 }
4897
4898 #[test]
4899 fn plan_build_content_only_plan_verifies() {
4900 let dir = tempdir().unwrap();
4901 let out_dir = tempdir().unwrap();
4902 let store = LocalFsStore::new(dir.path());
4903 let (key_path, tk) = write_ephemeral_key(dir.path());
4904 env_trusting(&store, &tk);
4905
4906 let target_file = dir.path().join("target.json");
4907 std::fs::write(
4908 &target_file,
4909 r#"{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},
4910 "bundles":[{"bundle_id":"app","bundle_path":"/tmp/app.gtbundle","bundle_digest":"sha256:aa"}]}"#,
4911 )
4912 .unwrap();
4913
4914 let mut args = plan_build_args(
4916 "local",
4917 2,
4918 vec![],
4919 Some(key_path),
4920 out_dir.path().to_path_buf(),
4921 );
4922 args.target_file = Some(target_file);
4923 plan_build(&store, &OpFlags::default(), args).unwrap();
4924
4925 let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4926 let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4927 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4928 let trust = store_trust_root::load(&env_dir).unwrap();
4929 let verified =
4930 greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4931 assert!(verified.plan.binaries.is_empty());
4932 assert_eq!(
4933 verified.plan.target["bundles"][0]["bundle_id"].as_str(),
4934 Some("app")
4935 );
4936 }
4937
4938 #[test]
4939 fn plan_build_rejects_plan_with_neither_target_nor_binary() {
4940 let dir = tempdir().unwrap();
4941 let store = LocalFsStore::new(dir.path());
4942 let (key_path, tk) = write_ephemeral_key(dir.path());
4943 env_trusting(&store, &tk);
4944
4945 let args = plan_build_args("local", 1, vec![], Some(key_path), dir.path().to_path_buf());
4946 let err = plan_build(&store, &OpFlags::default(), args).unwrap_err();
4947 assert!(
4948 matches!(&err, OpError::InvalidArgument(m) if m.contains("--binary") && m.contains("--target-file")),
4949 "unexpected error: {err:?}"
4950 );
4951 }
4952
4953 #[test]
4954 fn plan_build_schema_only() {
4955 let dir = tempdir().unwrap();
4956 let store = LocalFsStore::new(dir.path());
4957 let args = plan_build_args(
4958 "local",
4959 1,
4960 vec!["name=x,version=1,target=t,digest=d".to_string()],
4961 None,
4962 dir.path().to_path_buf(),
4963 );
4964 let out = plan_build(
4965 &store,
4966 &OpFlags {
4967 schema_only: true,
4968 ..OpFlags::default()
4969 },
4970 args,
4971 )
4972 .unwrap();
4973 assert_eq!(out.op, "plan-build");
4974 assert_eq!(out.noun, NOUN);
4975 assert!(out.result["properties"]["sequence"].is_object());
4976 }
4977}