alien-core 3.3.53

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! How a stack's `.enabled()` gates resolve to a yes or no, shared by the deployment steps that
//! strip declined resources and the preflight runner that hashes the stack they will keep.

use crate::{
    ownership_policy_for_resource_type, GateAnswers, Platform, ResourceEntry, Stack,
    StackInputDefaultValue, StackInputDefinition,
};
use std::collections::{HashMap, HashSet};

/// A gate value from the wire: JSON booleans stay booleans, and the
/// CloudFormation parameter strings "true"/"false" coerce — CloudFormation
/// has no boolean parameter type, so its registration payloads deliver gate
/// answers as strings. Anything else is `None`, refused loudly by callers.
pub fn gate_value_as_bool(value: &serde_json::Value) -> Option<bool> {
    match value {
        serde_json::Value::Bool(answer) => Some(*answer),
        serde_json::Value::String(text) => match text.as_str() {
            "true" => Some(true),
            "false" => Some(false),
            _ => None,
        },
        _ => None,
    }
}

/// The gate input of a setup-created gated resource, `None` for anything else.
pub fn frozen_gate_of(entry: &ResourceEntry) -> Option<&str> {
    gate_of(entry, true)
}

/// The gate input of a runtime-created gated resource, `None` for anything
/// else. The mirror of [`frozen_gate_of`] — which side of the setup boundary a
/// gated resource falls on is decided in exactly these two places.
pub fn live_gate_of(entry: &ResourceEntry) -> Option<&str> {
    gate_of(entry, false)
}

fn gate_of(entry: &ResourceEntry, setup_created: bool) -> Option<&str> {
    let input_id = entry.enabled_when.as_deref()?;
    let emitted_in_setup =
        ownership_policy_for_resource_type(entry.config.resource_type().as_ref())
            .should_emit_in_setup(entry.lifecycle);
    (emitted_in_setup == setup_created).then_some(input_id)
}

/// Every gated resource setup creates, as `(resource_id, input_id)`.
pub fn frozen_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
    stack
        .resources()
        .filter_map(|(resource_id, entry)| Some((resource_id, frozen_gate_of(entry)?)))
}

/// Every gated resource the runtime creates, as `(resource_id, input_id)`.
pub fn live_gated(stack: &Stack) -> impl Iterator<Item = (&String, &str)> {
    stack
        .resources()
        .filter_map(|(resource_id, entry)| Some((resource_id, live_gate_of(entry)?)))
}

/// Whether a gate's input is excluded from the deployment platform.
pub fn gate_is_off_platform(
    inputs: &[StackInputDefinition],
    input_id: &str,
    platform: Platform,
) -> bool {
    inputs
        .iter()
        .find(|input| input.id == input_id)
        .is_some_and(|input| !input.applies_to(platform))
}

/// Remove resources whose gate input is unavailable on this platform, including their links
/// and grants, plus any supplied container allocations. Unlike a mutable live decline,
/// these resources can never be enabled here.
pub fn remove_off_platform_gated_resources(
    stack: &mut Stack,
    platform: Platform,
    compute: Option<&mut crate::ComputeSettings>,
) {
    let declined = stack
        .resources()
        .filter_map(|(id, entry)| {
            let input_id = entry.enabled_when.as_deref()?;
            gate_is_off_platform(&stack.inputs, input_id, platform).then(|| id.clone())
        })
        .collect::<Vec<_>>();
    if let Some(compute) = compute {
        for id in &declined {
            if stack
                .resources
                .get(id)
                .is_some_and(|entry| entry.config.downcast_ref::<crate::Container>().is_some())
            {
                compute.containers.remove(id);
            }
        }
    }
    remove_declined_resources(stack, &declined);
}

