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, SANDBOX_BUNDLE_STORAGE_ID};
11use crate::sandbox_egress::{sandbox_egress_network, sandbox_needs_connector};
12use crate::{
13    parse_ecr_image_repository, ErrorData, ResourceEntry, ResourceLifecycle, Result, Sandbox,
14    SandboxCode, Stack, Storage, BUNDLE_REGION_TOKEN,
15};
16
17/// The account a sandbox's scaffolding is rendered into.
18#[derive(Debug, Clone, Copy)]
19pub struct SetupAccount<'a> {
20    pub partition: &'a str,
21    pub account_id: &'a str,
22    pub region: &'a str,
23}
24
25/// For comparing two stacks without an account. The region stays the token so a `{region}` host
26/// and a literal region never render the same ARN.
27pub const SETUP_INPUTS_COMPARISON_ACCOUNT: SetupAccount<'static> = SetupAccount {
28    partition: "aws",
29    account_id: "000000000000",
30    region: BUNDLE_REGION_TOKEN,
31};
32
33/// The id of the setup-owned network every AWS sandbox connector attaches to.
34/// The error is the refusal message, for the caller to wrap in its own error.
35pub fn aws_sandbox_egress_network_id<'a>(
36    stack: &'a Stack,
37    sandbox: &Sandbox,
38) -> std::result::Result<Option<&'a str>, String> {
39    if !sandbox_needs_connector(stack, sandbox) {
40        return Ok(None);
41    }
42    let network = match sandbox_egress_network(stack) {
43        Ok(network) => network,
44        Err(refusal) => return Err(refusal.to_string()),
45    };
46    if network.entry.lifecycle != ResourceLifecycle::Frozen {
47        return Err(
48            "an AWS sandbox routes session traffic through a VPC egress connector; its network \
49             must be created by setup, and this one is created at runtime"
50                .to_string(),
51        );
52    }
53    Ok(Some(network.id))
54}
55
56/// Whether the controller renders this sandbox's bundle into the deployment's bundle Storage,
57/// [`SANDBOX_BUNDLE_STORAGE_ID`]: the sandbox depends on it. Setup adds the Storage for a Live
58/// sandbox released as a container image; a deployment set up before it existed builds from the
59/// bundle a publisher wrote into the store its setup granted.
60pub fn aws_sandbox_renders_bundle(stack: &Stack, sandbox_id: &str) -> bool {
61    stack
62        .resources
63        .get(sandbox_id)
64        .is_some_and(aws_sandbox_entry_renders_bundle)
65}
66
67/// [`aws_sandbox_renders_bundle`] for the sandbox's own entry.
68pub fn aws_sandbox_entry_renders_bundle(entry: &ResourceEntry) -> bool {
69    entry.dependencies.iter().any(|dependency| {
70        dependency.resource_type == Storage::RESOURCE_TYPE
71            && dependency.id == SANDBOX_BUNDLE_STORAGE_ID
72    })
73}
74
75/// The build role setup renders for this sandbox. With `renders_bundle` it only pulls the image
76/// the rendered bundle names; otherwise it reads `bundle_uri`.
77pub fn aws_sandbox_build_role<'a>(
78    sandbox: &'a Sandbox,
79    bundle_uri: &'a str,
80    renders_bundle: bool,
81    lifecycle: ResourceLifecycle,
82    account: SetupAccount<'a>,
83) -> SandboxBuildRole<'a> {
84    SandboxBuildRole::builder()
85        .sandbox_id(&sandbox.id)
86        .partition(account.partition)
87        .account_id(account.account_id)
88        .region(account.region)
89        .bundle_uri(bundle_uri)
90        .runtime_built(lifecycle == ResourceLifecycle::Live)
91        .maybe_private_base_image(sandbox.private_base_image.as_deref())
92        .renders_bundle(renders_bundle)
93        .build()
94}
95
96/// The Remote Bindings grant setup renders for this sandbox, `None` when it publishes none.
97pub fn aws_sandbox_remote_grant(
98    stack: &Stack,
99    sandbox: &Sandbox,
100) -> Option<&'static RemoteBindingDefinition> {
101    stack
102        .resources
103        .get(&sandbox.id)
104        .filter(|entry| remote_binding_is_deliverable(entry))
105        .and_then(remote_binding_for_entry)
106}
107
108/// Stands in for a Live sandbox's bundle store in its setup inputs; see [`aws_sandbox_setup_inputs`].
109const SETUP_GRANTED_STORE: &str = "setup-granted-store";
110/// Stands in for a Live sandbox's private base image repository in its setup inputs.
111const SETUP_GRANTED_BASE_IMAGE: &str =
112    "000000000000.dkr.ecr.us-east-1.amazonaws.com/setup-granted-repository:base";
113
114/// `image` as [`SETUP_GRANTED_BASE_IMAGE`], keeping its repository's namespace (the path before
115/// the last `/`). A publisher keeps installed setups building by writing their bundles `FROM` a
116/// repository they granted, which covers a renamed repository in the same namespace. A move to
117/// another namespace is not covered, so the namespace stays a setup input.
118fn setup_granted_base_image(image: &str) -> std::result::Result<String, String> {
119    let repository = parse_ecr_image_repository(image)?;
120    Ok(match repository.repository.rsplit_once('/') {
121        Some((namespace, _)) => SETUP_GRANTED_BASE_IMAGE.replacen(
122            "/setup-granted-repository",
123            &format!("/{namespace}/setup-granted-repository"),
124            1,
125        ),
126        None => SETUP_GRANTED_BASE_IMAGE.to_string(),
127    })
128}
129
130/// `image` with its bucket replaced by [`SETUP_GRANTED_STORE`], keeping the key.
131fn setup_granted_bundle(image: &str) -> std::result::Result<String, String> {
132    image
133        .strip_prefix("s3://")
134        .and_then(|path| path.split_once('/'))
135        .map(|(_, key)| format!("s3://{SETUP_GRANTED_STORE}/{key}"))
136        .ok_or_else(|| format!("bundle '{image}' is not an s3://bucket/key URI"))
137}
138
139/// Each setup input with the name a refused update reports it by. A new bundle under the same
140/// stable prefix, or a new tag in the same private repository, leaves them unchanged. For a Live
141/// sandbox the bundle's store and the base image's repository name are not inputs at all: they
142/// are fixed by the setup that granted them. The repository's namespace is.
143pub fn aws_sandbox_setup_inputs(
144    stack: &Stack,
145    sandbox: &Sandbox,
146    lifecycle: ResourceLifecycle,
147    account: SetupAccount<'_>,
148) -> Result<Vec<(&'static str, Value)>> {
149    let refuse = |reason: String| {
150        AlienError::new(ErrorData::OperationNotSupported {
151            operation: format!("setup inputs for sandbox '{}'", sandbox.id),
152            reason,
153        })
154    };
155    let SandboxCode::Image { image, .. } = &sandbox.code else {
156        return Err(refuse(
157            "an AWS sandbox is built from a prebuilt s3:// bundle, not from source".to_string(),
158        ));
159    };
160    let policy = if aws_sandbox_renders_bundle(stack, &sandbox.id) {
161        // The bundle Storage is a setup-owned resource of its own, compared as one, so only the
162        // image grant is an input here.
163        aws_sandbox_build_role(sandbox, image, true, lifecycle, account).policy()?
164    } else if lifecycle == ResourceLifecycle::Live {
165        // A deployment set up before the bundle Storage existed builds from the publisher's
166        // bundle in `code.image`, so a release that also names the image it was built from keeps
167        // that setup's inputs: it goes on building without a setup rerun, and moves to the
168        // Storage when setup runs again.
169        //
170        // A Live sandbox's store and base image repository are fixed by its setup: the runtime
171        // reads each release's bundle key from the store this setup granted, and the publisher
172        // puts a bundle there whose base image this role can pull. Only the shape of the grant
173        // (stable prefix, whether a private base exists, its repository namespace) is a setup
174        // input.
175        let mut granted = sandbox.clone();
176        granted.private_base_image = sandbox
177            .private_base_image
178            .as_deref()
179            .map(setup_granted_base_image)
180            .transpose()
181            .map_err(refuse)?;
182        let bundle = setup_granted_bundle(image).map_err(refuse)?;
183        aws_sandbox_build_role(&granted, &bundle, false, lifecycle, account).policy()?
184    } else {
185        aws_sandbox_build_role(sandbox, image, false, lifecycle, account).policy()?
186    };
187    let network = aws_sandbox_egress_network_id(stack, sandbox).map_err(refuse)?;
188    // An update that stops publishing keeps the setup-owned Remote Bindings role, so without
189    // this the grant would outlive the declaration.
190    let grant =
191        aws_sandbox_remote_grant(stack, sandbox).map(|definition| definition.permission_set);
192    Ok(vec![
193        ("egress", sandbox.setup_digest_value()),
194        ("egress network", serde_json::json!(network)),
195        (
196            "build role policy",
197            serde_json::to_value(policy).expect("a build role policy serializes to JSON"),
198        ),
199        ("remote grant", serde_json::json!(grant)),
200    ])
201}
202
203/// [`aws_sandbox_setup_inputs`] for comparing stacks, which cannot fail: a sandbox whose inputs do
204/// not resolve counts as its whole configuration, so any change to it needs setup.
205pub fn comparable_aws_sandbox_setup_inputs(
206    stack: &Stack,
207    sandbox: &Sandbox,
208    lifecycle: ResourceLifecycle,
209) -> Vec<(&'static str, Value)> {
210    aws_sandbox_setup_inputs(stack, sandbox, lifecycle, SETUP_INPUTS_COMPARISON_ACCOUNT)
211        .unwrap_or_else(|_| {
212            vec![("configuration", {
213                let mut value =
214                    serde_json::to_value(sandbox).expect("a sandbox serializes to JSON");
215                value["egress"] = sandbox.setup_digest_value();
216                value
217                    .as_object_mut()
218                    .expect("sandbox config is an object")
219                    .remove("defaultPolicy");
220                value
221            })]
222        })
223}