diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 13ef2f10b2..c8ed2f12a7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15780,6 +15780,7 @@ dependencies = [ "windmill-operator", "windmill-queue", "windmill-runtime-nativets", + "windmill-sandbox", "windmill-test-utils", "windmill-worker", "windows-service", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index eccaf51815..126c0b859f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -261,6 +261,7 @@ async-nats.workspace = true aws-sdk-sqs.workspace = true aws-config.workspace = true aws-credential-types.workspace = true +windmill-sandbox = { workspace = true, features = ["parquet"] } [workspace.dependencies] diff --git a/backend/tests/snapshot_build.rs b/backend/tests/snapshot_build.rs new file mode 100644 index 0000000000..8dc85b2182 --- /dev/null +++ b/backend/tests/snapshot_build.rs @@ -0,0 +1,275 @@ +/*! + * Integration test for sandbox snapshot building. + * + * Requires: + * - `crane` CLI in PATH (for Docker image export) + * - A running PostgreSQL database (via DATABASE_URL or sqlx test infrastructure) + * + * Skips gracefully if `crane` is not available. + */ + +#[cfg(feature = "parquet")] +mod tests { + use sqlx::{Pool, Postgres}; + + /// Check if `crane` CLI is available in PATH. + fn crane_available() -> bool { + std::process::Command::new("crane") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Build a minimal snapshot from busybox (no setup script). + /// Verifies the full pipeline: crane export -> tar.gz -> upload to filesystem store -> DB status update. + #[sqlx::test(fixtures("base"))] + async fn test_build_snapshot_minimal(db: Pool) { + if !crane_available() { + eprintln!("SKIPPED: crane not found in PATH"); + return; + } + + let w_id = "test-workspace"; + let store_dir = tempfile::tempdir().unwrap(); + + // Configure filesystem-backed object store for the test workspace + let lfs = serde_json::json!({ + "type": "FilesystemStorage", + "root_path": store_dir.path().to_string_lossy(), + }); + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs, + w_id, + ) + .execute(&db) + .await + .unwrap(); + + // Insert a pending snapshot row (mirrors what the API create_snapshot does) + sqlx::query!( + "INSERT INTO sandbox_snapshot \ + (workspace_id, name, tag, s3_key, docker_image, status, created_by) \ + VALUES ($1, $2, $3, '', $4, 'pending', 'test-user')", + w_id, + "integ-test", + "latest", + "busybox:latest", + ) + .execute(&db) + .await + .unwrap(); + + // Run the build + windmill_sandbox::build_snapshot(w_id, "integ-test", "latest", "busybox:latest", None, &db) + .await + .unwrap(); + + // Verify snapshot is ready with valid metadata + let row = sqlx::query!( + "SELECT status, s3_key, size_bytes, content_hash \ + FROM sandbox_snapshot \ + WHERE workspace_id = $1 AND name = $2 AND tag = $3", + w_id, + "integ-test", + "latest", + ) + .fetch_one(&db) + .await + .unwrap(); + + assert_eq!(row.status, "ready", "snapshot status should be 'ready'"); + assert!( + row.size_bytes.unwrap_or(0) > 0, + "snapshot should have non-zero size" + ); + assert!( + !row.content_hash.is_empty(), + "content_hash should be populated" + ); + assert!( + row.s3_key.starts_with("sandbox/snapshots/"), + "s3_key should have correct prefix, got: {}", + row.s3_key + ); + + // Verify the tar.gz file was written to the filesystem store + let file_path = store_dir.path().join(&row.s3_key); + assert!( + file_path.exists(), + "snapshot tar.gz should exist at {}", + file_path.display() + ); + + // Verify the tar.gz is valid + let bytes = std::fs::read(&file_path).unwrap(); + let dest = tempfile::tempdir().unwrap(); + windmill_sandbox::untar_gz(&bytes, dest.path()).unwrap(); + + // busybox should have /bin/busybox + assert!( + dest.path().join("bin/busybox").exists(), + "unpacked snapshot should contain /bin/busybox" + ); + } + + /// Build a snapshot with a setup script that creates a file. + /// Verifies the setup script ran inside the rootfs. + #[sqlx::test(fixtures("base"))] + async fn test_build_snapshot_with_setup_script(db: Pool) { + if !crane_available() { + eprintln!("SKIPPED: crane not found in PATH"); + return; + } + + // nsjail is needed for setup scripts + let nsjail_available = std::process::Command::new( + std::env::var("NSJAIL_PATH").unwrap_or_else(|_| "nsjail".to_string()), + ) + .arg("--help") + .output() + .is_ok(); + + if !nsjail_available { + eprintln!("SKIPPED: nsjail not found in PATH"); + return; + } + + let w_id = "test-workspace"; + let store_dir = tempfile::tempdir().unwrap(); + + let lfs = serde_json::json!({ + "type": "FilesystemStorage", + "root_path": store_dir.path().to_string_lossy(), + }); + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs, + w_id, + ) + .execute(&db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO sandbox_snapshot \ + (workspace_id, name, tag, s3_key, docker_image, setup_script, status, created_by) \ + VALUES ($1, $2, $3, '', $4, $5, 'pending', 'test-user')", + w_id, + "setup-test", + "latest", + "busybox:latest", + "echo 'hello from setup' > /tmp/setup_marker", + ) + .execute(&db) + .await + .unwrap(); + + windmill_sandbox::build_snapshot( + w_id, + "setup-test", + "latest", + "busybox:latest", + Some("echo 'hello from setup' > /tmp/setup_marker"), + &db, + ) + .await + .unwrap(); + + // Verify status + let row = sqlx::query!( + "SELECT status, s3_key FROM sandbox_snapshot \ + WHERE workspace_id = $1 AND name = $2 AND tag = $3", + w_id, + "setup-test", + "latest", + ) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(row.status, "ready"); + + // Unpack and verify the setup script's marker file exists + let bytes = std::fs::read(store_dir.path().join(&row.s3_key)).unwrap(); + let dest = tempfile::tempdir().unwrap(); + windmill_sandbox::untar_gz(&bytes, dest.path()).unwrap(); + + let marker = dest.path().join("tmp/setup_marker"); + assert!( + marker.exists(), + "setup script marker file should exist in the snapshot" + ); + let content = std::fs::read_to_string(&marker).unwrap(); + assert_eq!(content.trim(), "hello from setup"); + } + + /// Verify that build_snapshot correctly sets status to 'failed' and records + /// the error when given an invalid docker image. + #[sqlx::test(fixtures("base"))] + async fn test_build_snapshot_invalid_image(db: Pool) { + if !crane_available() { + eprintln!("SKIPPED: crane not found in PATH"); + return; + } + + let w_id = "test-workspace"; + let store_dir = tempfile::tempdir().unwrap(); + + let lfs = serde_json::json!({ + "type": "FilesystemStorage", + "root_path": store_dir.path().to_string_lossy(), + }); + sqlx::query!( + "UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2", + lfs, + w_id, + ) + .execute(&db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO sandbox_snapshot \ + (workspace_id, name, tag, s3_key, docker_image, status, created_by) \ + VALUES ($1, $2, $3, '', $4, 'pending', 'test-user')", + w_id, + "bad-image", + "latest", + "nonexistent-registry.invalid/no-such-image:v999", + ) + .execute(&db) + .await + .unwrap(); + + let result = windmill_sandbox::build_snapshot( + w_id, + "bad-image", + "latest", + "nonexistent-registry.invalid/no-such-image:v999", + None, + &db, + ) + .await; + + assert!(result.is_err(), "build should fail for invalid image"); + + // Verify the DB status was set to 'failed' with an error message + let row = sqlx::query!( + "SELECT status, build_error FROM sandbox_snapshot \ + WHERE workspace_id = $1 AND name = $2 AND tag = $3", + w_id, + "bad-image", + "latest", + ) + .fetch_one(&db) + .await + .unwrap(); + + assert_eq!(row.status, "failed"); + assert!( + row.build_error.is_some(), + "build_error should be populated on failure" + ); + } +} diff --git a/backend/windmill-sandbox/src/s3_oss.rs b/backend/windmill-sandbox/src/s3_oss.rs index f9f11ef5d6..cafbf187d6 100644 --- a/backend/windmill-sandbox/src/s3_oss.rs +++ b/backend/windmill-sandbox/src/s3_oss.rs @@ -282,20 +282,51 @@ pub async fn build_snapshot( if let Some(script) = setup_script { if !script.trim().is_empty() { tracing::info!("Running setup script for snapshot {name}:{tag}"); - let output = Command::new("chroot") + + // Copy host resolv.conf so package managers can resolve DNS + let etc_dir = rootfs_dir.join("etc"); + tokio::fs::create_dir_all(&etc_dir).await.ok(); + tokio::fs::copy("/etc/resolv.conf", etc_dir.join("resolv.conf")) + .await + .ok(); + + let nsjail_path = + std::env::var("NSJAIL_PATH").unwrap_or_else(|_| "nsjail".to_string()); + let rootfs_str = rootfs_dir.to_string_lossy().to_string(); + let output = Command::new(&nsjail_path) .args([ - &rootfs_dir.to_string_lossy().to_string(), + "--mode", + "once", + "--chroot", + &rootfs_str, + "--rw", + "--keep_env", + "--disable_clone_newnet", + "--quiet", + "--rlimit_fsize", + "max", + "--rlimit_as", + "max", + "--", "/bin/sh", "-c", script, ]) .output() .await - .map_err(|e| Error::ExecutionErr(format!("Failed to run setup: {e}")))?; + .map_err(|e| Error::ExecutionErr(format!("Failed to run nsjail: {e}")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Error::ExecutionErr(format!("Setup script failed: {stderr}"))); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = if stdout.is_empty() { + stderr.to_string() + } else { + format!("{stderr}\n{stdout}") + }; + return Err(Error::ExecutionErr(format!( + "Setup script failed: {combined}" + ))); } } } @@ -313,7 +344,9 @@ pub async fn build_snapshot( .map_err(|e| Error::ExecutionErr(format!("Spawn blocking failed: {e}")))??; let size = bytes.len(); - if size > CE_SNAPSHOT_SIZE_LIMIT { + if !*windmill_common::ee_oss::LICENSE_KEY_VALID.read().await + && size > CE_SNAPSHOT_SIZE_LIMIT + { return Err(Error::ExecutionErr(format!( "Snapshot size ({:.1} MB) exceeds the {} MB limit. \ Upgrade to Windmill EE for unlimited snapshot sizes.", @@ -394,7 +427,9 @@ pub async fn upload_snapshot_bytes( use windmill_common::error::Error; let size = body.len(); - if size > CE_SNAPSHOT_SIZE_LIMIT { + if !*windmill_common::ee_oss::LICENSE_KEY_VALID.read().await + && size > CE_SNAPSHOT_SIZE_LIMIT + { return Err(Error::ExecutionErr(format!( "Snapshot size ({:.1} MB) exceeds the {} MB limit. \ Upgrade to Windmill EE for unlimited snapshot sizes.", diff --git a/frontend/src/routes/(root)/(logged)/sandboxes/+page.svelte b/frontend/src/routes/(root)/(logged)/sandboxes/+page.svelte index 763a4e5bf2..de1cc400fa 100644 --- a/frontend/src/routes/(root)/(logged)/sandboxes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sandboxes/+page.svelte @@ -13,15 +13,19 @@ TabContent, Tabs } from '$lib/components/common' - import Popover from '$lib/components/meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' import DataTable from '$lib/components/table/DataTable.svelte' import Cell from '$lib/components/table/Cell.svelte' import Head from '$lib/components/table/Head.svelte' import Row from '$lib/components/table/Row.svelte' - import { Plus, Trash, Upload, RefreshCw, Info } from 'lucide-svelte' + import { Plus, Trash, Upload, RefreshCw, Info, ExternalLink } from 'lucide-svelte' import { untrack } from 'svelte' + import { base } from '$app/paths' import { displayDate } from '$lib/utils' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import Popover from '$lib/components/meltComponents/Popover.svelte' type Snapshot = { workspace_id: string @@ -56,25 +60,123 @@ let snapshots: Snapshot[] | undefined = $state(undefined) let volumes: Volume[] | undefined = $state(undefined) - // Create snapshot form - let newSnapshotName = $state('') - let newSnapshotTag = $state('latest') - let newSnapshotDockerImage = $state('') - let newSnapshotSetupScript = $state('') + // Create snapshot drawer + let createDrawer: Drawer | undefined = $state() + let snapshotMode: 'builder' | 'dockerfile' | 'upload' = $state('builder') + + // Shared state + let newName = $state('') + let newTag = $state('latest') + + // Builder state + let newDockerImage = $state('') + let newSetupScript = $state('') + + // Dockerfile state + let dockerfileFileInput: HTMLInputElement | undefined = $state() + let dockerfileContent = $state('') + let dockerfileWarnings: string[] = $derived.by(() => { + if (!dockerfileContent.trim()) return [] + return parseDockerfile(dockerfileContent).warnings + }) + + // Upload state + let uploadFile: File | null = $state(null) + let uploading = $state(false) // Create volume form let newVolumeName = $state('') - // Upload state - let uploadDrawer: Drawer | undefined = $state() - let uploadName = $state('') - let uploadTag = $state('latest') - let uploadFile: File | null = $state(null) - let uploading = $state(false) + // Detail drawer + let detailDrawer: Drawer | undefined = $state() + let selectedSnapshot: Snapshot | undefined = $state() + + function openSnapshotDetail(snapshot: Snapshot) { + selectedSnapshot = snapshot + detailDrawer?.openDrawer() + } // Instructions drawer let instructionsDrawer: Drawer | undefined = $state() + function parseDockerfile(content: string): { + dockerImage: string + setupScript: string + warnings: string[] + } { + const lines = content.split('\n') + let dockerImage = '' + let scriptParts: string[] = [] + let warnings: string[] = [] + let continuation = '' + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trimEnd() + + // Handle line continuations + if (continuation) { + line = continuation + ' ' + line.trimStart() + continuation = '' + } + if (line.endsWith('\\')) { + continuation = line.slice(0, -1).trimEnd() + continue + } + + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const spaceIdx = trimmed.indexOf(' ') + if (spaceIdx === -1) continue + const instruction = trimmed.slice(0, spaceIdx).toUpperCase() + const args = trimmed.slice(spaceIdx + 1).trim() + + switch (instruction) { + case 'FROM': + if (!dockerImage) { + dockerImage = args.split(/\s+/)[0] + } + break + case 'RUN': + scriptParts.push(args) + break + case 'ENV': { + const eqIdx = args.indexOf('=') + if (eqIdx !== -1) { + scriptParts.push(`export ${args}`) + } else { + const parts = args.split(/\s+/, 2) + if (parts.length === 2) { + scriptParts.push(`export ${parts[0]}=${parts[1]}`) + } + } + break + } + case 'WORKDIR': + scriptParts.push(`mkdir -p ${args} && cd ${args}`) + break + default: + warnings.push(`Unsupported instruction: ${instruction} (line ${i + 1})`) + break + } + } + + return { + dockerImage, + setupScript: scriptParts.join('\n'), + warnings + } + } + + function resetCreateForm() { + newName = '' + newTag = 'latest' + newDockerImage = '' + newSetupScript = '' + dockerfileContent = '' + uploadFile = null + } + async function apiFetch(path: string, options?: RequestInit) { const resp = await fetch(`/api/w/${$workspaceStore}/sandbox${path}`, options) if (!resp.ok) { @@ -88,6 +190,11 @@ try { const resp = await apiFetch('/snapshots') snapshots = await resp.json() + if (selectedSnapshot) { + selectedSnapshot = snapshots?.find( + (s) => s.name === selectedSnapshot!.name && s.tag === selectedSnapshot!.tag + ) + } } catch (e: any) { sendUserToast(`Failed to load snapshots: ${e.message}`, true) snapshots = [] @@ -104,27 +211,51 @@ } } - async function createSnapshot(close: () => void) { + async function createSnapshot() { + const tag = newTag || 'latest' try { - await apiFetch('/snapshots', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: newSnapshotName, - tag: newSnapshotTag || 'latest', - docker_image: newSnapshotDockerImage, - setup_script: newSnapshotSetupScript || null + if (snapshotMode === 'upload') { + if (!uploadFile || !newName) return + uploading = true + const resp = await fetch( + `/api/w/${$workspaceStore}/sandbox/snapshots/${encodeURIComponent(newName)}/${encodeURIComponent(tag)}/upload`, + { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: uploadFile + } + ) + if (!resp.ok) { + throw new Error((await resp.text()) || resp.statusText) + } + sendUserToast(`Snapshot ${newName}:${tag} uploaded successfully`) + } else { + let dockerImage = newDockerImage + let setupScript = newSetupScript + if (snapshotMode === 'dockerfile') { + const parsed = parseDockerfile(dockerfileContent) + dockerImage = parsed.dockerImage + setupScript = parsed.setupScript + } + await apiFetch('/snapshots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newName, + tag, + docker_image: dockerImage, + setup_script: setupScript || null + }) }) - }) - sendUserToast(`Snapshot ${newSnapshotName}:${newSnapshotTag || 'latest'} created`) - newSnapshotName = '' - newSnapshotTag = 'latest' - newSnapshotDockerImage = '' - newSnapshotSetupScript = '' - close() + sendUserToast(`Snapshot ${newName}:${tag} created`) + } + resetCreateForm() + createDrawer?.closeDrawer() loadSnapshots() } catch (e: any) { sendUserToast(`Failed to create snapshot: ${e.message}`, true) + } finally { + uploading = false } } @@ -134,6 +265,7 @@ method: 'DELETE' }) sendUserToast(`Deleted snapshot ${name}:${tag}`) + detailDrawer?.closeDrawer() loadSnapshots() } catch (e: any) { sendUserToast(`Failed to delete snapshot: ${e.message}`, true) @@ -147,40 +279,13 @@ { method: 'POST' } ) sendUserToast(`Rebuild queued for ${name}:${tag}`) + detailDrawer?.closeDrawer() loadSnapshots() } catch (e: any) { sendUserToast(`Failed to rebuild snapshot: ${e.message}`, true) } } - async function uploadSnapshot() { - if (!uploadFile || !uploadName) return - uploading = true - try { - const resp = await fetch( - `/api/w/${$workspaceStore}/sandbox/snapshots/${encodeURIComponent(uploadName)}/${encodeURIComponent(uploadTag || 'latest')}/upload`, - { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: uploadFile - } - ) - if (!resp.ok) { - throw new Error((await resp.text()) || resp.statusText) - } - sendUserToast(`Snapshot ${uploadName}:${uploadTag || 'latest'} uploaded successfully`) - uploadName = '' - uploadTag = 'latest' - uploadFile = null - uploadDrawer?.closeDrawer() - loadSnapshots() - } catch (e: any) { - sendUserToast(`Upload failed: ${e.message}`, true) - } finally { - uploading = false - } - } - async function createVolume(close: () => void) { try { await apiFetch('/volumes', { @@ -240,53 +345,138 @@ }) } }) + + // Auto-refresh snapshots while any are pending or building + $effect(() => { + const hasPending = snapshots?.some( + (s) => s.status === 'pending' || s.status === 'building' + ) + if (!hasPending) return + + const interval = setInterval(() => { + loadSnapshots() + }, 5000) + + return () => clearInterval(interval) + }) - - + +
-

- Upload a pre-built rootfs tar.gz file to use as a sandbox snapshot. You can create one - using: -

-
crane export python:3.11-slim - | gzip > snapshot.tar.gz
- - {#if uploadFile} -

- File: {uploadFile.name} ({formatSize(uploadFile.size)}) + + + {#snippet children({ item })} + + + + {/snippet} + + + {#if snapshotMode === 'builder'} + + + {:else if snapshotMode === 'dockerfile'} +

+ Dockerfile + + { + const target = e.target as HTMLInputElement + const file = target.files?.[0] + if (file) { + file.text().then((text) => { + dockerfileContent = text + }) + } + target.value = '' + }} + /> + + {#if dockerfileWarnings.length > 0} +
+ {#each dockerfileWarnings as warning} + {warning} + {/each} +
+ {/if} +
+ {:else} +

+ Upload a pre-built rootfs tar.gz file. You can create one using:

+
crane export python:3.11-slim - | gzip > snapshot.tar.gz
+ + {#if uploadFile} +

+ File: {uploadFile.name} ({formatSize(uploadFile.size)}) +

+ {/if} {/if} +
@@ -305,43 +495,53 @@

1. Create a Snapshot

-

Export a Docker image as a tar.gz rootfs:

-
-# Install crane (Go required)
-go install github.com/google/go-containerregistry/cmd/crane@latest
-
-# Export a Docker image to tar.gz
-crane export python:3.11-slim - | gzip > python-slim.tar.gz
-
-# Or use docker directly
-docker create --name tmp python:3.11-slim
-docker export tmp | gzip > python-slim.tar.gz
-docker rm tmp
-
- -
-

2. Upload the Snapshot

-

- Use the "Upload Snapshot" button to upload the tar.gz file. It will be stored in your - configured S3 object store and marked as "ready". +

+ Click "New snapshot" to open the creation drawer. Three modes are available:

+
+
+

Builder

+

+ Specify a Docker image (e.g. python:3.11-slim) and an + optional setup script. Windmill pulls the image and runs the script to build the + snapshot on a worker. +

+
+
+

Dockerfile

+

+ Paste or upload a Dockerfile. FROM sets the base image; + RUN, ENV, and + WORKDIR are converted to a setup script. + Unsupported instructions (COPY, ADD, etc.) show a warning but don't block creation. +

+
+
+

Upload

+

+ Upload a pre-built rootfs .tar.gz file. You can create + one with: +

+
crane export python:3.11-slim - | gzip > snapshot.tar.gz
+
+
-

3. Use in Scripts

+

2. Use in Scripts

Reference snapshots and volumes using comment annotations in your script:

+

Python / Bash

-# Python / Bash
 # sandbox: python-env:latest
 # volume: data:/workspace/data
 
 def main():
     import pandas as pd  # available from snapshot
     # /workspace/data persists between runs
-
-// TypeScript / Go
+				

TypeScript / Go

+
 // sandbox: node-env:v2
 // volume: cache:/tmp/cache
 
@@ -387,6 +587,110 @@ export async function main() {
 	
 
 
+
+	
+		{#snippet actions()}
+			{#if selectedSnapshot}
+				
+				
+			{/if}
+		{/snippet}
+		{#if selectedSnapshot}
+			
+
+ Status + + {selectedSnapshot.status} + + + Size + {formatSize(selectedSnapshot.size_bytes)} + + Created + + {selectedSnapshot.created_by} · {displayDate(selectedSnapshot.created_at)} + + + Updated + {displayDate(selectedSnapshot.updated_at)} +
+ +
+

Build Configuration

+
+ Docker Image + {selectedSnapshot.docker_image} + + Setup Script +
+ {#if selectedSnapshot.setup_script} +
{selectedSnapshot.setup_script}
+ {:else} + None + {/if} +
+
+
+ + {#if selectedSnapshot.build_job_id || selectedSnapshot.build_error} +
+

Build Job

+
+ {#if selectedSnapshot.build_job_id} + + {/if} + {#if selectedSnapshot.build_error} +
+ Error +
{selectedSnapshot.build_error}
+
+ {/if} +
+
+ {/if} + +
+

Storage

+
+ S3 Key + {selectedSnapshot.s3_key} + + Content Hash + {selectedSnapshot.content_hash || '-'} +
+
+
+ {/if} +
+
+ {#if !$userStore?.is_admin && !$userStore?.is_super_admin}