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