/// Inputs scoped away from the platform always resolve false, even with a provided value.
/// The deployer's answer for a live gate: the provided value, else the
/// input's declared boolean default. The error is the reason, for the caller to wrap.
pub fn gate_resolves_true(
    inputs: &[StackInputDefinition],
    input_id: &str,
    input_values: &HashMap<String, serde_json::Value>,
    resource_id: &str,
    platform: Platform,
) -> Result<bool, String> {
    if gate_is_off_platform(inputs, input_id, platform) {
        return Ok(false);
    }
    if let Some(value) = input_values.get(input_id) {
        return gate_value_as_bool(value).ok_or_else(|| {
            format!(
                "Input '{input_id}' enables resource '{resource_id}' but its value is not \
                 a boolean: {value}"
            )
        });
    }

    match inputs
        .iter()
        .find(|input| input.id == input_id)
        .and_then(|input| input.default.as_ref())
    {
        Some(StackInputDefaultValue::Boolean(answer)) => Ok(*answer),
        _ => Err(format!(
            "Input '{input_id}' enables resource '{resource_id}' but no value was provided \
             and the input declares no boolean default"
        )),
    }
}

/// A live gate's answer: the provided value, else the answer recorded when
/// the deployment was created (frozen dominance — a live resource sharing a
/// frozen-gating input follows the fixed answer, not the declared default),
/// else the declared default.
pub fn live_gate_resolves_true(
    inputs: &[StackInputDefinition],
    input_id: &str,
    input_values: &HashMap<String, serde_json::Value>,
    persisted_gate_answers: &GateAnswers,
    still_frozen_gating: &HashSet<String>,
    resource_id: &str,
    platform: Platform,
) -> Result<(bool, &'static str), String> {
    if gate_is_off_platform(inputs, input_id, platform) {
        return Ok((false, "platform"));
    }
    // The recorded answer outranks the default only while the input actually
    // gates a frozen resource — that is what dominance means. Once a release
    // frees the input, its recorded answer is history, and a live gate
    // resolves the way any other live gate does.
    //
    // The source travels with the answer so the audit log cannot describe a
    // precedence this function did not apply.
    if input_values.contains_key(input_id) {
        return Ok((
            gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
            "provided",
        ));
    }
    if still_frozen_gating.contains(input_id) {
        if let Some(answer) = persisted_gate_answers.get(input_id) {
            return Ok((*answer, "persisted"));
        }
    }
    Ok((
        gate_resolves_true(inputs, input_id, input_values, resource_id, platform)?,
        "default",
    ))
}

/// The ids of the gated live resources whose input resolves false.
pub fn declined_live_resources(
    stack: &Stack,
    input_values: &HashMap<String, serde_json::Value>,
    persisted_gate_answers: &GateAnswers,
    still_frozen_gating: &HashSet<String>,
    platform: Platform,
) -> Result<Vec<String>, String> {
    let mut declined = Vec::new();
    for (resource_id, input_id) in live_gated(stack) {
        let (accepted, _source) = live_gate_resolves_true(
            &stack.inputs,
            input_id,
            input_values,
            persisted_gate_answers,
            still_frozen_gating,
            resource_id,
            platform,
        )?;
        if !accepted {
            declined.push(resource_id.clone());
        }
    }
    Ok(declined)
}

/// Frozen-gate answers read off a stack whose Frozen declines were already stripped: a surviving
/// gated setup-created resource reads as yes, even when its gate was never answered. So a strip
/// built on it never drops what the real strip keeps: a digest can miss a match, never fake one.
pub fn surviving_frozen_gate_answers(stack: &Stack) -> (GateAnswers, HashSet<String>) {
    let still_frozen_gating: HashSet<String> = frozen_gated(stack)
        .map(|(_, input_id)| input_id.to_string())
        .collect();
    let answers = still_frozen_gating
        .iter()
        .map(|input_id| (input_id.clone(), true))
        .collect();
    (answers, still_frozen_gating)
}

