1use super::*;
2use crate::{
3 fleet_catalog::{FleetCatalogEntryV1, read_fleet_catalog_entry_from_root},
4 network::resolve_canonical_network_id_from_root,
5 release_set::{AppConfigSnapshot, ConfiguredPoolExpectation},
6};
7use canic_core::ids::CanonicalNetworkId;
8use std::path::PathBuf;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct LocalDeploymentPlanRequest {
15 pub fleet_name: String,
16 pub app: String,
17 pub environment: String,
18 pub artifact_environment: String,
19 pub workspace_root: PathBuf,
20 pub icp_root: PathBuf,
21 pub config_path: Option<PathBuf>,
22 pub runtime_variant: String,
23 pub build_profile: String,
24}
25
26#[must_use]
29pub fn build_local_deployment_plan(request: &LocalDeploymentPlanRequest) -> DeploymentPlanV1 {
30 let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
31 let mut unresolved_assumptions = Vec::new();
32 let (roles, expected_controllers, expected_pool) = match AppConfigSnapshot::load(&config) {
33 Ok(snapshot) => {
34 if snapshot.app_id() != request.app {
35 unresolved_assumptions.push(assumption(
36 "local_config.app",
37 format!(
38 "{} declares App {}, not requested App {}",
39 config.display(),
40 snapshot.app_id(),
41 request.app
42 ),
43 ));
44 }
45 (
46 deployment_truth_roles_with_implicit_wasm_store(snapshot.bootstrap_roles()),
47 snapshot.controllers(),
48 local_expected_pool(snapshot.pool_expectations()),
49 )
50 }
51 Err(err) => {
52 for (code, subject) in [
53 ("local_config.app", "App identity"),
54 ("local_config.roles", "configured roles"),
55 ("local_config.controllers", "configured controllers"),
56 ("local_config.pools", "configured pool expectations"),
57 ] {
58 unresolved_assumptions.push(assumption(
59 code,
60 format!(
61 "could not resolve {subject} from {}: {err}",
62 config.display()
63 ),
64 ));
65 }
66 (Vec::new(), Vec::new(), Vec::new())
67 }
68 };
69 let resolved_fleet = local_fleet_identity(request, &mut unresolved_assumptions);
70 let root_canister_id = resolved_fleet
71 .catalog
72 .as_ref()
73 .map(|fleet| fleet.root_principal.clone());
74 let raw_config_sha256 = config_sha256_assumption(&config, &mut unresolved_assumptions);
75 let canonical_runtime_config_digest =
76 canonical_runtime_config_assumption(&config, &mut unresolved_assumptions);
77 let deployment_manifest_digest =
78 deployment_manifest_digest_assumption(request, &mut unresolved_assumptions);
79 let artifact_manifest = local_artifact_manifest(request, config);
80 extend_artifact_assumptions(
81 &mut unresolved_assumptions,
82 artifact_manifest.unresolved_artifacts,
83 );
84 let authority_profile = local_authority_profile(request, expected_controllers);
85 let role_artifacts = local_plan_role_artifacts(
86 artifact_manifest.role_artifacts,
87 &request.build_profile,
88 raw_config_sha256.as_ref(),
89 );
90 let expected_canisters = local_expected_canisters(roles, root_canister_id.as_deref());
91 let identity = local_plan_identity(
92 request,
93 PlanIdentityFacts {
94 root_canister_id: root_canister_id.clone(),
95 resolved_fleet: &resolved_fleet,
96 deployment_manifest_digest,
97 canonical_runtime_config_digest,
98 authority_profile: &authority_profile,
99 expected_canisters: &expected_canisters,
100 role_artifacts: &role_artifacts,
101 expected_pool: &expected_pool,
102 },
103 );
104 DeploymentPlanV1 {
105 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
106 plan_id: local_plan_id(request),
107 deployment_identity: identity,
108 trust_domain: TrustDomainV1 {
109 root_trust_anchor: root_canister_id,
110 },
111 runtime_variant: request.runtime_variant.clone(),
112 authority_profile,
113 role_artifacts,
114 expected_canisters,
115 expected_pool,
116 expected_verifier_readiness: VerifierReadinessExpectationV1 {
117 required: false,
118 expected_role_epochs: Vec::new(),
119 },
120 unresolved_assumptions,
121 }
122}
123
124fn local_plan_id(request: &LocalDeploymentPlanRequest) -> String {
125 format!("local:{}:{}:plan", request.environment, request.fleet_name)
126}
127
128struct PlanIdentityFacts<'a> {
129 root_canister_id: Option<String>,
130 resolved_fleet: &'a ResolvedPlanFleet,
131 deployment_manifest_digest: Option<String>,
132 canonical_runtime_config_digest: Option<String>,
133 authority_profile: &'a AuthorityProfileV1,
134 expected_canisters: &'a [ExpectedCanisterV1],
135 role_artifacts: &'a [RoleArtifactV1],
136 expected_pool: &'a [ExpectedPoolCanisterV1],
137}
138
139fn local_artifact_manifest(
140 request: &LocalDeploymentPlanRequest,
141 config: PathBuf,
142) -> RoleArtifactManifestV1 {
143 collect_local_role_artifact_manifest(&LocalArtifactManifestRequest {
144 environment: request.environment.clone(),
145 artifact_environment: request.artifact_environment.clone(),
146 workspace_root: request.workspace_root.clone(),
147 icp_root: request.icp_root.clone(),
148 config_path: Some(config),
149 })
150}
151
152fn local_plan_identity(
153 request: &LocalDeploymentPlanRequest,
154 facts: PlanIdentityFacts<'_>,
155) -> DeploymentIdentityV1 {
156 local_deployment_identity(
157 request,
158 PlanIdentityInput {
159 root_canister_id: facts.root_canister_id,
160 canonical_network_id: facts.resolved_fleet.canonical_network_id,
161 fleet_id: facts
162 .resolved_fleet
163 .catalog
164 .as_ref()
165 .map(|fleet| fleet.fleet_id),
166 deployment_manifest_digest: facts.deployment_manifest_digest,
167 canonical_runtime_config_digest: facts.canonical_runtime_config_digest,
168 authority_profile_hash: Some(stable_json_sha256_hex(facts.authority_profile)),
169 role_topology_hash: Some(stable_json_sha256_hex(&facts.expected_canisters)),
170 artifact_set_digest: Some(stable_json_sha256_hex(&facts.role_artifacts)),
171 pool_identity_set_digest: Some(stable_json_sha256_hex(&facts.expected_pool)),
172 },
173 )
174}
175
176struct ResolvedPlanFleet {
177 canonical_network_id: Option<CanonicalNetworkId>,
178 catalog: Option<FleetCatalogEntryV1>,
179}
180
181fn local_fleet_identity(
182 request: &LocalDeploymentPlanRequest,
183 assumptions: &mut Vec<DeploymentAssumptionV1>,
184) -> ResolvedPlanFleet {
185 let canonical_network_id =
186 match resolve_canonical_network_id_from_root(&request.icp_root, &request.environment) {
187 Ok(network) => Some(network),
188 Err(error) => {
189 assumptions.push(assumption(
190 DeploymentAssumptionKindV1::FleetCatalogReadFailed.key(),
191 format!(
192 "could not resolve canonical network identity for {}: {error}",
193 request.environment
194 ),
195 ));
196 None
197 }
198 };
199 let catalog = if canonical_network_id.is_none() {
200 None
201 } else {
202 match read_fleet_catalog_entry_from_root(
203 &request.icp_root,
204 &request.environment,
205 &request.fleet_name,
206 ) {
207 Ok(Some(fleet)) => Some(fleet),
208 Ok(None) => {
209 assumptions.push(assumption(
210 DeploymentAssumptionKindV1::FleetCatalogMissing.key(),
211 format!(
212 "no installed Fleet catalog entry exists for {}; root identity is unknown until installation completes",
213 request.fleet_name
214 ),
215 ));
216 None
217 }
218 Err(error) => {
219 assumptions.push(assumption(
220 DeploymentAssumptionKindV1::FleetCatalogReadFailed.key(),
221 format!(
222 "could not read Fleet catalog for {}: {error}",
223 request.fleet_name,
224 ),
225 ));
226 None
227 }
228 }
229 };
230 ResolvedPlanFleet {
231 canonical_network_id,
232 catalog,
233 }
234}
235
236struct PlanIdentityInput {
237 canonical_network_id: Option<CanonicalNetworkId>,
238 fleet_id: Option<canic_core::ids::FleetId>,
239 root_canister_id: Option<String>,
240 deployment_manifest_digest: Option<String>,
241 canonical_runtime_config_digest: Option<String>,
242 authority_profile_hash: Option<String>,
243 role_topology_hash: Option<String>,
244 artifact_set_digest: Option<String>,
245 pool_identity_set_digest: Option<String>,
246}
247
248fn local_deployment_identity(
249 request: &LocalDeploymentPlanRequest,
250 input: PlanIdentityInput,
251) -> DeploymentIdentityV1 {
252 DeploymentIdentityV1 {
253 canonical_network_id: input.canonical_network_id,
254 fleet_id: input.fleet_id,
255 fleet_name: request.fleet_name.clone(),
256 app: request.app.clone(),
257 environment: request.environment.clone(),
258 root_principal: input.root_canister_id,
259 authority_profile_hash: input.authority_profile_hash,
260 role_topology_hash: input.role_topology_hash,
261 deployment_manifest_digest: input.deployment_manifest_digest,
262 canonical_runtime_config_digest: input.canonical_runtime_config_digest,
263 role_embedded_config_set_digest: None,
264 artifact_set_digest: input.artifact_set_digest,
265 pool_identity_set_digest: input.pool_identity_set_digest,
266 canic_version: Some(env!("CARGO_PKG_VERSION").to_string()),
267 ic_memory_version: None,
268 }
269}
270
271fn local_authority_profile(
272 request: &LocalDeploymentPlanRequest,
273 expected_controllers: Vec<String>,
274) -> AuthorityProfileV1 {
275 AuthorityProfileV1 {
276 profile_id: format!(
277 "local:{}:{}:authority",
278 request.environment, request.fleet_name
279 ),
280 expected_controllers,
281 staging_controllers: Vec::new(),
282 emergency_controllers: Vec::new(),
283 }
284}
285
286fn local_expected_canisters(
287 roles: Vec<String>,
288 root_canister_id: Option<&str>,
289) -> Vec<ExpectedCanisterV1> {
290 roles
291 .into_iter()
292 .map(|role| ExpectedCanisterV1 {
293 canister_id: if role == "root" {
294 root_canister_id.map(str::to_string)
295 } else {
296 None
297 },
298 role,
299 control_class: CanisterControlClassV1::DeploymentControlled,
300 })
301 .collect()
302}
303
304fn local_expected_pool(pools: Vec<ConfiguredPoolExpectation>) -> Vec<ExpectedPoolCanisterV1> {
305 pools
306 .into_iter()
307 .map(|pool| ExpectedPoolCanisterV1 {
308 pool: pool.pool,
309 canister_id: None,
310 role: Some(pool.canister_role),
311 })
312 .collect()
313}
314
315fn local_plan_role_artifacts(
316 artifacts: Vec<RoleArtifactV1>,
317 build_profile: &str,
318 raw_config_sha256: Option<&String>,
319) -> Vec<RoleArtifactV1> {
320 artifacts
321 .into_iter()
322 .map(|mut artifact| {
323 artifact.build_profile = build_profile.to_string();
324 artifact.raw_config_sha256 = raw_config_sha256.cloned();
325 artifact
326 })
327 .collect()
328}
329
330fn extend_artifact_assumptions(
331 assumptions: &mut Vec<DeploymentAssumptionV1>,
332 gaps: Vec<DeploymentObservationGapV1>,
333) {
334 assumptions.extend(
335 gaps.into_iter()
336 .map(|gap| assumption(gap.key, gap.description)),
337 );
338}
339
340fn assumption(key: impl Into<String>, description: impl Into<String>) -> DeploymentAssumptionV1 {
341 DeploymentAssumptionV1 {
342 key: key.into(),
343 description: description.into(),
344 }
345}
346
347fn config_sha256_assumption(
348 path: &std::path::Path,
349 assumptions: &mut Vec<DeploymentAssumptionV1>,
350) -> Option<String> {
351 match file_sha256_hex(path) {
352 Ok(hash) => Some(hash),
353 Err(err) => {
354 assumptions.push(assumption(
355 "local_config.raw_sha256",
356 format!("could not hash config {}: {err}", path.display()),
357 ));
358 None
359 }
360 }
361}
362
363fn canonical_runtime_config_assumption(
364 path: &std::path::Path,
365 assumptions: &mut Vec<DeploymentAssumptionV1>,
366) -> Option<String> {
367 match canonical_runtime_config_sha256_hex(path) {
368 Ok(hash) => Some(hash),
369 Err(err) => {
370 assumptions.push(assumption(
371 "local_config.canonical_runtime_config_sha256",
372 format!(
373 "could not hash canonical runtime config {}: {err}",
374 path.display()
375 ),
376 ));
377 None
378 }
379 }
380}
381
382fn deployment_manifest_digest_assumption(
383 request: &LocalDeploymentPlanRequest,
384 assumptions: &mut Vec<DeploymentAssumptionV1>,
385) -> Option<String> {
386 let mut gaps = Vec::new();
387 let digest = super::observe::release_set_manifest_digest(
388 &request.icp_root,
389 &request.artifact_environment,
390 &mut gaps,
391 );
392 assumptions.extend(
393 gaps.into_iter()
394 .map(|gap| assumption(gap.key, gap.description)),
395 );
396 digest
397}