1use std::io::Write as _;
100use std::path::PathBuf;
101use std::time::{Duration, Instant};
102
103use async_trait::async_trait;
104use serde::Serialize;
105
106use super::deploy::load_target;
107use super::projects::validate_import;
108use super::resources::export_zip_bytes;
109use super::workspace::rfc3339_now_utc;
110use crate::client::GatewayApi;
111use crate::client::projects::{ImportOutcome, ProjectRecord};
112use crate::client::query::ListQuery;
113use crate::deploy::apply::hold::SystemClock;
114use crate::deploy::apply::preflight::{
115 DesignerSeen, DesignersPolicy, DesignersVerdict, designers_detail, designers_gate,
116 read_designers, wrong_target_failure, wrong_target_gate,
117};
118use crate::deploy::apply::run_store::{EventLog, RunDir, RunId, run_dir_rel};
119use crate::deploy::ledger::{LedgerRoot, release_record_location};
120use crate::deploy::project_artifact::{
121 Artifact, CanonicalDigest, build_artifact, canonical_digest,
122};
123use crate::deploy::projects::{
124 MemberDelta, PROJECT_FILE_REL, ParentLink, ProjectAction, ProjectFile, ProjectMeta, Release,
125 ReleaseOrder, ReleaseRecord, check_parent, load_project_file, member_delta, parent_chain,
126 read_project_meta,
127};
128use crate::deploy::target::{NodeTarget, Target};
129use crate::deploy::transport::for_node;
130use crate::deploy::transport::projects::{
131 ProjectHost, ReleaseRecordWriter, git_entry_location, release_writer_for_node,
132};
133use crate::deploy::transport::remote::safe_remote_path;
134use crate::error::{CoreError, PreflightFailure};
135
136pub const LIST_CAP: usize = 200;
138
139pub const CHILDREN_SETTLE_S: u64 = 30;
143
144pub const CHILDREN_POLL_S: u64 = 5;
146
147pub const PROJECTS_CANARY_EXEMPT: &[&str] = &["export"];
157
158#[async_trait]
165pub trait ProjectGateway: Send + Sync {
166 async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError>;
168 async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError>;
170 async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError>;
172 async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError>;
175 async fn gateway_name(&self) -> Result<Option<String>, CoreError>;
179 async fn project_import(
183 &self,
184 name: &str,
185 zip: Vec<u8>,
186 overwrite: bool,
187 ) -> Result<ImportOutcome, CoreError>;
188}
189
190pub struct GatewayProjects<'a>(pub &'a dyn GatewayApi);
192
193#[async_trait]
194impl ProjectGateway for GatewayProjects<'_> {
195 async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError> {
196 Ok(self.0.projects(&ListQuery::default()).await?.items)
197 }
198
199 async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError> {
200 match self.0.project_find(name).await {
201 Ok(record) => Ok(Some(record)),
202 Err(CoreError::NotFound { .. }) => Ok(None),
203 Err(err) => Err(err),
204 }
205 }
206
207 async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError> {
208 export_zip_bytes(self.0, name).await
209 }
210
211 async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError> {
212 read_designers(self.0).await
213 }
214
215 async fn gateway_name(&self) -> Result<Option<String>, CoreError> {
216 Ok(self
217 .0
218 .gateway_info()
219 .await?
220 .name
221 .filter(|name| !name.trim().is_empty()))
222 }
223
224 async fn project_import(
225 &self,
226 name: &str,
227 zip: Vec<u8>,
228 overwrite: bool,
229 ) -> Result<ImportOutcome, CoreError> {
230 self.0.project_import(name, zip, overwrite).await
231 }
232}
233
234pub struct ProjectsSurfaces<'a> {
239 pub gateway: &'a dyn ProjectGateway,
241 pub host: &'a dyn ProjectHost,
243}
244
245#[derive(Debug, Clone)]
247pub struct ProjectsInput {
248 pub repo: PathBuf,
250 pub targets: PathBuf,
252 pub env: String,
254 pub release: String,
256 pub allow_downgrade: bool,
259 pub allow_first_change: Option<String>,
262 pub artifact_sha256: Option<String>,
267}
268
269pub const FIRST_CHANGE_REASON_MAX: usize = 500;
271
272#[derive(Debug)]
274pub struct LoadedProject {
275 pub file: ProjectFile,
277 pub meta: ProjectMeta,
279 pub target_file: String,
281 pub target: Target,
283 pub artifact: Artifact,
285 pub release: Release,
287}
288
289pub fn load_project(input: &ProjectsInput) -> Result<LoadedProject, CoreError> {
291 let release = Release::parse(&input.release)?;
294 if let Some(reason) = &input.allow_first_change {
295 let chars = reason.chars().count();
296 if chars == 0 || chars > FIRST_CHANGE_REASON_MAX {
297 return Err(CoreError::InvalidInput {
298 reason: format!(
299 "--allow-first-change <reason>: the reason is 1 to {FIRST_CHANGE_REASON_MAX} characters"
300 ),
301 });
302 }
303 }
304 if let Some(hash) = &input.artifact_sha256
305 && !is_lower_hex64(hash)
306 {
307 return Err(CoreError::InvalidInput {
308 reason: "--artifact-sha256 must be 64 lowercase hex characters (the \
309 `artifact_sha256` of `ign deploy projects --check --json`)"
310 .to_string(),
311 });
312 }
313 let file = load_project_file(&input.repo)?;
314 file.require_env(&input.env)?;
315 let meta = read_project_meta(&input.repo)?;
316 let (target_file, target) = load_target(&input.targets, &input.env)?;
317 record_names_fit_the_host(
320 &file.project,
321 &target.gateway,
322 &target_file,
323 &target.nodes.master,
324 )?;
325 let artifact = build_artifact(&input.repo)?;
326 check_artifact_zip(&artifact.zip)?;
327 if let Some(expected) = &input.artifact_sha256
331 && *expected != artifact.digest.sha256
332 {
333 return Err(CoreError::ProjectHashMismatch {
334 project: file.project.clone(),
335 expected: expected.clone(),
336 actual: artifact.digest.sha256.clone(),
337 });
338 }
339 Ok(LoadedProject {
340 file,
341 meta,
342 target_file,
343 target,
344 artifact,
345 release,
346 })
347}
348
349fn record_names_fit_the_host(
358 project: &str,
359 gateway: &str,
360 target_file: &str,
361 master: &NodeTarget,
362) -> Result<(), CoreError> {
363 if master.ssh.is_none() {
364 return Ok(());
365 }
366 if let Err(why) = safe_remote_path(project) {
367 return Err(CoreError::deploy_tree_invalid(
368 PROJECT_FILE_REL,
369 None,
370 None,
371 format!(
372 "project: the name cannot be used for the release record on an ssh node ({why})"
373 ),
374 ));
375 }
376 if let Err(why) = safe_remote_path(gateway) {
377 return Err(CoreError::deploy_tree_invalid(
378 target_file,
379 None,
380 None,
381 format!(
382 "gateway: the name cannot be used for the release record on an ssh node ({why})"
383 ),
384 ));
385 }
386 Ok(())
387}
388
389fn is_lower_hex64(text: &str) -> bool {
391 text.len() == 64
392 && text
393 .bytes()
394 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
395}
396
397pub(crate) fn check_artifact_zip(zip: &[u8]) -> Result<(), CoreError> {
402 validate_import(zip)
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
407#[serde(rename_all = "snake_case")]
408pub enum ProjectOutcome {
409 Checked,
411 Skipped,
414 Created,
416 Imported,
419 Recorded,
422 ImportedUnverified,
425}
426
427impl ProjectOutcome {
428 pub fn as_str(self) -> &'static str {
430 match self {
431 ProjectOutcome::Checked => "checked",
432 ProjectOutcome::Skipped => "skipped",
433 ProjectOutcome::Created => "created",
434 ProjectOutcome::Imported => "imported",
435 ProjectOutcome::Recorded => "recorded",
436 ProjectOutcome::ImportedUnverified => "imported_unverified",
437 }
438 }
439}
440
441pub const PROJECT_UNVERIFIED_SLUG: &str = "deploy_project_unverified";
447
448#[derive(Debug, Clone, Serialize)]
450pub struct DeployProjectsResult {
451 pub env: String,
453 pub gateway: String,
455 pub project: String,
457 pub release: String,
459 pub check: bool,
461 pub action: ProjectAction,
463 pub would_change: bool,
465 pub artifact_sha256: String,
467 pub live_sha256: Option<String>,
469 pub members: MemberDelta,
471 pub removed: Vec<String>,
473 pub removed_count: usize,
475 pub excluded: Vec<String>,
480 pub excluded_count: usize,
482 pub unmanaged: Vec<String>,
485 pub unmanaged_count: usize,
487 pub parent: Option<String>,
490 pub previous_release: Option<String>,
493 pub recorded_sha256: Option<String>,
496 pub live_matches_last_deploy: Option<bool>,
499 pub allow_downgrade: bool,
501 pub allow_first_change: Option<String>,
503 pub warnings: Vec<String>,
505 pub run_id: Option<String>,
507 pub run_dir: Option<String>,
510 pub record_written: bool,
512 pub restore_command: Option<String>,
516 pub children: Vec<ChildReport>,
521 pub outcome: ProjectOutcome,
523 pub ok: bool,
525 pub slug: Option<String>,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
531#[serde(rename_all = "snake_case")]
532pub enum ChildStatus {
533 Ok,
535 Mismatch,
537 Unverified,
540}
541
542impl ChildStatus {
543 pub fn as_str(self) -> &'static str {
545 match self {
546 ChildStatus::Ok => "ok",
547 ChildStatus::Mismatch => "mismatch",
548 ChildStatus::Unverified => "unverified",
549 }
550 }
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
556pub struct ChildReport {
557 pub name: String,
559 pub status: ChildStatus,
561 pub release: Option<String>,
563 pub recorded_sha256: Option<String>,
565 pub live_sha256: Option<String>,
568}
569
570impl DeployProjectsResult {
571 pub fn failure_exit_code(&self) -> Option<u8> {
575 (self.outcome == ProjectOutcome::ImportedUnverified).then_some(6)
576 }
577}
578
579fn shell_word(text: &str) -> String {
582 let plain = !text.is_empty()
583 && text.chars().all(|c| {
584 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '+' | ',')
585 });
586 if plain {
587 text.to_string()
588 } else {
589 format!("'{}'", text.replace('\'', "'\\''"))
590 }
591}
592
593fn restore_command(project: &str, run_dir_rel: &str) -> String {
598 format!(
599 "ign project import {} --file {} --collision-policy overwrite --yes",
600 shell_word(project),
601 shell_word(&format!("{run_dir_rel}/export/{project}.zip")),
602 )
603}
604
605fn capped(mut items: Vec<String>) -> (Vec<String>, usize) {
606 let count = items.len();
607 items.truncate(LIST_CAP);
608 (items, count)
609}
610
611fn parent_link(record: &ProjectRecord) -> ParentLink {
613 ParentLink {
614 name: record.name.clone(),
615 parent: record.parent.clone().filter(|p| !p.is_empty()),
616 inheritable: record.inheritable,
617 invalid_parent: record.invalid_parent,
618 }
619}
620
621fn live_digest(project: &str, zip: &[u8]) -> Result<CanonicalDigest, CoreError> {
624 canonical_digest(zip).map_err(|err| match err {
625 CoreError::DeployTreeInvalid(_) => CoreError::Internal(format!(
626 "the gateway's export of project {project} holds no members"
627 )),
628 other => other,
629 })
630}
631
632#[derive(Debug, Clone)]
634pub enum ProjectsPrepared {
635 Ready {
638 prose: String,
641 plan: DeployProjectsResult,
644 },
645 Unchanged(DeployProjectsResult),
648}
649
650impl ProjectsPrepared {
651 pub fn into_check_result(self) -> DeployProjectsResult {
654 let (ProjectsPrepared::Ready {
655 plan: mut result, ..
656 }
657 | ProjectsPrepared::Unchanged(mut result)) = self;
658 result.check = true;
659 result.outcome = ProjectOutcome::Checked;
660 result
661 }
662}
663
664fn plan_prose(plan: &DeployProjectsResult) -> String {
669 let action = match plan.action {
670 ProjectAction::Create => "create project",
671 ProjectAction::Import => "overwrite-import project",
672 ProjectAction::Record => "record the release only for project",
673 ProjectAction::Skip => "leave project",
674 };
675 let mut lines = vec![format!(
676 "deploy projects will {action} {} on gateway {} (env {}, release {})",
677 plan.project, plan.gateway, plan.env, plan.release
678 )];
679 if plan.action == ProjectAction::Record {
680 lines.push("the project itself is not changed".to_string());
681 } else {
682 lines.push(format!(
683 "members: {} added, {} changed, {} removed, {} same",
684 plan.members.added, plan.members.changed, plan.members.removed, plan.members.same
685 ));
686 }
687 if plan.removed_count > 0 {
688 lines.push(format!(
689 "the import deletes {} member(s) the repo does not have:",
690 plan.removed_count
691 ));
692 for path in plan.removed.iter().take(PROSE_REMOVED_SHOWN) {
693 lines.push(format!(" {path}"));
694 }
695 if plan.removed_count > PROSE_REMOVED_SHOWN {
696 lines.push(format!(
697 " ... and {} more",
698 plan.removed_count - PROSE_REMOVED_SHOWN
699 ));
700 }
701 }
702 if plan.action == ProjectAction::Import {
703 lines.push("the live project is saved into a run directory before the import".to_string());
704 }
705 lines.push(format!("artifact_sha256 {}", plan.artifact_sha256));
706 lines.join("\n")
707}
708
709const PROSE_REMOVED_SHOWN: usize = 20;
711
712pub async fn deploy_projects_prepare(
721 loaded: &LoadedProject,
722 input: &ProjectsInput,
723 surfaces: &ProjectsSurfaces<'_>,
724) -> Result<ProjectsPrepared, CoreError> {
725 let project = &loaded.file.project;
726 let gateway = surfaces.gateway;
727 let gateway_name = &loaded.target.gateway;
728
729 let git = surfaces.host.git_entry(project).await?;
730 if git.is_present() {
731 return Err(CoreError::ProjectGitManaged {
732 project: project.clone(),
733 location: git_entry_location(project),
734 kind: git.as_str().to_string(),
735 });
736 }
737
738 let record = match surfaces
742 .host
743 .read_release_record(gateway_name, project)
744 .await?
745 {
746 None => None,
747 Some(bytes) => {
748 let location = release_record_location(
749 LedgerRoot::for_node(&loaded.target.nodes.master),
750 gateway_name,
751 project,
752 );
753 Some(ReleaseRecord::parse(
754 &bytes,
755 &location,
756 gateway_name,
757 project,
758 )?)
759 }
760 };
761 if let Some(record) = &record
762 && loaded.release.order_against(&record.release()?) == ReleaseOrder::Older
763 && !input.allow_downgrade
764 {
765 return Err(CoreError::DestructiveChangeRefused {
766 gate: "downgrade".to_string(),
767 keys: vec![project.clone()],
768 count: 1,
769 });
770 }
771
772 let records = gateway.projects().await?;
773 let mut warnings = Vec::new();
777 if let Some(parent) = &loaded.meta.parent {
778 let all: Vec<ParentLink> = records.iter().map(parent_link).collect();
779 let (chain, bound_hit) = parent_chain(parent, &all);
780 check_parent(project, parent, &chain, bound_hit).map_err(|reason| {
781 CoreError::ProjectParentInvalid {
782 project: project.clone(),
783 parent: parent.clone(),
784 reason: reason.as_str().to_string(),
785 }
786 })?;
787 if chain
791 .first()
792 .is_some_and(|p| p.invalid_parent == Some(true))
793 {
794 warnings.push(format!(
795 "project {project}: the gateway reports that parent {parent} has an invalid parent of its own"
796 ));
797 }
798 }
799 let repo_parent = loaded.meta.parent.as_deref().filter(|p| !p.is_empty());
803 if let Some(live_record) = records.iter().find(|r| r.name == *project) {
804 let live_parent = live_record.parent.as_deref().filter(|p| !p.is_empty());
805 if live_parent != repo_parent {
806 warnings.push(format!(
807 "project {project}: importing this project changes its parent from {} to {}",
808 live_parent.unwrap_or("none"),
809 repo_parent.unwrap_or("none")
810 ));
811 }
812 }
813 let mut others: Vec<String> = records
814 .into_iter()
815 .map(|r| r.name)
816 .filter(|name| name != project)
817 .collect();
818 others.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
819 others.dedup();
820
821 let live = match gateway.project_find(project).await? {
822 None => None,
823 Some(_) => {
824 let zip = gateway.export_bytes(project).await?;
825 Some(live_digest(project, &zip)?)
826 }
827 };
828
829 let artifact = &loaded.artifact.digest;
830 let equal = live.as_ref().is_some_and(|live| {
831 !live.members.is_empty() && !artifact.members.is_empty() && live.sha256 == artifact.sha256
832 });
833 let action = match &live {
834 None => ProjectAction::Create,
835 Some(_) if equal => match &record {
838 Some(record) if record.release()?.version == loaded.release.version => {
839 ProjectAction::Skip
840 }
841 _ => ProjectAction::Record,
842 },
843 Some(_) => ProjectAction::Import,
844 };
845 if action == ProjectAction::Import && record.is_none() && input.allow_first_change.is_none() {
848 return Err(CoreError::DestructiveChangeRefused {
849 gate: "first_deploy".to_string(),
850 keys: vec![project.clone()],
851 count: 1,
852 });
853 }
854 let live_matches_last_deploy = match (&record, &live) {
855 (Some(record), Some(live)) => Some(record.artifact_sha256 == live.sha256),
856 _ => None,
857 };
858 if let (Some(record), Some(false)) = (&record, live_matches_last_deploy) {
859 warnings.push(format!(
860 "project {project}: the live project no longer matches the last release this tool deployed ({})",
861 record.release
862 ));
863 }
864 let (members, removed) = member_delta(artifact, live.as_ref());
865 let (removed, removed_count) = capped(removed);
866 let (excluded, excluded_count) = capped(loaded.artifact.excluded.clone());
867 let (unmanaged, unmanaged_count) = capped(others);
868
869 let plan = DeployProjectsResult {
870 env: input.env.clone(),
871 gateway: loaded.target.gateway.clone(),
872 project: project.clone(),
873 release: input.release.clone(),
874 check: false,
875 action,
876 would_change: action.would_change(),
877 artifact_sha256: artifact.sha256.clone(),
878 live_sha256: live.map(|l| l.sha256),
879 members,
880 removed,
881 removed_count,
882 excluded,
883 excluded_count,
884 unmanaged,
885 unmanaged_count,
886 parent: loaded.meta.parent.clone(),
887 previous_release: record.as_ref().map(|r| r.release.clone()),
888 recorded_sha256: record.map(|r| r.artifact_sha256),
889 live_matches_last_deploy,
890 allow_downgrade: input.allow_downgrade,
891 allow_first_change: input.allow_first_change.clone(),
892 warnings,
893 run_id: None,
894 run_dir: None,
895 record_written: false,
896 restore_command: None,
897 children: Vec::new(),
898 outcome: match action {
899 ProjectAction::Create => ProjectOutcome::Created,
900 ProjectAction::Skip => ProjectOutcome::Skipped,
901 ProjectAction::Import => ProjectOutcome::Imported,
902 ProjectAction::Record => ProjectOutcome::Recorded,
903 },
904 ok: true,
905 slug: None,
906 };
907 Ok(if action == ProjectAction::Skip {
908 ProjectsPrepared::Unchanged(plan)
909 } else {
910 ProjectsPrepared::Ready {
911 prose: plan_prose(&plan),
912 plan,
913 }
914 })
915}
916
917#[derive(Debug, Clone, PartialEq, Eq)]
922pub enum ProjectsEvent {
923 DesignersPoll {
926 open: usize,
928 },
929 DesignersProceed {
931 sessions: Vec<DesignerSeen>,
933 },
934 DesignersSkipped,
937 RunStarted {
939 run_id: String,
941 action: ProjectAction,
943 },
944 PreImageExported {
946 bytes: usize,
948 },
949 ImportSent {
951 overwrite: bool,
953 },
954 ImportAnswered,
956 Verified {
958 equal: bool,
960 },
961 RecordWritten,
963 RecordFailed {
965 code: String,
967 },
968 ChildrenSettling {
971 children: Vec<String>,
973 },
974 ChildChecked {
976 name: String,
978 status: ChildStatus,
980 },
981 Finished {
983 outcome: ProjectOutcome,
985 },
986}
987
988impl ProjectsEvent {
989 pub fn name(&self) -> &'static str {
991 match self {
992 ProjectsEvent::DesignersPoll { .. } => "designers_poll",
993 ProjectsEvent::DesignersProceed { .. } => "designers_proceed",
994 ProjectsEvent::DesignersSkipped => "designers_skipped",
995 ProjectsEvent::RunStarted { .. } => "run_started",
996 ProjectsEvent::PreImageExported { .. } => "pre_image_exported",
997 ProjectsEvent::ImportSent { .. } => "import_sent",
998 ProjectsEvent::ImportAnswered => "import_answered",
999 ProjectsEvent::Verified { .. } => "verified",
1000 ProjectsEvent::RecordWritten => "record_written",
1001 ProjectsEvent::RecordFailed { .. } => "record_failed",
1002 ProjectsEvent::ChildrenSettling { .. } => "children_settling",
1003 ProjectsEvent::ChildChecked { .. } => "child_checked",
1004 ProjectsEvent::Finished { .. } => "finished",
1005 }
1006 }
1007
1008 fn fields(&self) -> serde_json::Value {
1009 match self {
1010 ProjectsEvent::DesignersPoll { open } => serde_json::json!({ "open": open }),
1011 ProjectsEvent::DesignersProceed { sessions } => {
1012 serde_json::json!({ "sessions": sessions_json(sessions) })
1013 }
1014 ProjectsEvent::DesignersSkipped => serde_json::json!({ "reason": "record only" }),
1015 ProjectsEvent::RunStarted { run_id, action } => {
1016 serde_json::json!({ "run_id": run_id, "action": action })
1017 }
1018 ProjectsEvent::PreImageExported { bytes } => serde_json::json!({ "bytes": bytes }),
1019 ProjectsEvent::ImportSent { overwrite } => {
1020 serde_json::json!({ "overwrite": overwrite })
1021 }
1022 ProjectsEvent::Verified { equal } => serde_json::json!({ "equal": equal }),
1023 ProjectsEvent::RecordFailed { code } => serde_json::json!({ "code": code }),
1024 ProjectsEvent::ChildrenSettling { children } => {
1025 serde_json::json!({ "children": children })
1026 }
1027 ProjectsEvent::ChildChecked { name, status } => {
1028 serde_json::json!({ "name": name, "status": status.as_str() })
1029 }
1030 ProjectsEvent::Finished { outcome } => {
1031 serde_json::json!({ "outcome": outcome.as_str() })
1032 }
1033 ProjectsEvent::ImportAnswered | ProjectsEvent::RecordWritten => serde_json::Value::Null,
1034 }
1035 }
1036}
1037
1038pub trait ProjectsObserver: Send + Sync {
1041 fn event(&self, event: &ProjectsEvent);
1043}
1044
1045pub struct NoProjectsObserver;
1047
1048impl ProjectsObserver for NoProjectsObserver {
1049 fn event(&self, _event: &ProjectsEvent) {}
1050}
1051
1052pub struct ProjectsRunOptions<'a> {
1054 pub repo: PathBuf,
1056 pub targets_root: PathBuf,
1059 pub target_file: String,
1061 pub master: NodeTarget,
1065 #[doc(hidden)]
1068 pub record_writer: Option<&'a dyn ReleaseRecordWriter>,
1069 pub designers: DesignersPolicy,
1071 pub children_settle: Duration,
1074 pub children_poll: Duration,
1078}
1079
1080struct RunEvents<'a> {
1084 log: EventLog,
1085 observer: &'a dyn ProjectsObserver,
1086 late_failure: bool,
1087}
1088
1089impl RunEvents<'_> {
1090 fn emit(&mut self, event: ProjectsEvent) -> Result<(), CoreError> {
1091 self.observer.event(&event);
1092 self.log.append(event.name(), event.fields())
1093 }
1094
1095 fn record(&mut self, event: &ProjectsEvent) -> Result<(), CoreError> {
1097 self.log.append(event.name(), event.fields())
1098 }
1099
1100 fn emit_late(&mut self, event: ProjectsEvent) {
1101 if self.emit(event).is_err() {
1102 self.late_failure = true;
1103 }
1104 }
1105}
1106
1107fn sessions_json(sessions: &[DesignerSeen]) -> Vec<serde_json::Value> {
1110 sessions
1111 .iter()
1112 .map(|(user, project, host)| {
1113 serde_json::json!({ "user": user, "project": project, "host": host })
1114 })
1115 .collect()
1116}
1117
1118struct DesignersFound {
1120 mode: &'static str,
1123 timeout_s: Option<u64>,
1125 polls: Vec<usize>,
1127 seen: Vec<DesignerSeen>,
1129 proceeded: bool,
1131 skipped: bool,
1133}
1134
1135async fn designers_check(
1140 plan: &DeployProjectsResult,
1141 gateway: &dyn ProjectGateway,
1142 policy: &DesignersPolicy,
1143 observer: &dyn ProjectsObserver,
1144) -> Result<DesignersFound, CoreError> {
1145 if plan.action == ProjectAction::Record {
1146 return Ok(DesignersFound {
1147 mode: "skipped",
1148 timeout_s: None,
1149 polls: Vec::new(),
1150 seen: Vec::new(),
1151 proceeded: false,
1152 skipped: true,
1153 });
1154 }
1155 let mut polls = Vec::new();
1156 let (verdict, seen) =
1157 designers_gate(|| gateway.designers(), policy, &SystemClock, &mut |open| {
1158 polls.push(open);
1159 observer.event(&ProjectsEvent::DesignersPoll { open });
1160 Ok(())
1161 })
1162 .await?;
1163 if verdict == DesignersVerdict::Open {
1164 return Err(CoreError::DeployPreflightFailed {
1165 failures: Box::new([PreflightFailure {
1166 check: "designers".to_string(),
1167 detail: designers_detail(&seen),
1168 }]),
1169 });
1170 }
1171 Ok(DesignersFound {
1172 mode: policy.mode.as_str(),
1173 timeout_s: Some(policy.timeout.as_secs()),
1174 polls,
1175 seen,
1176 proceeded: verdict == DesignersVerdict::Proceeded,
1177 skipped: false,
1178 })
1179}
1180
1181async fn wrong_target_check(
1186 loaded: &LoadedProject,
1187 gateway: &dyn ProjectGateway,
1188 opts: &ProjectsRunOptions<'_>,
1189) -> Result<(), CoreError> {
1190 let rest = gateway.gateway_name().await?;
1191 let reader = for_node(
1192 &opts.targets_root,
1193 &opts.target_file,
1194 "nodes.master",
1195 &opts.master,
1196 )?;
1197 let problems = wrong_target_gate(
1198 rest.as_deref(),
1199 reader.as_ref(),
1200 &loaded.target.mode,
1201 opts.master.gateway_name.as_deref(),
1202 )
1203 .await?;
1204 if problems.is_empty() {
1205 return Ok(());
1206 }
1207 Err(CoreError::DeployPreflightFailed {
1208 failures: Box::new([wrong_target_failure(&problems)]),
1209 })
1210}
1211
1212fn release_record(plan: &DeployProjectsResult, release: &str, run_id: &RunId) -> ReleaseRecord {
1214 ReleaseRecord {
1215 schema_version: 1,
1216 gateway: plan.gateway.clone(),
1217 project: plan.project.clone(),
1218 release: release.to_string(),
1219 artifact_sha256: plan.artifact_sha256.clone(),
1220 run_id: run_id.as_str().to_string(),
1221 deployed_at: rfc3339_now_utc(),
1222 }
1223}
1224
1225fn stale_live(expected: String, actual: String) -> CoreError {
1226 CoreError::PlanStale {
1227 expected,
1228 actual,
1229 detail: "live project changed since the plan".to_string(),
1230 }
1231}
1232
1233pub async fn deploy_projects_run(
1240 prepared: ProjectsPrepared,
1241 loaded: &LoadedProject,
1242 input: &ProjectsInput,
1243 surfaces: &ProjectsSurfaces<'_>,
1244 opts: &ProjectsRunOptions<'_>,
1245 observer: &dyn ProjectsObserver,
1246) -> Result<DeployProjectsResult, CoreError> {
1247 let plan = match prepared {
1248 ProjectsPrepared::Unchanged(result) => return Ok(result),
1249 ProjectsPrepared::Ready { plan, .. } => plan,
1250 };
1251 let built;
1254 let writer: &dyn ReleaseRecordWriter = match opts.record_writer {
1255 Some(writer) => writer,
1256 None => {
1257 built = release_writer_for_node(
1258 &opts.targets_root,
1259 &opts.target_file,
1260 "nodes.master",
1261 &opts.master,
1262 )?;
1263 built.as_ref()
1264 }
1265 };
1266 wrong_target_check(loaded, surfaces.gateway, opts).await?;
1269 let designers = designers_check(&plan, surfaces.gateway, &opts.designers, observer).await?;
1270 let (run_id, run_dir) = RunDir::create_fresh(&opts.repo, &plan.gateway)?;
1271 let rel = run_dir_rel(&plan.gateway, &run_id);
1272 let outcome = execute(
1273 &plan, loaded, input, surfaces, opts, writer, &run_dir, &run_id, &rel, &designers, observer,
1274 )
1275 .await;
1276 match outcome {
1277 Ok(mut result) => {
1278 if let Err(err) = run_dir.write_json("result.json", &result) {
1279 result.warnings.push(format!(
1280 "the run's result.json could not be written ({})",
1281 err.code()
1282 ));
1283 }
1284 Ok(result)
1285 }
1286 Err(err) => {
1287 let _ = run_dir.write_json(
1289 "result.json",
1290 &serde_json::json!({
1291 "ok": false,
1292 "slug": err.code(),
1293 "run_id": run_id.as_str(),
1294 }),
1295 );
1296 Err(err)
1297 }
1298 }
1299}
1300
1301struct ChildSweep {
1303 children: Vec<ChildReport>,
1304 warnings: Vec<String>,
1305}
1306
1307type ChildRead = Result<String, String>;
1310
1311async fn read_children(gateway: &dyn ProjectGateway, names: &[String]) -> Vec<ChildRead> {
1312 let mut reads = Vec::with_capacity(names.len());
1313 for name in names {
1314 reads.push(match gateway.export_bytes(name).await {
1315 Ok(zip) => live_digest(name, &zip)
1316 .map(|digest| digest.sha256)
1317 .map_err(|err| err.code().to_string()),
1318 Err(err) => Err(err.code().to_string()),
1319 });
1320 }
1321 reads
1322}
1323
1324async fn sweep_children(
1335 result: &DeployProjectsResult,
1336 loaded: &LoadedProject,
1337 surfaces: &ProjectsSurfaces<'_>,
1338 opts: &ProjectsRunOptions<'_>,
1339 events: &mut RunEvents<'_>,
1340) -> ChildSweep {
1341 let project = result.project.as_str();
1342 let gateway = surfaces.gateway;
1343 let mut sweep = ChildSweep {
1344 children: Vec::new(),
1345 warnings: Vec::new(),
1346 };
1347
1348 let records = match gateway.projects().await {
1349 Ok(records) => records,
1350 Err(err) => {
1351 sweep.warnings.push(format!(
1352 "could not list the projects to check the children of {project} ({})",
1353 err.code()
1354 ));
1355 return sweep;
1356 }
1357 };
1358 let mut names: Vec<String> = records
1359 .into_iter()
1360 .filter(|record| record.parent.as_deref() == Some(project))
1361 .map(|record| record.name)
1362 .collect();
1363 names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1364 names.dedup();
1365 if names.is_empty() {
1366 return sweep;
1367 }
1368 events.emit_late(ProjectsEvent::ChildrenSettling {
1369 children: names.clone(),
1370 });
1371
1372 let started = Instant::now();
1375 let mut current = read_children(gateway, &names).await;
1376 let mut unsettled = vec![true; names.len()];
1377 loop {
1378 let elapsed = started.elapsed();
1379 if elapsed >= opts.children_settle {
1380 break;
1381 }
1382 tokio::time::sleep(opts.children_poll.min(opts.children_settle - elapsed)).await;
1383 let next = read_children(gateway, &names).await;
1384 unsettled = current.iter().zip(&next).map(|(a, b)| a != b).collect();
1385 current = next;
1386 if !unsettled.iter().any(|changing| *changing) {
1387 break;
1388 }
1389 }
1390
1391 let gateway_name = result.gateway.as_str();
1392 let root = LedgerRoot::for_node(&loaded.target.nodes.master);
1393 for ((name, read), changing) in names.iter().zip(current).zip(unsettled) {
1394 let live = match read {
1395 Ok(sha256) => Some(sha256),
1396 Err(code) => {
1397 sweep.warnings.push(format!(
1398 "child project {name}: its export could not be read ({code})"
1399 ));
1400 None
1401 }
1402 };
1403 if changing {
1404 sweep.warnings.push(format!(
1405 "child project {name} did not settle within {} s",
1406 opts.children_settle.as_secs()
1407 ));
1408 }
1409 let record = match surfaces.host.read_release_record(gateway_name, name).await {
1410 Ok(None) => None,
1411 Ok(Some(bytes)) => {
1412 let location = release_record_location(root, gateway_name, name);
1413 match ReleaseRecord::parse(&bytes, &location, gateway_name, name) {
1414 Ok(record) => Some(record),
1415 Err(err) => {
1416 sweep.warnings.push(format!(
1417 "child project {name}: its release record could not be read ({})",
1418 err.code()
1419 ));
1420 None
1421 }
1422 }
1423 }
1424 Err(err) => {
1425 sweep.warnings.push(format!(
1426 "child project {name}: its release record could not be read ({})",
1427 err.code()
1428 ));
1429 None
1430 }
1431 };
1432 let status = match (&record, &live) {
1433 (Some(record), Some(live)) if record.artifact_sha256 == *live => ChildStatus::Ok,
1434 (Some(record), Some(_)) => {
1435 sweep.warnings.push(format!(
1436 "child project {name} no longer matches the release this tool last deployed to it ({})",
1437 record.release
1438 ));
1439 ChildStatus::Mismatch
1440 }
1441 _ => ChildStatus::Unverified,
1442 };
1443 events.emit_late(ProjectsEvent::ChildChecked {
1444 name: name.clone(),
1445 status,
1446 });
1447 sweep.children.push(ChildReport {
1448 name: name.clone(),
1449 status,
1450 release: record.as_ref().map(|r| r.release.clone()),
1451 recorded_sha256: record.map(|r| r.artifact_sha256),
1452 live_sha256: live,
1453 });
1454 }
1455 sweep
1456}
1457
1458#[allow(clippy::too_many_arguments)]
1459async fn execute(
1460 plan: &DeployProjectsResult,
1461 loaded: &LoadedProject,
1462 input: &ProjectsInput,
1463 surfaces: &ProjectsSurfaces<'_>,
1464 opts: &ProjectsRunOptions<'_>,
1465 writer: &dyn ReleaseRecordWriter,
1466 run_dir: &RunDir,
1467 run_id: &RunId,
1468 rel: &str,
1469 designers: &DesignersFound,
1470 observer: &dyn ProjectsObserver,
1471) -> Result<DeployProjectsResult, CoreError> {
1472 let project = plan.project.as_str();
1473 let gateway = surfaces.gateway;
1474 let artifact = &loaded.artifact.digest;
1475
1476 run_dir.write_json(
1477 "run.json",
1478 &serde_json::json!({
1479 "run_id": run_id.as_str(),
1480 "started_at": rfc3339_now_utc(),
1481 "env": plan.env,
1482 "gateway": plan.gateway,
1483 "project": plan.project,
1484 "release": plan.release,
1485 "action": plan.action,
1486 "artifact_sha256": plan.artifact_sha256,
1487 "live_sha256": plan.live_sha256,
1488 "allow_downgrade": input.allow_downgrade,
1489 "allow_first_change": input.allow_first_change,
1490 "designers": designers.mode,
1491 "designers_timeout_s": designers.timeout_s,
1492 "designers_seen": sessions_json(&designers.seen),
1493 "run_dir_git": run_dir.git_status().as_record(),
1494 }),
1495 )?;
1496 run_dir.write_json("plan.json", plan)?;
1497 let mut events = RunEvents {
1498 log: EventLog::open(run_dir)?,
1499 observer,
1500 late_failure: false,
1501 };
1502 events.emit(ProjectsEvent::RunStarted {
1503 run_id: run_id.as_str().to_string(),
1504 action: plan.action,
1505 })?;
1506 for open in &designers.polls {
1509 events.record(&ProjectsEvent::DesignersPoll { open: *open })?;
1510 }
1511 if designers.proceeded {
1512 events.emit(ProjectsEvent::DesignersProceed {
1513 sessions: designers.seen.clone(),
1514 })?;
1515 }
1516 if designers.skipped {
1517 events.emit(ProjectsEvent::DesignersSkipped)?;
1518 }
1519
1520 let mut result = plan.clone();
1521 result.run_id = Some(run_id.as_str().to_string());
1522 result.run_dir = Some(rel.to_string());
1523 let record = release_record(plan, &loaded.release.tag, run_id);
1524
1525 if plan.action == ProjectAction::Record {
1526 let zip = gateway.export_bytes(project).await?;
1529 let live = live_digest(project, &zip)?;
1530 if live.sha256 != artifact.sha256 {
1531 return Err(stale_live(artifact.sha256.clone(), live.sha256));
1532 }
1533 events.emit(ProjectsEvent::Verified { equal: true })?;
1534 if let Err(err) = writer
1535 .write_release_record(&plan.gateway, project, &record.to_bytes())
1536 .await
1537 {
1538 events.emit_late(ProjectsEvent::RecordFailed {
1539 code: err.code().to_string(),
1540 });
1541 return Err(err);
1542 }
1543 finish_recorded(&mut result, &mut events);
1547 return Ok(result);
1548 }
1549
1550 let overwrite = plan.action == ProjectAction::Import;
1551 if overwrite {
1552 let pre = gateway.export_bytes(project).await?;
1556 let live = live_digest(project, &pre)?;
1557 let mut file = run_dir.create_private_file_in("export", &format!("{project}.zip"))?;
1558 file.write_all(&pre)
1559 .and_then(|()| file.sync_all())
1560 .map_err(|e| CoreError::DeployRunInvalid {
1561 run_id: run_id.as_str().to_string(),
1562 problem: format!("could not write the pre-image export ({:?})", e.kind()),
1563 })?;
1564 events.emit(ProjectsEvent::PreImageExported { bytes: pre.len() })?;
1565 if plan.live_sha256.as_deref() != Some(live.sha256.as_str()) {
1568 return Err(stale_live(
1569 plan.live_sha256
1570 .clone()
1571 .unwrap_or_else(|| "none".to_string()),
1572 live.sha256,
1573 ));
1574 }
1575 }
1576
1577 events.emit(ProjectsEvent::ImportSent { overwrite })?;
1578 let answer = match gateway
1579 .project_import(project, loaded.artifact.zip.clone(), overwrite)
1580 .await
1581 {
1582 Ok(answer) => Some(answer),
1583 Err(err) if import_outcome_unknown(&err) => {
1588 result.warnings.push(format!(
1589 "the import request failed after it may have reached the gateway ({}); the project was read back to find out what it holds",
1590 err.code()
1591 ));
1592 None
1593 }
1594 Err(err) => return Err(err),
1596 };
1597 if let Some(answer) = &answer {
1600 events.emit_late(ProjectsEvent::ImportAnswered);
1601 if answer
1602 .response
1603 .get("changes")
1604 .and_then(serde_json::Value::as_array)
1605 .is_some_and(Vec::is_empty)
1606 {
1607 result
1608 .warnings
1609 .push("the gateway reported no changes for this import".to_string());
1610 }
1611 }
1612
1613 let equal = match gateway.export_bytes(project).await {
1614 Ok(zip) => match live_digest(project, &zip) {
1615 Ok(live) => Some(live.sha256 == artifact.sha256),
1616 Err(err) => {
1617 result.warnings.push(format!(
1618 "could not verify the import: the project read back is unusable ({})",
1619 err.code()
1620 ));
1621 None
1622 }
1623 },
1624 Err(err) => {
1625 result.warnings.push(format!(
1626 "could not verify the import: the project could not be read back ({})",
1627 err.code()
1628 ));
1629 None
1630 }
1631 };
1632 events.emit_late(ProjectsEvent::Verified {
1633 equal: equal == Some(true),
1634 });
1635
1636 if equal == Some(true) {
1637 match writer
1638 .write_release_record(&plan.gateway, project, &record.to_bytes())
1639 .await
1640 {
1641 Ok(()) => {
1642 events.emit_late(ProjectsEvent::RecordWritten);
1643 result.record_written = true;
1644 }
1645 Err(err) => {
1646 events.emit_late(ProjectsEvent::RecordFailed {
1647 code: err.code().to_string(),
1648 });
1649 result.warnings.push(format!(
1650 "the import is verified but the release record could not be written ({})",
1651 err.code()
1652 ));
1653 }
1654 }
1655 if let Some(parent) = &loaded.meta.parent {
1658 match gateway.projects().await {
1659 Ok(records) => {
1660 if records
1661 .iter()
1662 .find(|r| r.name == project)
1663 .is_some_and(|r| r.invalid_parent == Some(true))
1664 {
1665 result.warnings.push(format!(
1666 "project {project}: after the import the gateway reports that its parent {parent} is invalid"
1667 ));
1668 }
1669 }
1670 Err(err) => result.warnings.push(format!(
1671 "project {project}: the parent could not be read back after the import ({})",
1672 err.code()
1673 )),
1674 }
1675 }
1676 result.outcome = if overwrite {
1677 ProjectOutcome::Imported
1678 } else {
1679 ProjectOutcome::Created
1680 };
1681 } else {
1682 result.outcome = ProjectOutcome::ImportedUnverified;
1685 result.ok = false;
1686 result.slug = Some(PROJECT_UNVERIFIED_SLUG.to_string());
1687 if overwrite {
1691 result.restore_command = Some(restore_command(project, rel));
1692 }
1693 }
1694 if matches!(
1698 result.outcome,
1699 ProjectOutcome::Imported | ProjectOutcome::ImportedUnverified
1700 ) {
1701 let sweep = sweep_children(&result, loaded, surfaces, opts, &mut events).await;
1702 result.children = sweep.children;
1703 result.warnings.extend(sweep.warnings);
1704 }
1705 events.emit_late(ProjectsEvent::Finished {
1706 outcome: result.outcome,
1707 });
1708 note_late_log_failure(&mut result, &events);
1709 Ok(result)
1710}
1711
1712fn import_outcome_unknown(err: &CoreError) -> bool {
1719 match err {
1720 CoreError::Network {
1721 source: Some(source),
1722 ..
1723 } => !(source.is_connect() || source.is_builder()),
1724 CoreError::Network { source: None, .. } | CoreError::Internal(_) => true,
1725 _ => false,
1726 }
1727}
1728
1729fn finish_recorded(result: &mut DeployProjectsResult, events: &mut RunEvents<'_>) {
1733 result.record_written = true;
1734 result.outcome = ProjectOutcome::Recorded;
1735 events.emit_late(ProjectsEvent::RecordWritten);
1736 events.emit_late(ProjectsEvent::Finished {
1737 outcome: result.outcome,
1738 });
1739 note_late_log_failure(result, events);
1740}
1741
1742fn note_late_log_failure(result: &mut DeployProjectsResult, events: &RunEvents<'_>) {
1744 if events.late_failure {
1745 result
1746 .warnings
1747 .push("the run's events log could not be written to the end".to_string());
1748 }
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753 use super::*;
1754
1755 fn sample_zip() -> Vec<u8> {
1756 use std::io::Write as _;
1757 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1758 let options = zip::write::SimpleFileOptions::default()
1759 .compression_method(zip::CompressionMethod::Deflated);
1760 for (name, bytes) in [
1761 ("project.json", br#"{"title":"T"}"#.as_slice()),
1762 ("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
1763 ("ignition/s/a.py", &[b'x'; 4096]),
1764 ] {
1765 writer.start_file(name, options).expect("start");
1766 writer.write_all(bytes).expect("write");
1767 }
1768 writer.finish().expect("finish").into_inner()
1769 }
1770
1771 #[test]
1772 fn check_artifact_zip_refuses_truncated() {
1773 let whole = sample_zip();
1774 check_artifact_zip(&whole).expect("the whole zip passes");
1775 let half = &whole[..whole.len() / 2];
1776 let err = check_artifact_zip(half).expect_err("half a zip");
1777 assert_eq!(err.code(), "invalid_import_file");
1778 let err = check_artifact_zip(&whole[..whole.len() - 40]).expect_err("tail cut");
1780 assert_eq!(err.code(), "invalid_import_file");
1781 let err = check_artifact_zip(b"not a zip").expect_err("no magic");
1782 assert_eq!(err.code(), "invalid_import_file");
1783 }
1784
1785 fn sample_result() -> DeployProjectsResult {
1786 DeployProjectsResult {
1787 env: "prod".to_string(),
1788 gateway: "gw".to_string(),
1789 project: "CapApp".to_string(),
1790 release: "v1.0.0".to_string(),
1791 check: false,
1792 action: ProjectAction::Record,
1793 would_change: true,
1794 artifact_sha256: "a".repeat(64),
1795 live_sha256: Some("a".repeat(64)),
1796 members: MemberDelta::default(),
1797 removed: Vec::new(),
1798 removed_count: 0,
1799 excluded: Vec::new(),
1800 excluded_count: 0,
1801 unmanaged: Vec::new(),
1802 unmanaged_count: 0,
1803 parent: None,
1804 previous_release: None,
1805 recorded_sha256: None,
1806 live_matches_last_deploy: None,
1807 allow_downgrade: false,
1808 allow_first_change: None,
1809 warnings: Vec::new(),
1810 run_id: None,
1811 run_dir: None,
1812 record_written: false,
1813 restore_command: None,
1814 children: Vec::new(),
1815 outcome: ProjectOutcome::Checked,
1816 ok: true,
1817 slug: None,
1818 }
1819 }
1820
1821 #[test]
1825 fn a_log_failure_after_the_record_is_written_is_a_warning() {
1826 let dir = tempfile::tempdir().expect("tempdir");
1827 let path = dir.path().join("events.ndjson");
1828 std::fs::write(&path, b"").expect("seed");
1829 let read_only = std::fs::File::open(&path).expect("open read-only");
1831 let mut events = RunEvents {
1832 log: EventLog::from_file(read_only),
1833 observer: &NoProjectsObserver,
1834 late_failure: false,
1835 };
1836 let mut result = sample_result();
1837
1838 finish_recorded(&mut result, &mut events);
1839
1840 assert!(result.record_written, "the record is on the host");
1841 assert_eq!(result.outcome, ProjectOutcome::Recorded);
1842 assert!(result.ok, "a log failure does not fail the run");
1843 assert!(events.late_failure);
1844 assert!(
1845 result
1846 .warnings
1847 .iter()
1848 .any(|w| w.contains("events log could not be written")),
1849 "{:?}",
1850 result.warnings
1851 );
1852 }
1853
1854 #[test]
1855 fn a_built_artifact_passes_the_check() {
1856 let dir = tempfile::tempdir().expect("tempdir");
1857 for (rel, bytes) in [
1858 ("project.json", br#"{"title":"T"}"#.as_slice()),
1859 ("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
1860 ("ignition/s/a.py", b"pass"),
1861 ] {
1862 let path = dir.path().join(rel);
1863 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1864 std::fs::write(path, bytes).expect("write");
1865 }
1866 let artifact = build_artifact(dir.path()).expect("artifact");
1867 check_artifact_zip(&artifact.zip).expect("a built artifact is a valid import");
1868 }
1869}