/// Remove declined resources before mutations, including node grants that must name concrete
/// resources when the ComputeCluster mutation derives setup-owned node permissions.
pub fn remove_declined_resources(stack: &mut Stack, declined: &[String]) {
    if declined.is_empty() {
        return;
    }
    for entry in stack.resources.values_mut() {
        if let Some(profile) = entry
            .config
            .downcast_mut::<crate::ComputeCluster>()
            .and_then(|cluster| cluster.node_permissions.as_mut())
        {
            scrub_declined_profile(profile, declined);
        }
    }
    remove_declined_live_resources(stack, declined);
}

/// Remove mutable Live declines after mutations, retaining setup-owned node grants just as
/// derived service-account grants are retained. Later updates compare those Frozen configs
/// with the accepted render before applying Live declines. The setup digest uses this strip too.
pub fn remove_declined_live_resources(stack: &mut Stack, declined: &[String]) {
    if declined.is_empty() {
        return;
    }

    for resource_id in declined {
        tracing::info!(
            resource_id = %resource_id,
            "The deployer declined this gated resource; it leaves the desired stack"
        );
        stack.resources.shift_remove(resource_id);
    }

    // Removing the resource without its inbound links would leave a survivor pointing at
    // something that was never created, which the executor and binding resolution both
    // reject. Scrubbing here is what lets an ungated resource link a gated one.
    for (resource_id, entry) in stack.resources.iter_mut() {
        let dropped = match crate::resource_links_mut(&mut entry.config) {
            Some(owner) => {
                let before = owner.links().len();
                owner
                    .links_mut()
                    .retain(|link| !declined.contains(&link.id));
                before - owner.links().len()
            }
            None => 0,
        };
        if dropped > 0 {
            tracing::info!(
                resource_id = %resource_id,
                dropped,
                declined = ?declined,
                "Dropped links to declined resources; this resource keeps its own lifecycle"
            );
        }

        let ordering_before = entry.dependencies.len();
        entry
            .dependencies
            .retain(|dependency| !declined.contains(&dependency.id));
        // The release-time preflight refuses authored ordering edges onto gated resources,
        // so one reaching here predates the rule; dropping it keeps the stack coherent.
        if ordering_before > entry.dependencies.len() {
            tracing::info!(
                resource_id = %resource_id,
                dropped = ordering_before - entry.dependencies.len(),
                "Dropped ordering edges to declined resources"
            );
        }
    }

    scrub_declined_grants(stack, declined);
}

/// Drop grants naming a declined resource from workload and management profiles.
///
/// Not inert: GCP applies every non-`"*"` entry without consulting the desired resources.
/// Nothing is lost, because the mutations re-derive them whenever the gate is accepted.
fn scrub_declined_grants(stack: &mut Stack, declined: &[String]) {
    for profile in stack.permissions.profiles.values_mut() {
        scrub_declined_profile(profile, declined);
    }
    match &mut stack.permissions.management {
        crate::permissions::ManagementPermissions::Extend(profile)
        | crate::permissions::ManagementPermissions::Override(profile) => {
            scrub_declined_profile(profile, declined);
        }
        crate::permissions::ManagementPermissions::Auto => {}
    }
}

