Skip to main content

canic_host/release_set/current/
mod.rs

1//! Module: release_set::current
2//!
3//! Responsibility: bind the application union and infrastructure manifest into one release authority.
4//! Does not own: artifact builds, topology, Fleet identities, or installation.
5//! Boundary: finalization hashes one canonical manifest whose children were independently validated.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    MAX_DOCUMENT_READ_BYTES,
12    release_build::{ReleaseBuildPlanError, ReleaseBuildPlanState, load_release_build_plan},
13    release_set::{
14        PersistedApplicationArtifactUnion, PersistedCanicInfrastructureArtifactManifest,
15        fixture::{
16            FixtureArtifactError, PersistedFixtureArtifactManifest, load_fixture_artifact_manifest,
17            verify_fixture_artifacts,
18        },
19    },
20};
21use canic_contracts::ids::{BuildNetwork, ReleaseBuildId};
22use canic_core::bootstrap::compiled::ComponentTopology;
23use ic_host_artifacts::artifact::ArtifactError;
24use ic_host_fs::{durable::create_new_bytes_with_parents, read::read_optional_file_no_follow};
25use serde::{Deserialize, Serialize};
26use sha2_host::{Digest, Sha256};
27use std::{
28    io,
29    path::{Path, PathBuf},
30};
31use thiserror::Error as ThisError;
32
33pub const CURRENT_RELEASE_SET_MANIFEST_FILE: &str = "current-release-set-manifest.json";
34
35/// Release-bound policy for crossing an installed Fleet's release boundary.
36/// Same-release interruption recovery remains governed by its existing journal.
37#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ReleaseTransitionMode {
40    ReinstallOnly,
41}
42
43/// Canonical complete current release authority consumed by Fleet generation.
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(deny_unknown_fields)]
46pub struct CurrentReleaseSetManifest {
47    pub application_artifact_union_sha256: [u8; 32],
48    pub build_network: BuildNetwork,
49    pub fixture_artifact_manifest_sha256: [u8; 32],
50    pub infrastructure_artifact_manifest_sha256: [u8; 32],
51    pub release_build_id: ReleaseBuildId,
52    pub schema_version: u16,
53    pub transition_mode: ReleaseTransitionMode,
54}
55
56impl CurrentReleaseSetManifest {
57    pub const SCHEMA_VERSION: u16 = 1;
58
59    pub fn validate(
60        &self,
61        release_build_id: ReleaseBuildId,
62    ) -> Result<(), CurrentReleaseSetManifestError> {
63        if self.schema_version != Self::SCHEMA_VERSION {
64            return Err(CurrentReleaseSetManifestError::Invalid(format!(
65                "unsupported schema version {}",
66                self.schema_version
67            )));
68        }
69        if self.release_build_id != release_build_id {
70            return Err(CurrentReleaseSetManifestError::Invalid(
71                "path and document release-build identities differ".to_string(),
72            ));
73        }
74        Ok(())
75    }
76
77    /// Validate retained fixture bytes against the complete selected release authority.
78    pub fn verify_fixtures(
79        &self,
80        root: &Path,
81        topology: &ComponentTopology,
82    ) -> Result<PersistedFixtureArtifactManifest, FixtureArtifactError> {
83        let fixtures = load_fixture_artifact_manifest(
84            root,
85            topology,
86            self.release_build_id,
87            self.fixture_artifact_manifest_sha256,
88        )?;
89        verify_fixture_artifacts(
90            root,
91            topology,
92            self.release_build_id,
93            self.fixture_artifact_manifest_sha256,
94        )?;
95        Ok(fixtures)
96    }
97
98    pub fn canonical_bytes(&self) -> Result<Vec<u8>, CurrentReleaseSetManifestError> {
99        self.validate(self.release_build_id)?;
100        serde_json::to_vec(self).map_err(CurrentReleaseSetManifestError::Serialize)
101    }
102}
103
104/// One persisted complete current release authority.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct PersistedCurrentReleaseSetManifest {
107    pub digest: [u8; 32],
108    pub manifest: CurrentReleaseSetManifest,
109    pub path: PathBuf,
110}
111
112/// Typed complete release-set persistence failure.
113#[derive(Debug, ThisError)]
114pub enum CurrentReleaseSetManifestError {
115    #[error("current release-set manifest already exists with different contents: {0}")]
116    Conflict(PathBuf),
117
118    #[error("invalid current release-set manifest: {0}")]
119    Invalid(String),
120
121    #[error("current release-set manifest is missing: {0}")]
122    Missing(PathBuf),
123
124    #[error("current release-set manifest is not a regular no-follow file: {0}")]
125    Unsafe(PathBuf),
126
127    #[error("failed to access current release-set manifest {path}: {source}")]
128    Io {
129        path: PathBuf,
130        #[source]
131        source: io::Error,
132    },
133
134    #[error("failed to encode current release-set manifest: {0}")]
135    Serialize(serde_json::Error),
136
137    #[error(transparent)]
138    Fixture(#[from] FixtureArtifactError),
139
140    #[error(transparent)]
141    ReleaseBuild(#[from] ReleaseBuildPlanError),
142}
143
144/// Bind the exact artifact and fixture child manifests before the release build becomes immutable.
145pub fn compile_and_persist_current_release_set_manifest(
146    root: &Path,
147    topology: &ComponentTopology,
148    release_build_id: ReleaseBuildId,
149    application: &PersistedApplicationArtifactUnion,
150    infrastructure: &PersistedCanicInfrastructureArtifactManifest,
151    fixtures: &PersistedFixtureArtifactManifest,
152) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
153    let release_build = load_release_build_plan(root, release_build_id)?;
154    if application.union.release_build_id != release_build_id
155        || infrastructure.manifest.release_build_id != release_build_id
156    {
157        return Err(CurrentReleaseSetManifestError::Invalid(
158            "child manifest release-build identities differ".to_string(),
159        ));
160    }
161    let retained =
162        load_fixture_artifact_manifest(root, topology, release_build_id, fixtures.digest)?;
163    if retained != *fixtures
164        || application.union.fleet_component_topology_digest
165            != retained.manifest.component_topology_digest
166    {
167        return Err(FixtureArtifactError::Authority.into());
168    }
169    verify_fixture_artifacts(root, topology, release_build_id, fixtures.digest)?;
170    let expected = CurrentReleaseSetManifest {
171        application_artifact_union_sha256: application.digest,
172        build_network: release_build.build_network,
173        fixture_artifact_manifest_sha256: fixtures.digest,
174        infrastructure_artifact_manifest_sha256: infrastructure.digest,
175        release_build_id,
176        schema_version: CurrentReleaseSetManifest::SCHEMA_VERSION,
177        transition_mode: ReleaseTransitionMode::ReinstallOnly,
178    };
179    let path = current_release_set_manifest_path(root, release_build_id);
180    let existing = load_optional(&path, release_build_id)?;
181    if matches!(release_build.state, ReleaseBuildPlanState::Finalized { .. }) {
182        return match existing {
183            Some(existing) if existing.manifest == expected => Ok(existing),
184            _ => Err(CurrentReleaseSetManifestError::Conflict(path)),
185        };
186    }
187    if let Some(existing) = existing {
188        return if existing.manifest == expected {
189            Ok(existing)
190        } else {
191            Err(CurrentReleaseSetManifestError::Conflict(path))
192        };
193    }
194    let bytes = expected.canonical_bytes()?;
195    if let Err(source) =
196        create_new_bytes_with_parents(&path, &bytes).map_err(crate::publication::ops::io_error)
197    {
198        if let Ok(Some(existing)) = load_optional(&path, release_build_id)
199            && existing.manifest == expected
200        {
201            return Ok(existing);
202        }
203        return Err(CurrentReleaseSetManifestError::Io { path, source });
204    }
205    load_persisted_current_release_set_manifest(root, release_build_id)
206}
207
208/// Load one exact complete current release authority.
209pub fn load_persisted_current_release_set_manifest(
210    root: &Path,
211    release_build_id: ReleaseBuildId,
212) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
213    let path = current_release_set_manifest_path(root, release_build_id);
214    load_optional(&path, release_build_id)?.ok_or(CurrentReleaseSetManifestError::Missing(path))
215}
216
217fn load_optional(
218    path: &Path,
219    release_build_id: ReleaseBuildId,
220) -> Result<Option<PersistedCurrentReleaseSetManifest>, CurrentReleaseSetManifestError> {
221    let bytes = match read_optional_file_no_follow(path, MAX_DOCUMENT_READ_BYTES) {
222        Ok(value) => value,
223        Err(ArtifactError::NotRegularFile) => {
224            return Err(CurrentReleaseSetManifestError::Unsafe(path.to_path_buf()));
225        }
226        Err(source) => {
227            return Err(CurrentReleaseSetManifestError::Io {
228                path: path.to_path_buf(),
229                source: source.into(),
230            });
231        }
232    };
233    let Some(bytes) = bytes else {
234        return Ok(None);
235    };
236    let manifest: CurrentReleaseSetManifest = serde_json::from_slice(&bytes)
237        .map_err(|error| CurrentReleaseSetManifestError::Invalid(error.to_string()))?;
238    manifest.validate(release_build_id)?;
239    if manifest.canonical_bytes()? != bytes {
240        return Err(CurrentReleaseSetManifestError::Invalid(
241            "document bytes are not canonical".to_string(),
242        ));
243    }
244    Ok(Some(PersistedCurrentReleaseSetManifest {
245        digest: Sha256::digest(&bytes).into(),
246        manifest,
247        path: path.to_path_buf(),
248    }))
249}
250
251fn current_release_set_manifest_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
252    root.join(".canic")
253        .join("release-builds")
254        .join(release_build_id.to_string())
255        .join(CURRENT_RELEASE_SET_MANIFEST_FILE)
256}