use std::io::Write as _;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde::Serialize;
use super::deploy::load_target;
use super::projects::validate_import;
use super::resources::export_zip_bytes;
use super::workspace::rfc3339_now_utc;
use crate::client::GatewayApi;
use crate::client::projects::{ImportOutcome, ProjectRecord};
use crate::client::query::ListQuery;
use crate::deploy::apply::hold::SystemClock;
use crate::deploy::apply::preflight::{
DesignerSeen, DesignersPolicy, DesignersVerdict, designers_detail, designers_gate,
read_designers, wrong_target_failure, wrong_target_gate,
};
use crate::deploy::apply::run_store::{EventLog, RunDir, RunId, run_dir_rel};
use crate::deploy::exclude;
use crate::deploy::ledger::{LedgerRoot, release_record_location};
use crate::deploy::project_artifact::{
Artifact, CanonicalDigest, build_artifact, canonical_digest,
};
use crate::deploy::project_tags::{
FILES_PER_ENTRY, Lookup, MISSING_TAGS_LIMIT, MissingTag, TagRef, TagRefs, TagTree,
TagsUnavailable, UnknownProvider, scan_artifact,
};
use crate::deploy::projects::{
MemberDelta, PROJECT_FILE_REL, ParentLink, ProjectAction, ProjectFile, ProjectMeta, Release,
ReleaseOrder, ReleaseRecord, check_parent, load_project_file, member_delta, parent_chain,
read_project_meta,
};
use crate::deploy::target::{NodeTarget, Target};
use crate::deploy::transport::for_node;
use crate::deploy::transport::projects::{
ProjectHost, ReleaseRecordWriter, git_entry_location, release_writer_for_node,
};
use crate::deploy::transport::remote::safe_remote_path;
use crate::error::{CoreError, PreflightFailure};
pub const LIST_CAP: usize = 200;
pub const CHILDREN_SETTLE_S: u64 = 30;
pub const CHILDREN_POLL_S: u64 = 5;
pub const PROJECTS_CANARY_EXEMPT: &[&str] = &["export"];
#[async_trait]
pub trait ProjectGateway: Send + Sync {
async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError>;
async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError>;
async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError>;
async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError>;
async fn gateway_name(&self) -> Result<Option<String>, CoreError>;
async fn tag_provider_names(&self) -> Result<Vec<String>, CoreError>;
async fn tag_export(&self, provider: &str) -> Result<Vec<u8>, CoreError>;
async fn project_import(
&self,
name: &str,
zip: Vec<u8>,
overwrite: bool,
) -> Result<ImportOutcome, CoreError>;
}
pub struct GatewayProjects<'a>(pub &'a dyn GatewayApi);
#[async_trait]
impl ProjectGateway for GatewayProjects<'_> {
async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError> {
Ok(self.0.projects(&ListQuery::default()).await?.items)
}
async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError> {
match self.0.project_find(name).await {
Ok(record) => Ok(Some(record)),
Err(CoreError::NotFound { .. }) => Ok(None),
Err(err) => Err(err),
}
}
async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError> {
export_zip_bytes(self.0, name).await
}
async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError> {
read_designers(self.0).await
}
async fn gateway_name(&self) -> Result<Option<String>, CoreError> {
Ok(self
.0
.gateway_info()
.await?
.name
.filter(|name| !name.trim().is_empty()))
}
async fn tag_provider_names(&self) -> Result<Vec<String>, CoreError> {
Ok(self
.0
.tag_provider_list(&ListQuery::default())
.await?
.items
.into_iter()
.map(|record| record.name)
.collect())
}
async fn tag_export(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
self.0.tags_export_provider(provider).await
}
async fn project_import(
&self,
name: &str,
zip: Vec<u8>,
overwrite: bool,
) -> Result<ImportOutcome, CoreError> {
self.0.project_import(name, zip, overwrite).await
}
}
pub struct ProjectsSurfaces<'a> {
pub gateway: &'a dyn ProjectGateway,
pub host: &'a dyn ProjectHost,
}
#[derive(Debug, Clone)]
pub struct ProjectsInput {
pub repo: PathBuf,
pub targets: PathBuf,
pub env: String,
pub release: String,
pub allow_downgrade: bool,
pub allow_first_change: Option<String>,
pub artifact_sha256: Option<String>,
}
pub const FIRST_CHANGE_REASON_MAX: usize = 500;
#[derive(Debug)]
pub struct LoadedProject {
pub file: ProjectFile,
pub meta: ProjectMeta,
pub target_file: String,
pub target: Target,
pub artifact: Artifact,
pub release: Release,
}
pub fn load_project(input: &ProjectsInput) -> Result<LoadedProject, CoreError> {
let release = Release::parse(&input.release)?;
if let Some(reason) = &input.allow_first_change {
let chars = reason.chars().count();
if chars == 0 || chars > FIRST_CHANGE_REASON_MAX {
return Err(CoreError::InvalidInput {
reason: format!(
"--allow-first-change <reason>: the reason is 1 to {FIRST_CHANGE_REASON_MAX} characters"
),
});
}
}
if let Some(hash) = &input.artifact_sha256
&& !is_lower_hex64(hash)
{
return Err(CoreError::InvalidInput {
reason: "--artifact-sha256 must be 64 lowercase hex characters (the \
`artifact_sha256` of `ign deploy projects --check --json`)"
.to_string(),
});
}
let file = load_project_file(&input.repo)?;
file.require_env(&input.env)?;
let meta = read_project_meta(&input.repo)?;
let (target_file, target) = load_target(&input.targets, &input.env)?;
record_names_fit_the_host(
&file.project,
&target.gateway,
&target_file,
&target.nodes.master,
)?;
let artifact = build_artifact(&input.repo)?;
check_artifact_zip(&artifact.zip)?;
if let Some(expected) = &input.artifact_sha256
&& *expected != artifact.digest.sha256
{
return Err(CoreError::ProjectHashMismatch {
project: file.project.clone(),
expected: expected.clone(),
actual: artifact.digest.sha256.clone(),
});
}
Ok(LoadedProject {
file,
meta,
target_file,
target,
artifact,
release,
})
}
fn record_names_fit_the_host(
project: &str,
gateway: &str,
target_file: &str,
master: &NodeTarget,
) -> Result<(), CoreError> {
if master.ssh.is_none() {
return Ok(());
}
if let Err(why) = safe_remote_path(project) {
return Err(CoreError::deploy_tree_invalid(
PROJECT_FILE_REL,
None,
None,
format!(
"project: the name cannot be used for the release record on an ssh node ({why})"
),
));
}
if let Err(why) = safe_remote_path(gateway) {
return Err(CoreError::deploy_tree_invalid(
target_file,
None,
None,
format!(
"gateway: the name cannot be used for the release record on an ssh node ({why})"
),
));
}
Ok(())
}
fn is_lower_hex64(text: &str) -> bool {
text.len() == 64
&& text
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub(crate) fn check_artifact_zip(zip: &[u8]) -> Result<(), CoreError> {
validate_import(zip)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectOutcome {
Checked,
Skipped,
Created,
Imported,
Recorded,
ImportedUnverified,
}
impl ProjectOutcome {
pub fn as_str(self) -> &'static str {
match self {
ProjectOutcome::Checked => "checked",
ProjectOutcome::Skipped => "skipped",
ProjectOutcome::Created => "created",
ProjectOutcome::Imported => "imported",
ProjectOutcome::Recorded => "recorded",
ProjectOutcome::ImportedUnverified => "imported_unverified",
}
}
}
pub const PROJECT_UNVERIFIED_SLUG: &str = "deploy_project_unverified";
#[derive(Debug, Clone, Serialize)]
pub struct DeployProjectsResult {
pub env: String,
pub gateway: String,
pub project: String,
pub release: String,
pub check: bool,
pub action: ProjectAction,
pub would_change: bool,
pub artifact_sha256: String,
pub live_sha256: Option<String>,
pub members: MemberDelta,
pub removed: Vec<String>,
pub removed_count: usize,
pub excluded: Vec<String>,
pub excluded_count: usize,
pub unmanaged: Vec<String>,
pub unmanaged_count: usize,
pub parent: Option<String>,
pub previous_release: Option<String>,
pub recorded_sha256: Option<String>,
pub live_matches_last_deploy: Option<bool>,
pub allow_downgrade: bool,
pub allow_first_change: Option<String>,
pub warnings: Vec<String>,
pub run_id: Option<String>,
pub run_dir: Option<String>,
pub record_written: bool,
pub restore_command: Option<String>,
pub children: Vec<ChildReport>,
pub missing_tags: Option<Vec<MissingTag>>,
pub missing_tags_count: Option<usize>,
pub missing_tags_unknown_providers: Option<Vec<UnknownProvider>>,
pub missing_tags_unknown_providers_count: Option<usize>,
pub missing_tags_unavailable: Option<Vec<TagsUnavailable>>,
pub missing_tags_limit: &'static str,
pub outcome: ProjectOutcome,
pub ok: bool,
pub slug: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChildStatus {
Ok,
Mismatch,
Unverified,
}
impl ChildStatus {
pub fn as_str(self) -> &'static str {
match self {
ChildStatus::Ok => "ok",
ChildStatus::Mismatch => "mismatch",
ChildStatus::Unverified => "unverified",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChildReport {
pub name: String,
pub status: ChildStatus,
pub release: Option<String>,
pub recorded_sha256: Option<String>,
pub live_sha256: Option<String>,
}
impl DeployProjectsResult {
pub fn failure_exit_code(&self) -> Option<u8> {
(self.outcome == ProjectOutcome::ImportedUnverified).then_some(6)
}
}
fn shell_word(text: &str) -> String {
let plain = !text.is_empty()
&& text.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '+' | ',')
});
if plain {
text.to_string()
} else {
format!("'{}'", text.replace('\'', "'\\''"))
}
}
fn restore_command(project: &str, run_dir_rel: &str) -> String {
format!(
"ign project import {} --file {} --collision-policy overwrite --yes",
shell_word(project),
shell_word(&format!("{run_dir_rel}/export/{project}.zip")),
)
}
fn capped(mut items: Vec<String>) -> (Vec<String>, usize) {
let count = items.len();
items.truncate(LIST_CAP);
(items, count)
}
fn cap_list<T>(mut items: Vec<T>) -> (Vec<T>, usize) {
let count = items.len();
items.truncate(LIST_CAP);
(items, count)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TagCheck {
pub missing: Option<Vec<MissingTag>>,
pub unknown_providers: Option<Vec<UnknownProvider>>,
pub unavailable: Vec<TagsUnavailable>,
}
fn unavailable_reason(what: &str, err: &CoreError) -> String {
format!("{what} ({})", err.code())
}
const UNRESOLVED_PATHS_SHOWN: usize = 3;
fn unresolved_reason(paths: &std::collections::BTreeSet<String>) -> String {
let shown: Vec<&str> = paths
.iter()
.take(UNRESOLVED_PATHS_SHOWN)
.map(String::as_str)
.collect();
let rest = paths.len() - shown.len();
let mut reason = format!(
"the provider's tag export does not resolve the UDT type of {} tag path(s): {}",
paths.len(),
shown.join(", ")
);
if rest > 0 {
reason.push_str(&format!(" and {rest} more"));
}
reason
}
fn strip_property(path: &str) -> Option<&str> {
let start = path.rfind('/').map_or(0, |at| at + 1);
let dot = path[start..].find('.')?;
(dot > 0).then(|| &path[..start + dot])
}
fn provider_shaped(name: &str) -> bool {
name.chars().any(char::is_alphabetic)
&& name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | ' '))
&& !name.starts_with(' ')
&& !name.ends_with(' ')
&& !name.eq_ignore_ascii_case("client")
}
struct UnknownAcc {
provider: String,
paths: std::collections::BTreeSet<String>,
files: std::collections::BTreeSet<String>,
}
struct MissingAcc {
path: String,
files: std::collections::BTreeSet<String>,
}
pub async fn check_tag_references(
zip: &[u8],
excluded: &[String],
gateway: &dyn ProjectGateway,
) -> TagCheck {
let refs = match scan_artifact(zip) {
Ok(refs) => refs,
Err(err) => {
return TagCheck {
missing: None,
unknown_providers: None,
unavailable: vec![TagsUnavailable {
provider: None,
reason: unavailable_reason("the project files could not be scanned", &err),
}],
};
}
};
let mut check = check_refs(&refs, excluded, gateway).await;
if let Some(reason) = refs.cut_reason() {
check.unavailable.insert(
0,
TagsUnavailable {
provider: None,
reason,
},
);
check.missing = check.missing.filter(|list| !list.is_empty());
check.unknown_providers = check.unknown_providers.filter(|list| !list.is_empty());
}
check
}
async fn check_refs(refs: &TagRefs, excluded: &[String], gateway: &dyn ProjectGateway) -> TagCheck {
let mut by_provider: std::collections::BTreeMap<String, Vec<&TagRef>> =
std::collections::BTreeMap::new();
for tag in refs.iter() {
let fold = tag.provider.to_ascii_lowercase();
if exclude::is_excluded(&fold, excluded) {
continue;
}
by_provider.entry(fold).or_default().push(tag);
}
if by_provider.is_empty() {
return TagCheck {
missing: Some(Vec::new()),
unknown_providers: Some(Vec::new()),
unavailable: Vec::new(),
};
}
let names = match gateway.tag_provider_names().await {
Ok(names) => names,
Err(err) => {
return TagCheck {
missing: None,
unknown_providers: None,
unavailable: vec![TagsUnavailable {
provider: None,
reason: unavailable_reason(
"the gateway's tag providers could not be listed",
&err,
),
}],
};
}
};
let mut listed: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for name in names {
let fold = name.to_ascii_lowercase();
match listed.get(&fold) {
Some(kept) if kept.as_bytes() <= name.as_bytes() => {}
_ => {
listed.insert(fold, name);
}
}
}
let mut found: std::collections::BTreeMap<(String, String), MissingAcc> =
std::collections::BTreeMap::new();
let mut unknown: std::collections::BTreeMap<String, UnknownAcc> =
std::collections::BTreeMap::new();
let mut unavailable = Vec::new();
let (mut needed, mut checked) = (0usize, 0usize);
for (fold, tags) in &by_provider {
let Some(provider) = listed.get(fold) else {
if provider_shaped(fold) {
let acc = unknown.entry(fold.clone()).or_insert_with(|| UnknownAcc {
provider: tags[0].provider.clone(),
paths: std::collections::BTreeSet::new(),
files: std::collections::BTreeSet::new(),
});
for tag in tags {
if tag.provider < acc.provider {
acc.provider = tag.provider.clone();
}
let path = strip_property(&tag.path).unwrap_or(&tag.path);
acc.paths.insert(path.to_lowercase());
acc.files.extend(tag.files.iter().cloned());
while acc.files.len() > FILES_PER_ENTRY {
acc.files.pop_last();
}
}
}
continue;
};
needed += 1;
let bytes = match gateway.tag_export(provider).await {
Ok(bytes) => bytes,
Err(err) => {
unavailable.push(TagsUnavailable {
provider: Some(provider.clone()),
reason: unavailable_reason("the provider's tag export could not be read", &err),
});
continue;
}
};
let tree = match TagTree::parse(&bytes, provider) {
Ok(tree) => tree,
Err(err) => {
unavailable.push(TagsUnavailable {
provider: Some(provider.clone()),
reason: unavailable_reason("the provider's tag export is not a tag tree", &err),
});
continue;
}
};
drop(bytes);
let mut unresolved: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for tag in tags {
let full = tree.lookup(&tag.path);
let answer = match (full, strip_property(&tag.path)) {
(Lookup::Found, _) => Lookup::Found,
(other, None) => other,
(full, Some(stripped)) => match (full, tree.lookup(stripped)) {
(_, Lookup::Found) => Lookup::Found,
(Lookup::Missing, Lookup::Missing) => Lookup::Missing,
_ => Lookup::Unknown,
},
};
let reported = match answer {
Lookup::Found => {
checked += 1;
continue;
}
Lookup::Unknown => {
unresolved.insert(format!("[{}]{}", tag.provider, tag.path));
continue;
}
Lookup::Missing => {
checked += 1;
strip_property(&tag.path).unwrap_or(&tag.path)
}
};
let display = format!("[{}]{}", tag.provider, reported);
let acc = found
.entry((fold.clone(), reported.to_lowercase()))
.or_insert_with(|| MissingAcc {
path: display.clone(),
files: std::collections::BTreeSet::new(),
});
if display < acc.path {
acc.path = display;
}
acc.files.extend(tag.files.iter().cloned());
while acc.files.len() > FILES_PER_ENTRY {
acc.files.pop_last();
}
}
if !unresolved.is_empty() {
unavailable.push(TagsUnavailable {
provider: Some(provider.clone()),
reason: unresolved_reason(&unresolved),
});
}
}
let missing = if needed > 0 && checked == 0 {
None
} else {
let mut list: Vec<MissingTag> = found
.into_values()
.map(|acc| MissingTag {
path: acc.path,
files: acc.files.into_iter().collect(),
})
.collect();
list.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
Some(list)
};
let mut unknown_providers: Vec<UnknownProvider> = unknown
.into_values()
.map(|acc| UnknownProvider {
provider: acc.provider,
path_count: acc.paths.len(),
files: acc.files.into_iter().collect(),
})
.collect();
unknown_providers.sort_by(|a, b| a.provider.as_bytes().cmp(b.provider.as_bytes()));
TagCheck {
missing,
unknown_providers: Some(unknown_providers),
unavailable,
}
}
const WARNING_PROVIDERS_SHOWN: usize = 5;
fn tag_warnings(project: &str, gateway: &str, check: &TagCheck) -> Vec<String> {
let mut warnings = Vec::new();
let missing = check.missing.as_ref().map_or(0, Vec::len);
if missing > 0 {
warnings.push(format!(
"project {project}: {missing} tag path(s) the project uses are not on gateway {gateway}; \
ign deploy projects does not deploy tags, and paths built at runtime aren't checked"
));
}
if let Some(unknown) = check.unknown_providers.as_ref().filter(|u| !u.is_empty()) {
let shown: Vec<&str> = unknown
.iter()
.take(WARNING_PROVIDERS_SHOWN)
.map(|u| u.provider.as_str())
.collect();
let rest = unknown.len().saturating_sub(WARNING_PROVIDERS_SHOWN);
warnings.push(format!(
"project {project}: tag paths the project uses name {} provider(s) that gateway {gateway} does not have: {}{}; \
ign deploy projects does not deploy tags, and paths built at runtime aren't checked",
unknown.len(),
shown.join(", "),
if rest > 0 {
format!(" and {rest} more")
} else {
String::new()
}
));
}
for entry in &check.unavailable {
warnings.push(format!(
"project {project}: tag paths were not checked for {}: {}",
entry.provider.as_deref().unwrap_or("every provider"),
entry.reason
));
}
warnings
}
fn parent_link(record: &ProjectRecord) -> ParentLink {
ParentLink {
name: record.name.clone(),
parent: record.parent.clone().filter(|p| !p.is_empty()),
inheritable: record.inheritable,
invalid_parent: record.invalid_parent,
}
}
fn live_digest(project: &str, zip: &[u8]) -> Result<CanonicalDigest, CoreError> {
canonical_digest(zip).map_err(|err| match err {
CoreError::DeployTreeInvalid(_) => CoreError::Internal(format!(
"the gateway's export of project {project} holds no members"
)),
other => other,
})
}
#[derive(Debug, Clone)]
pub enum ProjectsPrepared {
Ready {
prose: String,
plan: DeployProjectsResult,
},
Unchanged(DeployProjectsResult),
}
impl ProjectsPrepared {
pub fn into_check_result(self) -> DeployProjectsResult {
let (ProjectsPrepared::Ready {
plan: mut result, ..
}
| ProjectsPrepared::Unchanged(mut result)) = self;
result.check = true;
result.outcome = ProjectOutcome::Checked;
result
}
}
fn plan_prose(plan: &DeployProjectsResult) -> String {
let action = match plan.action {
ProjectAction::Create => "create project",
ProjectAction::Import => "overwrite-import project",
ProjectAction::Record => "record the release only for project",
ProjectAction::Skip => "leave project",
};
let mut lines = vec![format!(
"deploy projects will {action} {} on gateway {} (env {}, release {})",
plan.project, plan.gateway, plan.env, plan.release
)];
if plan.action == ProjectAction::Record {
lines.push("the project itself is not changed".to_string());
} else {
lines.push(format!(
"members: {} added, {} changed, {} removed, {} same",
plan.members.added, plan.members.changed, plan.members.removed, plan.members.same
));
}
if plan.removed_count > 0 {
lines.push(format!(
"the import deletes {} member(s) the repo does not have:",
plan.removed_count
));
for path in plan.removed.iter().take(PROSE_REMOVED_SHOWN) {
lines.push(format!(" {path}"));
}
if plan.removed_count > PROSE_REMOVED_SHOWN {
lines.push(format!(
" ... and {} more",
plan.removed_count - PROSE_REMOVED_SHOWN
));
}
}
let missing = plan.missing_tags_count.unwrap_or(0);
if missing > 0 {
lines.push(format!(
"{missing} tag path(s) the project uses are not on gateway {} (this verb does not deploy tags):",
plan.gateway
));
for tag in plan.missing_tags.iter().flatten().take(PROSE_REMOVED_SHOWN) {
lines.push(format!(" {}", tag.path));
}
if missing > PROSE_REMOVED_SHOWN {
lines.push(format!(" ... and {} more", missing - PROSE_REMOVED_SHOWN));
}
}
let unknown = plan.missing_tags_unknown_providers_count.unwrap_or(0);
if unknown > 0 {
lines.push(format!(
"{unknown} provider(s) the project uses are not on gateway {}:",
plan.gateway
));
for entry in plan
.missing_tags_unknown_providers
.iter()
.flatten()
.take(PROSE_REMOVED_SHOWN)
{
lines.push(format!(" {}", entry.provider));
}
if unknown > PROSE_REMOVED_SHOWN {
lines.push(format!(" ... and {} more", unknown - PROSE_REMOVED_SHOWN));
}
}
for entry in plan.missing_tags_unavailable.iter().flatten() {
lines.push(format!(
"tags NOT CHECKED for {}: {}",
entry.provider.as_deref().unwrap_or("every provider"),
entry.reason
));
}
if plan.action == ProjectAction::Import {
lines.push("the live project is saved into a run directory before the import".to_string());
}
lines.push(format!("artifact_sha256 {}", plan.artifact_sha256));
lines.join("\n")
}
const PROSE_REMOVED_SHOWN: usize = 20;
pub async fn deploy_projects_prepare(
loaded: &LoadedProject,
input: &ProjectsInput,
surfaces: &ProjectsSurfaces<'_>,
) -> Result<ProjectsPrepared, CoreError> {
let project = &loaded.file.project;
let gateway = surfaces.gateway;
let gateway_name = &loaded.target.gateway;
let git = surfaces.host.git_entry(project).await?;
if git.is_present() {
return Err(CoreError::ProjectGitManaged {
project: project.clone(),
location: git_entry_location(project),
kind: git.as_str().to_string(),
});
}
let record = match surfaces
.host
.read_release_record(gateway_name, project)
.await?
{
None => None,
Some(bytes) => {
let location = release_record_location(
LedgerRoot::for_node(&loaded.target.nodes.master),
gateway_name,
project,
);
Some(ReleaseRecord::parse(
&bytes,
&location,
gateway_name,
project,
)?)
}
};
if let Some(record) = &record
&& loaded.release.order_against(&record.release()?) == ReleaseOrder::Older
&& !input.allow_downgrade
{
return Err(CoreError::DestructiveChangeRefused {
gate: "downgrade".to_string(),
keys: vec![project.clone()],
count: 1,
});
}
let records = gateway.projects().await?;
let mut warnings = Vec::new();
if let Some(parent) = &loaded.meta.parent {
let all: Vec<ParentLink> = records.iter().map(parent_link).collect();
let (chain, bound_hit) = parent_chain(parent, &all);
check_parent(project, parent, &chain, bound_hit).map_err(|reason| {
CoreError::ProjectParentInvalid {
project: project.clone(),
parent: parent.clone(),
reason: reason.as_str().to_string(),
}
})?;
if chain
.first()
.is_some_and(|p| p.invalid_parent == Some(true))
{
warnings.push(format!(
"project {project}: the gateway reports that parent {parent} has an invalid parent of its own"
));
}
}
let repo_parent = loaded.meta.parent.as_deref().filter(|p| !p.is_empty());
if let Some(live_record) = records.iter().find(|r| r.name == *project) {
let live_parent = live_record.parent.as_deref().filter(|p| !p.is_empty());
if live_parent != repo_parent {
warnings.push(format!(
"project {project}: importing this project changes its parent from {} to {}",
live_parent.unwrap_or("none"),
repo_parent.unwrap_or("none")
));
}
}
let mut others: Vec<String> = records
.into_iter()
.map(|r| r.name)
.filter(|name| name != project)
.collect();
others.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
others.dedup();
let live = match gateway.project_find(project).await? {
None => None,
Some(_) => {
let zip = gateway.export_bytes(project).await?;
Some(live_digest(project, &zip)?)
}
};
let artifact = &loaded.artifact.digest;
let equal = live.as_ref().is_some_and(|live| {
!live.members.is_empty() && !artifact.members.is_empty() && live.sha256 == artifact.sha256
});
let action = match &live {
None => ProjectAction::Create,
Some(_) if equal => match &record {
Some(record) if record.release()?.version == loaded.release.version => {
ProjectAction::Skip
}
_ => ProjectAction::Record,
},
Some(_) => ProjectAction::Import,
};
if action == ProjectAction::Import && record.is_none() && input.allow_first_change.is_none() {
return Err(CoreError::DestructiveChangeRefused {
gate: "first_deploy".to_string(),
keys: vec![project.clone()],
count: 1,
});
}
let tag_check = check_tag_references(
&loaded.artifact.zip,
&exclude::effective(&loaded.target, None),
gateway,
)
.await;
let live_matches_last_deploy = match (&record, &live) {
(Some(record), Some(live)) => Some(record.artifact_sha256 == live.sha256),
_ => None,
};
if let (Some(record), Some(false)) = (&record, live_matches_last_deploy) {
warnings.push(format!(
"project {project}: the live project no longer matches the last release this tool deployed ({})",
record.release
));
}
warnings.extend(tag_warnings(project, gateway_name, &tag_check));
let (members, removed) = member_delta(artifact, live.as_ref());
let (removed, removed_count) = capped(removed);
let (excluded, excluded_count) = capped(loaded.artifact.excluded.clone());
let (unmanaged, unmanaged_count) = capped(others);
let (missing_tags, missing_tags_count) = match tag_check.missing {
Some(list) => {
let (list, count) = cap_list(list);
(Some(list), Some(count))
}
None => (None, None),
};
let (missing_tags_unknown_providers, missing_tags_unknown_providers_count) =
match tag_check.unknown_providers {
Some(list) => {
let (list, count) = cap_list(list);
(Some(list), Some(count))
}
None => (None, None),
};
let missing_tags_unavailable =
(!tag_check.unavailable.is_empty()).then_some(tag_check.unavailable);
let plan = DeployProjectsResult {
env: input.env.clone(),
gateway: loaded.target.gateway.clone(),
project: project.clone(),
release: input.release.clone(),
check: false,
action,
would_change: action.would_change(),
artifact_sha256: artifact.sha256.clone(),
live_sha256: live.map(|l| l.sha256),
members,
removed,
removed_count,
excluded,
excluded_count,
unmanaged,
unmanaged_count,
parent: loaded.meta.parent.clone(),
previous_release: record.as_ref().map(|r| r.release.clone()),
recorded_sha256: record.map(|r| r.artifact_sha256),
live_matches_last_deploy,
allow_downgrade: input.allow_downgrade,
allow_first_change: input.allow_first_change.clone(),
warnings,
run_id: None,
run_dir: None,
record_written: false,
restore_command: None,
children: Vec::new(),
missing_tags,
missing_tags_count,
missing_tags_unknown_providers,
missing_tags_unknown_providers_count,
missing_tags_unavailable,
missing_tags_limit: MISSING_TAGS_LIMIT,
outcome: match action {
ProjectAction::Create => ProjectOutcome::Created,
ProjectAction::Skip => ProjectOutcome::Skipped,
ProjectAction::Import => ProjectOutcome::Imported,
ProjectAction::Record => ProjectOutcome::Recorded,
},
ok: true,
slug: None,
};
Ok(if action == ProjectAction::Skip {
ProjectsPrepared::Unchanged(plan)
} else {
ProjectsPrepared::Ready {
prose: plan_prose(&plan),
plan,
}
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectsEvent {
DesignersPoll {
open: usize,
},
DesignersProceed {
sessions: Vec<DesignerSeen>,
},
DesignersSkipped,
RunStarted {
run_id: String,
action: ProjectAction,
},
PreImageExported {
bytes: usize,
},
ImportSent {
overwrite: bool,
},
ImportAnswered,
Verified {
equal: bool,
},
RecordWritten,
RecordFailed {
code: String,
},
ChildrenSettling {
children: Vec<String>,
},
ChildChecked {
name: String,
status: ChildStatus,
},
Finished {
outcome: ProjectOutcome,
},
}
impl ProjectsEvent {
pub fn name(&self) -> &'static str {
match self {
ProjectsEvent::DesignersPoll { .. } => "designers_poll",
ProjectsEvent::DesignersProceed { .. } => "designers_proceed",
ProjectsEvent::DesignersSkipped => "designers_skipped",
ProjectsEvent::RunStarted { .. } => "run_started",
ProjectsEvent::PreImageExported { .. } => "pre_image_exported",
ProjectsEvent::ImportSent { .. } => "import_sent",
ProjectsEvent::ImportAnswered => "import_answered",
ProjectsEvent::Verified { .. } => "verified",
ProjectsEvent::RecordWritten => "record_written",
ProjectsEvent::RecordFailed { .. } => "record_failed",
ProjectsEvent::ChildrenSettling { .. } => "children_settling",
ProjectsEvent::ChildChecked { .. } => "child_checked",
ProjectsEvent::Finished { .. } => "finished",
}
}
fn fields(&self) -> serde_json::Value {
match self {
ProjectsEvent::DesignersPoll { open } => serde_json::json!({ "open": open }),
ProjectsEvent::DesignersProceed { sessions } => {
serde_json::json!({ "sessions": sessions_json(sessions) })
}
ProjectsEvent::DesignersSkipped => serde_json::json!({ "reason": "record only" }),
ProjectsEvent::RunStarted { run_id, action } => {
serde_json::json!({ "run_id": run_id, "action": action })
}
ProjectsEvent::PreImageExported { bytes } => serde_json::json!({ "bytes": bytes }),
ProjectsEvent::ImportSent { overwrite } => {
serde_json::json!({ "overwrite": overwrite })
}
ProjectsEvent::Verified { equal } => serde_json::json!({ "equal": equal }),
ProjectsEvent::RecordFailed { code } => serde_json::json!({ "code": code }),
ProjectsEvent::ChildrenSettling { children } => {
serde_json::json!({ "children": children })
}
ProjectsEvent::ChildChecked { name, status } => {
serde_json::json!({ "name": name, "status": status.as_str() })
}
ProjectsEvent::Finished { outcome } => {
serde_json::json!({ "outcome": outcome.as_str() })
}
ProjectsEvent::ImportAnswered | ProjectsEvent::RecordWritten => serde_json::Value::Null,
}
}
}
pub trait ProjectsObserver: Send + Sync {
fn event(&self, event: &ProjectsEvent);
}
pub struct NoProjectsObserver;
impl ProjectsObserver for NoProjectsObserver {
fn event(&self, _event: &ProjectsEvent) {}
}
pub struct ProjectsRunOptions<'a> {
pub repo: PathBuf,
pub targets_root: PathBuf,
pub target_file: String,
pub master: NodeTarget,
#[doc(hidden)]
pub record_writer: Option<&'a dyn ReleaseRecordWriter>,
pub designers: DesignersPolicy,
pub children_settle: Duration,
pub children_poll: Duration,
}
struct RunEvents<'a> {
log: EventLog,
observer: &'a dyn ProjectsObserver,
late_failure: bool,
}
impl RunEvents<'_> {
fn emit(&mut self, event: ProjectsEvent) -> Result<(), CoreError> {
self.observer.event(&event);
self.log.append(event.name(), event.fields())
}
fn record(&mut self, event: &ProjectsEvent) -> Result<(), CoreError> {
self.log.append(event.name(), event.fields())
}
fn emit_late(&mut self, event: ProjectsEvent) {
if self.emit(event).is_err() {
self.late_failure = true;
}
}
}
fn sessions_json(sessions: &[DesignerSeen]) -> Vec<serde_json::Value> {
sessions
.iter()
.map(|(user, project, host)| {
serde_json::json!({ "user": user, "project": project, "host": host })
})
.collect()
}
struct DesignersFound {
mode: &'static str,
timeout_s: Option<u64>,
polls: Vec<usize>,
seen: Vec<DesignerSeen>,
proceeded: bool,
skipped: bool,
}
async fn designers_check(
plan: &DeployProjectsResult,
gateway: &dyn ProjectGateway,
policy: &DesignersPolicy,
observer: &dyn ProjectsObserver,
) -> Result<DesignersFound, CoreError> {
if plan.action == ProjectAction::Record {
return Ok(DesignersFound {
mode: "skipped",
timeout_s: None,
polls: Vec::new(),
seen: Vec::new(),
proceeded: false,
skipped: true,
});
}
let mut polls = Vec::new();
let (verdict, seen) =
designers_gate(|| gateway.designers(), policy, &SystemClock, &mut |open| {
polls.push(open);
observer.event(&ProjectsEvent::DesignersPoll { open });
Ok(())
})
.await?;
if verdict == DesignersVerdict::Open {
return Err(CoreError::DeployPreflightFailed {
failures: Box::new([PreflightFailure {
check: "designers".to_string(),
detail: designers_detail(&seen),
}]),
});
}
Ok(DesignersFound {
mode: policy.mode.as_str(),
timeout_s: Some(policy.timeout.as_secs()),
polls,
seen,
proceeded: verdict == DesignersVerdict::Proceeded,
skipped: false,
})
}
async fn wrong_target_check(
loaded: &LoadedProject,
gateway: &dyn ProjectGateway,
opts: &ProjectsRunOptions<'_>,
) -> Result<(), CoreError> {
let rest = gateway.gateway_name().await?;
let reader = for_node(
&opts.targets_root,
&opts.target_file,
"nodes.master",
&opts.master,
)?;
let problems = wrong_target_gate(
rest.as_deref(),
reader.as_ref(),
&loaded.target.mode,
opts.master.gateway_name.as_deref(),
)
.await?;
if problems.is_empty() {
return Ok(());
}
Err(CoreError::DeployPreflightFailed {
failures: Box::new([wrong_target_failure(&problems)]),
})
}
fn release_record(plan: &DeployProjectsResult, release: &str, run_id: &RunId) -> ReleaseRecord {
ReleaseRecord {
schema_version: 1,
gateway: plan.gateway.clone(),
project: plan.project.clone(),
release: release.to_string(),
artifact_sha256: plan.artifact_sha256.clone(),
run_id: run_id.as_str().to_string(),
deployed_at: rfc3339_now_utc(),
}
}
fn stale_live(expected: String, actual: String) -> CoreError {
CoreError::PlanStale {
expected,
actual,
detail: "live project changed since the plan".to_string(),
}
}
pub async fn deploy_projects_run(
prepared: ProjectsPrepared,
loaded: &LoadedProject,
input: &ProjectsInput,
surfaces: &ProjectsSurfaces<'_>,
opts: &ProjectsRunOptions<'_>,
observer: &dyn ProjectsObserver,
) -> Result<DeployProjectsResult, CoreError> {
let plan = match prepared {
ProjectsPrepared::Unchanged(result) => return Ok(result),
ProjectsPrepared::Ready { plan, .. } => plan,
};
let built;
let writer: &dyn ReleaseRecordWriter = match opts.record_writer {
Some(writer) => writer,
None => {
built = release_writer_for_node(
&opts.targets_root,
&opts.target_file,
"nodes.master",
&opts.master,
)?;
built.as_ref()
}
};
wrong_target_check(loaded, surfaces.gateway, opts).await?;
let designers = designers_check(&plan, surfaces.gateway, &opts.designers, observer).await?;
let (run_id, run_dir) = RunDir::create_fresh(&opts.repo, &plan.gateway)?;
let rel = run_dir_rel(&plan.gateway, &run_id);
let outcome = execute(
&plan, loaded, input, surfaces, opts, writer, &run_dir, &run_id, &rel, &designers, observer,
)
.await;
match outcome {
Ok(mut result) => {
if let Err(err) = run_dir.write_json("result.json", &result) {
result.warnings.push(format!(
"the run's result.json could not be written ({})",
err.code()
));
}
Ok(result)
}
Err(err) => {
let _ = run_dir.write_json(
"result.json",
&serde_json::json!({
"ok": false,
"slug": err.code(),
"run_id": run_id.as_str(),
}),
);
Err(err)
}
}
}
struct ChildSweep {
children: Vec<ChildReport>,
warnings: Vec<String>,
}
type ChildRead = Result<String, String>;
async fn read_children(gateway: &dyn ProjectGateway, names: &[String]) -> Vec<ChildRead> {
let mut reads = Vec::with_capacity(names.len());
for name in names {
reads.push(match gateway.export_bytes(name).await {
Ok(zip) => live_digest(name, &zip)
.map(|digest| digest.sha256)
.map_err(|err| err.code().to_string()),
Err(err) => Err(err.code().to_string()),
});
}
reads
}
async fn sweep_children(
result: &DeployProjectsResult,
loaded: &LoadedProject,
surfaces: &ProjectsSurfaces<'_>,
opts: &ProjectsRunOptions<'_>,
events: &mut RunEvents<'_>,
) -> ChildSweep {
let project = result.project.as_str();
let gateway = surfaces.gateway;
let mut sweep = ChildSweep {
children: Vec::new(),
warnings: Vec::new(),
};
let records = match gateway.projects().await {
Ok(records) => records,
Err(err) => {
sweep.warnings.push(format!(
"could not list the projects to check the children of {project} ({})",
err.code()
));
return sweep;
}
};
let mut names: Vec<String> = records
.into_iter()
.filter(|record| record.parent.as_deref() == Some(project))
.map(|record| record.name)
.collect();
names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
names.dedup();
if names.is_empty() {
return sweep;
}
events.emit_late(ProjectsEvent::ChildrenSettling {
children: names.clone(),
});
let started = Instant::now();
let mut current = read_children(gateway, &names).await;
let mut unsettled = vec![true; names.len()];
loop {
let elapsed = started.elapsed();
if elapsed >= opts.children_settle {
break;
}
tokio::time::sleep(opts.children_poll.min(opts.children_settle - elapsed)).await;
let next = read_children(gateway, &names).await;
unsettled = current.iter().zip(&next).map(|(a, b)| a != b).collect();
current = next;
if !unsettled.iter().any(|changing| *changing) {
break;
}
}
let gateway_name = result.gateway.as_str();
let root = LedgerRoot::for_node(&loaded.target.nodes.master);
for ((name, read), changing) in names.iter().zip(current).zip(unsettled) {
let live = match read {
Ok(sha256) => Some(sha256),
Err(code) => {
sweep.warnings.push(format!(
"child project {name}: its export could not be read ({code})"
));
None
}
};
if changing {
sweep.warnings.push(format!(
"child project {name} did not settle within {} s",
opts.children_settle.as_secs()
));
}
let record = match surfaces.host.read_release_record(gateway_name, name).await {
Ok(None) => None,
Ok(Some(bytes)) => {
let location = release_record_location(root, gateway_name, name);
match ReleaseRecord::parse(&bytes, &location, gateway_name, name) {
Ok(record) => Some(record),
Err(err) => {
sweep.warnings.push(format!(
"child project {name}: its release record could not be read ({})",
err.code()
));
None
}
}
}
Err(err) => {
sweep.warnings.push(format!(
"child project {name}: its release record could not be read ({})",
err.code()
));
None
}
};
let status = match (&record, &live) {
(Some(record), Some(live)) if record.artifact_sha256 == *live => ChildStatus::Ok,
(Some(record), Some(_)) => {
sweep.warnings.push(format!(
"child project {name} no longer matches the release this tool last deployed to it ({})",
record.release
));
ChildStatus::Mismatch
}
_ => ChildStatus::Unverified,
};
events.emit_late(ProjectsEvent::ChildChecked {
name: name.clone(),
status,
});
sweep.children.push(ChildReport {
name: name.clone(),
status,
release: record.as_ref().map(|r| r.release.clone()),
recorded_sha256: record.map(|r| r.artifact_sha256),
live_sha256: live,
});
}
sweep
}
#[allow(clippy::too_many_arguments)]
async fn execute(
plan: &DeployProjectsResult,
loaded: &LoadedProject,
input: &ProjectsInput,
surfaces: &ProjectsSurfaces<'_>,
opts: &ProjectsRunOptions<'_>,
writer: &dyn ReleaseRecordWriter,
run_dir: &RunDir,
run_id: &RunId,
rel: &str,
designers: &DesignersFound,
observer: &dyn ProjectsObserver,
) -> Result<DeployProjectsResult, CoreError> {
let project = plan.project.as_str();
let gateway = surfaces.gateway;
let artifact = &loaded.artifact.digest;
run_dir.write_json(
"run.json",
&serde_json::json!({
"run_id": run_id.as_str(),
"started_at": rfc3339_now_utc(),
"env": plan.env,
"gateway": plan.gateway,
"project": plan.project,
"release": plan.release,
"action": plan.action,
"artifact_sha256": plan.artifact_sha256,
"live_sha256": plan.live_sha256,
"allow_downgrade": input.allow_downgrade,
"allow_first_change": input.allow_first_change,
"designers": designers.mode,
"designers_timeout_s": designers.timeout_s,
"designers_seen": sessions_json(&designers.seen),
"run_dir_git": run_dir.git_status().as_record(),
}),
)?;
run_dir.write_json("plan.json", plan)?;
let mut events = RunEvents {
log: EventLog::open(run_dir)?,
observer,
late_failure: false,
};
events.emit(ProjectsEvent::RunStarted {
run_id: run_id.as_str().to_string(),
action: plan.action,
})?;
for open in &designers.polls {
events.record(&ProjectsEvent::DesignersPoll { open: *open })?;
}
if designers.proceeded {
events.emit(ProjectsEvent::DesignersProceed {
sessions: designers.seen.clone(),
})?;
}
if designers.skipped {
events.emit(ProjectsEvent::DesignersSkipped)?;
}
let mut result = plan.clone();
result.run_id = Some(run_id.as_str().to_string());
result.run_dir = Some(rel.to_string());
let record = release_record(plan, &loaded.release.tag, run_id);
if plan.action == ProjectAction::Record {
let zip = gateway.export_bytes(project).await?;
let live = live_digest(project, &zip)?;
if live.sha256 != artifact.sha256 {
return Err(stale_live(artifact.sha256.clone(), live.sha256));
}
events.emit(ProjectsEvent::Verified { equal: true })?;
if let Err(err) = writer
.write_release_record(&plan.gateway, project, &record.to_bytes())
.await
{
events.emit_late(ProjectsEvent::RecordFailed {
code: err.code().to_string(),
});
return Err(err);
}
finish_recorded(&mut result, &mut events);
return Ok(result);
}
let overwrite = plan.action == ProjectAction::Import;
if overwrite {
let pre = gateway.export_bytes(project).await?;
let live = live_digest(project, &pre)?;
let mut file = run_dir.create_private_file_in("export", &format!("{project}.zip"))?;
file.write_all(&pre)
.and_then(|()| file.sync_all())
.map_err(|e| CoreError::DeployRunInvalid {
run_id: run_id.as_str().to_string(),
problem: format!("could not write the pre-image export ({:?})", e.kind()),
})?;
events.emit(ProjectsEvent::PreImageExported { bytes: pre.len() })?;
if plan.live_sha256.as_deref() != Some(live.sha256.as_str()) {
return Err(stale_live(
plan.live_sha256
.clone()
.unwrap_or_else(|| "none".to_string()),
live.sha256,
));
}
}
events.emit(ProjectsEvent::ImportSent { overwrite })?;
let answer = match gateway
.project_import(project, loaded.artifact.zip.clone(), overwrite)
.await
{
Ok(answer) => Some(answer),
Err(err) if import_outcome_unknown(&err) => {
result.warnings.push(format!(
"the import request failed after it may have reached the gateway ({}); the project was read back to find out what it holds",
err.code()
));
None
}
Err(err) => return Err(err),
};
if let Some(answer) = &answer {
events.emit_late(ProjectsEvent::ImportAnswered);
if answer
.response
.get("changes")
.and_then(serde_json::Value::as_array)
.is_some_and(Vec::is_empty)
{
result
.warnings
.push("the gateway reported no changes for this import".to_string());
}
}
let equal = match gateway.export_bytes(project).await {
Ok(zip) => match live_digest(project, &zip) {
Ok(live) => Some(live.sha256 == artifact.sha256),
Err(err) => {
result.warnings.push(format!(
"could not verify the import: the project read back is unusable ({})",
err.code()
));
None
}
},
Err(err) => {
result.warnings.push(format!(
"could not verify the import: the project could not be read back ({})",
err.code()
));
None
}
};
events.emit_late(ProjectsEvent::Verified {
equal: equal == Some(true),
});
if equal == Some(true) {
match writer
.write_release_record(&plan.gateway, project, &record.to_bytes())
.await
{
Ok(()) => {
events.emit_late(ProjectsEvent::RecordWritten);
result.record_written = true;
}
Err(err) => {
events.emit_late(ProjectsEvent::RecordFailed {
code: err.code().to_string(),
});
result.warnings.push(format!(
"the import is verified but the release record could not be written ({})",
err.code()
));
}
}
if let Some(parent) = &loaded.meta.parent {
match gateway.projects().await {
Ok(records) => {
if records
.iter()
.find(|r| r.name == project)
.is_some_and(|r| r.invalid_parent == Some(true))
{
result.warnings.push(format!(
"project {project}: after the import the gateway reports that its parent {parent} is invalid"
));
}
}
Err(err) => result.warnings.push(format!(
"project {project}: the parent could not be read back after the import ({})",
err.code()
)),
}
}
result.outcome = if overwrite {
ProjectOutcome::Imported
} else {
ProjectOutcome::Created
};
} else {
result.outcome = ProjectOutcome::ImportedUnverified;
result.ok = false;
result.slug = Some(PROJECT_UNVERIFIED_SLUG.to_string());
if overwrite {
result.restore_command = Some(restore_command(project, rel));
}
}
if matches!(
result.outcome,
ProjectOutcome::Imported | ProjectOutcome::ImportedUnverified
) {
let sweep = sweep_children(&result, loaded, surfaces, opts, &mut events).await;
result.children = sweep.children;
result.warnings.extend(sweep.warnings);
}
events.emit_late(ProjectsEvent::Finished {
outcome: result.outcome,
});
note_late_log_failure(&mut result, &events);
Ok(result)
}
fn import_outcome_unknown(err: &CoreError) -> bool {
match err {
CoreError::Network {
source: Some(source),
..
} => !(source.is_connect() || source.is_builder()),
CoreError::Network { source: None, .. } | CoreError::Internal(_) => true,
_ => false,
}
}
fn finish_recorded(result: &mut DeployProjectsResult, events: &mut RunEvents<'_>) {
result.record_written = true;
result.outcome = ProjectOutcome::Recorded;
events.emit_late(ProjectsEvent::RecordWritten);
events.emit_late(ProjectsEvent::Finished {
outcome: result.outcome,
});
note_late_log_failure(result, events);
}
fn note_late_log_failure(result: &mut DeployProjectsResult, events: &RunEvents<'_>) {
if events.late_failure {
result
.warnings
.push("the run's events log could not be written to the end".to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_zip() -> Vec<u8> {
use std::io::Write as _;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, bytes) in [
("project.json", br#"{"title":"T"}"#.as_slice()),
("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
("ignition/s/a.py", &[b'x'; 4096]),
] {
writer.start_file(name, options).expect("start");
writer.write_all(bytes).expect("write");
}
writer.finish().expect("finish").into_inner()
}
#[test]
fn check_artifact_zip_refuses_truncated() {
let whole = sample_zip();
check_artifact_zip(&whole).expect("the whole zip passes");
let half = &whole[..whole.len() / 2];
let err = check_artifact_zip(half).expect_err("half a zip");
assert_eq!(err.code(), "invalid_import_file");
let err = check_artifact_zip(&whole[..whole.len() - 40]).expect_err("tail cut");
assert_eq!(err.code(), "invalid_import_file");
let err = check_artifact_zip(b"not a zip").expect_err("no magic");
assert_eq!(err.code(), "invalid_import_file");
}
fn sample_result() -> DeployProjectsResult {
DeployProjectsResult {
env: "prod".to_string(),
gateway: "gw".to_string(),
project: "CapApp".to_string(),
release: "v1.0.0".to_string(),
check: false,
action: ProjectAction::Record,
would_change: true,
artifact_sha256: "a".repeat(64),
live_sha256: Some("a".repeat(64)),
members: MemberDelta::default(),
removed: Vec::new(),
removed_count: 0,
excluded: Vec::new(),
excluded_count: 0,
unmanaged: Vec::new(),
unmanaged_count: 0,
parent: None,
previous_release: None,
recorded_sha256: None,
live_matches_last_deploy: None,
allow_downgrade: false,
allow_first_change: None,
warnings: Vec::new(),
run_id: None,
run_dir: None,
record_written: false,
restore_command: None,
children: Vec::new(),
missing_tags: Some(Vec::new()),
missing_tags_count: Some(0),
missing_tags_unknown_providers: Some(Vec::new()),
missing_tags_unknown_providers_count: Some(0),
missing_tags_unavailable: None,
missing_tags_limit: MISSING_TAGS_LIMIT,
outcome: ProjectOutcome::Checked,
ok: true,
slug: None,
}
}
#[test]
fn a_log_failure_after_the_record_is_written_is_a_warning() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("events.ndjson");
std::fs::write(&path, b"").expect("seed");
let read_only = std::fs::File::open(&path).expect("open read-only");
let mut events = RunEvents {
log: EventLog::from_file(read_only),
observer: &NoProjectsObserver,
late_failure: false,
};
let mut result = sample_result();
finish_recorded(&mut result, &mut events);
assert!(result.record_written, "the record is on the host");
assert_eq!(result.outcome, ProjectOutcome::Recorded);
assert!(result.ok, "a log failure does not fail the run");
assert!(events.late_failure);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("events log could not be written")),
"{:?}",
result.warnings
);
}
#[test]
fn a_built_artifact_passes_the_check() {
let dir = tempfile::tempdir().expect("tempdir");
for (rel, bytes) in [
("project.json", br#"{"title":"T"}"#.as_slice()),
("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
("ignition/s/a.py", b"pass"),
] {
let path = dir.path().join(rel);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(path, bytes).expect("write");
}
let artifact = build_artifact(dir.path()).expect("artifact");
check_artifact_zip(&artifact.zip).expect("a built artifact is a valid import");
}
}