Skip to main content

alien_core/
sandbox_setup_inputs.rs

1//! The facts an AWS sandbox's setup renders its scaffolding from. Setup applies them and the
2//! runtime never re-reads them, so a change to any of them needs setup to run again.
3
4use alien_error::AlienError;
5use serde_json::Value;
6
7use crate::remote_bindings::{
8    remote_binding_for_entry, remote_binding_is_deliverable, RemoteBindingDefinition,
9};
10use crate::sandbox_build_role::SandboxBuildRole;
11use crate::sandbox_egress::{sandbox_egress_network, sandbox_needs_connector};
12use crate::{
13    ErrorData, ResourceLifecycle, Result, Sandbox, SandboxCode, Stack, BUNDLE_REGION_TOKEN,
14};
15
16/// The account a sandbox's scaffolding is rendered into.
17#[derive(Debug, Clone, Copy)]
18pub struct SetupAccount<'a> {
19    pub partition: &'a str,
20    pub account_id: &'a str,
21    pub region: &'a str,
22}
23
24/// For comparing two stacks without an account. The region stays the token so a `{region}` host
25/// and a literal region never render the same ARN.
26pub const SETUP_INPUTS_COMPARISON_ACCOUNT: SetupAccount<'static> = SetupAccount {
27    partition: "aws",
28    account_id: "000000000000",
29    region: BUNDLE_REGION_TOKEN,
30};
31
32/// The id of the setup-owned network every AWS sandbox connector attaches to.
33/// The error is the refusal message, for the caller to wrap in its own error.
34pub fn aws_sandbox_egress_network_id<'a>(
35    stack: &'a Stack,
36    sandbox: &Sandbox,
37) -> std::result::Result<Option<&'a str>, String> {
38    if !sandbox_needs_connector(stack, sandbox) {
39        return Ok(None);
40    }
41    let network = match sandbox_egress_network(stack) {
42        Ok(network) => network,
43        Err(refusal) => return Err(refusal.to_string()),
44    };
45    if network.entry.lifecycle != ResourceLifecycle::Frozen {
46        return Err(
47            "an AWS sandbox routes session traffic through a VPC egress connector; its network \
48             must be created by setup, and this one is created at runtime"
49                .to_string(),
50        );
51    }
52    Ok(Some(network.id))
53}
54
55/// The build role setup renders for this sandbox from its bundle.
56pub fn aws_sandbox_build_role<'a>(
57    sandbox: &'a Sandbox,
58    bundle_uri: &'a str,
59    lifecycle: ResourceLifecycle,
60    account: SetupAccount<'a>,
61) -> SandboxBuildRole<'a> {
62    SandboxBuildRole::builder()
63        .sandbox_id(&sandbox.id)
64        .partition(account.partition)
65        .account_id(account.account_id)
66        .region(account.region)
67        .bundle_uri(bundle_uri)
68        .runtime_built(lifecycle == ResourceLifecycle::Live)
69        .maybe_private_base_image(sandbox.private_base_image.as_deref())
70        .build()
71}
72
73/// The Remote Bindings grant setup renders for this sandbox, `None` when it publishes none.
74pub fn aws_sandbox_remote_grant(
75    stack: &Stack,
76    sandbox: &Sandbox,
77) -> Option<&'static RemoteBindingDefinition> {
78    stack
79        .resources
80        .get(&sandbox.id)
81        .filter(|entry| remote_binding_is_deliverable(entry))
82        .and_then(remote_binding_for_entry)
83}
84
85/// Stands in for a Live sandbox's bundle store in its setup inputs; see [`aws_sandbox_setup_inputs`].
86const SETUP_GRANTED_STORE: &str = "setup-granted-store";
87/// Stands in for a Live sandbox's private base image repository in its setup inputs.
88const SETUP_GRANTED_BASE_IMAGE: &str =
89    "000000000000.dkr.ecr.us-east-1.amazonaws.com/setup-granted-repository:base";
90
91/// `image` with its bucket replaced by [`SETUP_GRANTED_STORE`], keeping the key.
92fn setup_granted_bundle(image: &str) -> std::result::Result<String, String> {
93    image
94        .strip_prefix("s3://")
95        .and_then(|path| path.split_once('/'))
96        .map(|(_, key)| format!("s3://{SETUP_GRANTED_STORE}/{key}"))
97        .ok_or_else(|| format!("bundle '{image}' is not an s3://bucket/key URI"))
98}
99
100/// Each setup input with the name a refused update reports it by. A new bundle under the same
101/// stable prefix, or a new tag in the same private repository, leaves them unchanged. For a Live
102/// sandbox the bundle's store and the base image's repository are not inputs at all: they are
103/// fixed by the setup that granted them.
104pub fn aws_sandbox_setup_inputs(
105    stack: &Stack,
106    sandbox: &Sandbox,
107    lifecycle: ResourceLifecycle,
108    account: SetupAccount<'_>,
109) -> Result<Vec<(&'static str, Value)>> {
110    let refuse = |reason: String| {
111        AlienError::new(ErrorData::OperationNotSupported {
112            operation: format!("setup inputs for sandbox '{}'", sandbox.id),
113            reason,
114        })
115    };
116    let SandboxCode::Image { image } = &sandbox.code else {
117        return Err(refuse(
118            "an AWS sandbox is built from a prebuilt s3:// bundle, not from source".to_string(),
119        ));
120    };
121    let policy = if lifecycle == ResourceLifecycle::Live {
122        // A Live sandbox's store and base image repository are fixed by its setup: the runtime
123        // reads each release's bundle key from the store this setup granted, and the publisher
124        // puts a bundle there whose base image this role can pull. Only the shape of the grant
125        // (stable prefix, whether a private base exists) is a setup input.
126        let mut granted = sandbox.clone();
127        granted.private_base_image = sandbox
128            .private_base_image
129            .as_ref()
130            .map(|_| SETUP_GRANTED_BASE_IMAGE.to_string());
131        let bundle = setup_granted_bundle(image).map_err(refuse)?;
132        aws_sandbox_build_role(&granted, &bundle, lifecycle, account).policy()?
133    } else {
134        aws_sandbox_build_role(sandbox, image, lifecycle, account).policy()?
135    };
136    let network = aws_sandbox_egress_network_id(stack, sandbox).map_err(refuse)?;
137    // An update that stops publishing keeps the setup-owned Remote Bindings role, so without
138    // this the grant would outlive the declaration.
139    let grant =
140        aws_sandbox_remote_grant(stack, sandbox).map(|definition| definition.permission_set);
141    Ok(vec![
142        ("egress", sandbox.setup_digest_value()),
143        ("egress network", serde_json::json!(network)),
144        (
145            "build role policy",
146            serde_json::to_value(policy).expect("a build role policy serializes to JSON"),
147        ),
148        ("remote grant", serde_json::json!(grant)),
149    ])
150}
151
152/// [`aws_sandbox_setup_inputs`] for comparing stacks, which cannot fail: a sandbox whose inputs do
153/// not resolve counts as its whole configuration, so any change to it needs setup.
154pub fn comparable_aws_sandbox_setup_inputs(
155    stack: &Stack,
156    sandbox: &Sandbox,
157    lifecycle: ResourceLifecycle,
158) -> Vec<(&'static str, Value)> {
159    aws_sandbox_setup_inputs(stack, sandbox, lifecycle, SETUP_INPUTS_COMPARISON_ACCOUNT)
160        .unwrap_or_else(|_| {
161            vec![("configuration", {
162                let mut value =
163                    serde_json::to_value(sandbox).expect("a sandbox serializes to JSON");
164                value["egress"] = sandbox.setup_digest_value();
165                value
166                    .as_object_mut()
167                    .expect("sandbox config is an object")
168                    .remove("defaultPolicy");
169                value
170            })]
171        })
172}