fn scrub_declined_profile(
    profile: &mut crate::permissions::PermissionProfile,
    declined: &[String],
) {
    for resource_id in declined {
        if profile.0.shift_remove(resource_id).is_some() {
            tracing::info!(resource_id = %resource_id, "Dropped the grant for a declined resource");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn declining_a_node_target_preserves_empty_profiles_and_unrelated_grants() {
        let profile = crate::PermissionProfile::new()
            .resource("data", ["storage/data-read"])
            .resource("missing", ["storage/data-write"]);
        let mut stack = Stack::new("declined-grants".into())
            .add(
                crate::Storage::new("data".into()).build(),
                crate::ResourceLifecycle::Frozen,
            )
            .add(
                crate::ComputeCluster::new("compute".into())
                    .node_permissions(profile.clone())
                    .build(),
                crate::ResourceLifecycle::Frozen,
            )
            .permission("reader", profile.clone())
            .build();
        stack.permissions.management = crate::ManagementPermissions::Extend(profile);
        let cluster = stack
            .resources
            .get_mut("compute")
            .unwrap()
            .config
            .downcast_mut::<crate::ComputeCluster>()
            .unwrap();
        cluster.node_permissions_platforms = Some(vec![Platform::Aws]);
        remove_declined_resources(&mut stack, &["data".into()]);
        let cluster = stack.resources["compute"]
            .config
            .downcast_ref::<crate::ComputeCluster>()
            .unwrap();
        let remaining = crate::PermissionProfile::new().resource("missing", ["storage/data-write"]);
        assert_eq!(cluster.node_permissions.as_ref(), Some(&remaining));
        assert_eq!(
            cluster.node_permissions_platforms,
            Some(vec![Platform::Aws])
        );
        assert_eq!(stack.permissions.profiles["reader"], remaining);
        assert_eq!(
            stack.permissions.management,
            crate::ManagementPermissions::Extend(remaining)
        );
        remove_declined_resources(&mut stack, &["missing".into()]);
        let cluster = stack.resources["compute"]
            .config
            .downcast_ref::<crate::ComputeCluster>()
            .unwrap();
        assert_eq!(
            cluster.node_permissions,
            Some(crate::PermissionProfile::new())
        );
        assert_eq!(
            cluster.node_permissions_platforms,
            Some(vec![Platform::Aws])
        );
    }

    #[test]
    fn off_platform_gate_needs_neither_a_valid_value_nor_a_default() {
        let mut input = StackInputDefinition::deployer_boolean(
            "managedBucket",
            "Managed bucket",
            "Create a bucket",
            None,
        );
        input.platforms = Some(vec![Platform::Aws]);
        let values = HashMap::from([(input.id.clone(), serde_json::json!("not a boolean"))]);
        assert!(!gate_resolves_true(
            std::slice::from_ref(&input),
            &input.id,
            &values,
            "data",
            Platform::Machines
        )
        .unwrap());
        assert!(gate_resolves_true(
            std::slice::from_ref(&input),
            &input.id,
            &values,
            "data",
            Platform::Aws
        )
        .is_err());
    }

    #[test]
    fn platform_scope_overrides_defaults_provided_and_recorded_answers() {
        for platforms in [None, Some(vec![Platform::Aws]), Some(vec![])] {
            for default in [false, true] {
                let mut input = StackInputDefinition::deployer_boolean(
                    "managedBucket",
                    "Managed bucket",
                    "Create a bucket",
                    Some(default),
                );
                input.platforms = platforms.clone();
                for platform in [
                    Platform::Aws,
                    Platform::Machines,
                    Platform::Local,
                    Platform::Kubernetes,
                ] {
                    for provided in [None, Some(false), Some(true)] {
                        let values = provided
                            .map(|value| (input.id.clone(), serde_json::json!(value)))
                            .into_iter()
                            .collect();
                        let excluded = platforms
                            .as_ref()
                            .is_some_and(|scope| !scope.is_empty() && !scope.contains(&platform));
                        let expected = !excluded && provided.unwrap_or(default);
                        assert_eq!(
                            gate_resolves_true(
                                std::slice::from_ref(&input),
                                &input.id,
                                &values,
                                "data",
                                platform
                            )
                            .unwrap(),
                            expected
                        );
                        for recorded in [false, true] {
                            let answers = GateAnswers::from([(input.id.clone(), recorded)]);
                            for frozen in [HashSet::new(), HashSet::from([input.id.clone()])] {
                                let expected = !excluded
                                    && provided.unwrap_or(if frozen.is_empty() {
                                        default
                                    } else {
                                        recorded
                                    });
                                let (actual, source) = live_gate_resolves_true(
                                    std::slice::from_ref(&input),
                                    &input.id,
                                    &values,
                                    &answers,
                                    &frozen,
                                    "data",
                                    platform,
                                )
                                .unwrap();
                                assert_eq!(actual, expected);
                                if excluded {
                                    assert_eq!(source, "platform");
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}