This commit is contained in:
Ruben Fiszel
2026-02-18 21:26:07 +00:00
parent c0abb961e8
commit 95cbf3f047
5 changed files with 772 additions and 185 deletions
+1
View File
@@ -15780,6 +15780,7 @@ dependencies = [
"windmill-operator",
"windmill-queue",
"windmill-runtime-nativets",
"windmill-sandbox",
"windmill-test-utils",
"windmill-worker",
"windows-service",
+1
View File
@@ -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]
+275
View File
@@ -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<Postgres>) {
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<Postgres>) {
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<Postgres>) {
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"
);
}
}
+41 -6
View File
@@ -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.",
@@ -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)
})
</script>
<Drawer bind:this={uploadDrawer}>
<DrawerContent title="Upload Snapshot" on:close={uploadDrawer?.closeDrawer}>
<Drawer bind:this={createDrawer}>
<DrawerContent title="New Snapshot" on:close={createDrawer?.closeDrawer}>
<div class="flex flex-col gap-4">
<p class="text-secondary text-xs">
Upload a pre-built rootfs tar.gz file to use as a sandbox snapshot. You can create one
using:
</p>
<pre
class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto"
>crane export python:3.11-slim - | gzip &gt; snapshot.tar.gz</pre>
<label class="block">
<span class="text-xs font-semibold">Name</span>
<input
class="w-full mt-1"
bind:value={uploadName}
placeholder="e.g. python-env"
/>
<input class="w-full mt-1" bind:value={newName} placeholder="e.g. python-env" />
</label>
<label class="block">
<span class="text-xs font-semibold">Tag</span>
<input class="w-full mt-1" bind:value={uploadTag} placeholder="latest" />
<input class="w-full mt-1" bind:value={newTag} placeholder="latest" />
</label>
<label class="block">
<span class="text-xs font-semibold">Snapshot file (.tar.gz)</span>
<input
type="file"
accept=".tar.gz,.tgz"
class="w-full mt-1"
onchange={(e) => {
const target = e.target as HTMLInputElement
uploadFile = target.files?.[0] ?? null
}}
/>
</label>
{#if uploadFile}
<p class="text-xs text-secondary">
File: {uploadFile.name} ({formatSize(uploadFile.size)})
<ToggleButtonGroup bind:selected={snapshotMode}>
{#snippet children({ item })}
<ToggleButton value="builder" label="Builder" {item} />
<ToggleButton value="dockerfile" label="Dockerfile" {item} />
<ToggleButton value="upload" label="Upload" icon={Upload} {item} />
{/snippet}
</ToggleButtonGroup>
{#if snapshotMode === 'builder'}
<label class="block">
<span class="text-xs font-semibold">Docker image</span>
<input
class="w-full mt-1"
bind:value={newDockerImage}
placeholder="e.g. python:3.11-slim"
/>
</label>
<label class="block">
<span class="text-xs font-semibold">Setup script (optional)</span>
<textarea
class="w-full mt-1 text-xs font-mono"
rows="4"
bind:value={newSetupScript}
placeholder="pip install numpy pandas"
></textarea>
</label>
{:else if snapshotMode === 'dockerfile'}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold">Dockerfile</span>
<textarea
class="w-full text-xs font-mono bg-surface-secondary rounded p-3"
rows="8"
bind:value={dockerfileContent}
placeholder={"FROM python:3.11-slim\nRUN pip install numpy pandas"}
></textarea>
<input
type="file"
class="hidden"
bind:this={dockerfileFileInput}
onchange={(e) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
file.text().then((text) => {
dockerfileContent = text
})
}
target.value = ''
}}
/>
<Button
variant="border"
size="xs"
startIcon={{ icon: Upload }}
on:click={() => dockerfileFileInput?.click()}
>
Load from file
</Button>
{#if dockerfileWarnings.length > 0}
<div class="flex flex-col gap-1">
{#each dockerfileWarnings as warning}
<Badge color="yellow" small>{warning}</Badge>
{/each}
</div>
{/if}
</div>
{:else}
<p class="text-secondary text-xs">
Upload a pre-built rootfs tar.gz file. You can create one using:
</p>
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto"
>crane export python:3.11-slim - | gzip &gt; snapshot.tar.gz</pre>
<label class="block">
<span class="text-xs font-semibold">Snapshot file (.tar.gz)</span>
<input
type="file"
accept=".tar.gz,.tgz"
class="w-full mt-1"
onchange={(e) => {
const target = e.target as HTMLInputElement
uploadFile = target.files?.[0] ?? null
}}
/>
</label>
{#if uploadFile}
<p class="text-xs text-secondary">
File: {uploadFile.name} ({formatSize(uploadFile.size)})
</p>
{/if}
{/if}
<Button
variant="accent"
disabled={!uploadName || !uploadFile || uploading}
on:click={uploadSnapshot}
disabled={!newName ||
(snapshotMode === 'builder' && !newDockerImage) ||
(snapshotMode === 'dockerfile' && !dockerfileContent.trim()) ||
(snapshotMode === 'upload' && (!uploadFile || uploading))}
on:click={createSnapshot}
>
{uploading ? 'Uploading...' : 'Upload'}
{#if snapshotMode === 'upload' && uploading}
Uploading...
{:else}
Create
{/if}
</Button>
</div>
</DrawerContent>
@@ -305,43 +495,53 @@
<section>
<h3 class="font-semibold text-base mb-2">1. Create a Snapshot</h3>
<p class="text-secondary mb-2">Export a Docker image as a tar.gz rootfs:</p>
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
# 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 &gt; python-slim.tar.gz
# Or use docker directly
docker create --name tmp python:3.11-slim
docker export tmp | gzip &gt; python-slim.tar.gz
docker rm tmp</pre>
</section>
<section>
<h3 class="font-semibold text-base mb-2">2. Upload the Snapshot</h3>
<p class="text-secondary">
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".
<p class="text-secondary mb-2">
Click "New snapshot" to open the creation drawer. Three modes are available:
</p>
<div class="flex flex-col gap-3">
<div>
<p class="font-medium text-xs mb-1">Builder</p>
<p class="text-secondary text-xs">
Specify a Docker image (e.g. <code class="text-2xs">python:3.11-slim</code>) and an
optional setup script. Windmill pulls the image and runs the script to build the
snapshot on a worker.
</p>
</div>
<div>
<p class="font-medium text-xs mb-1">Dockerfile</p>
<p class="text-secondary text-xs">
Paste or upload a Dockerfile. <code class="text-2xs">FROM</code> sets the base image;
<code class="text-2xs">RUN</code>, <code class="text-2xs">ENV</code>, and
<code class="text-2xs">WORKDIR</code> are converted to a setup script.
Unsupported instructions (COPY, ADD, etc.) show a warning but don't block creation.
</p>
</div>
<div>
<p class="font-medium text-xs mb-1">Upload</p>
<p class="text-secondary text-xs">
Upload a pre-built rootfs <code class="text-2xs">.tar.gz</code> file. You can create
one with:
</p>
<pre class="bg-surface-secondary rounded p-2 text-xs overflow-x-auto mt-1">crane export python:3.11-slim - | gzip &gt; snapshot.tar.gz</pre>
</div>
</div>
</section>
<section>
<h3 class="font-semibold text-base mb-2">3. Use in Scripts</h3>
<h3 class="font-semibold text-base mb-2">2. Use in Scripts</h3>
<p class="text-secondary mb-2">
Reference snapshots and volumes using comment annotations in your script:
</p>
<p class="text-xs font-medium mb-1">Python / Bash</p>
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
# Python / Bash
# sandbox: python-env:latest
# volume: data:/workspace/data
def main():
import pandas as pd # available from snapshot
# /workspace/data persists between runs</pre>
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto mt-2">
// TypeScript / Go
<p class="text-xs font-medium mb-1 mt-2">TypeScript / Go</p>
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
// sandbox: node-env:v2
// volume: cache:/tmp/cache
@@ -387,6 +587,110 @@ export async function main() &#123;
</DrawerContent>
</Drawer>
<Drawer bind:this={detailDrawer}>
<DrawerContent
title={selectedSnapshot ? `${selectedSnapshot.name}:${selectedSnapshot.tag}` : ''}
on:close={detailDrawer?.closeDrawer}
>
{#snippet actions()}
{#if selectedSnapshot}
<Button
variant="border"
size="xs"
startIcon={{ icon: RefreshCw }}
on:click={() => rebuildSnapshot(selectedSnapshot!.name, selectedSnapshot!.tag)}
>
Rebuild
</Button>
<Button
color="red"
variant="border"
size="xs"
startIcon={{ icon: Trash }}
on:click={() => deleteSnapshot(selectedSnapshot!.name, selectedSnapshot!.tag)}
>
Delete
</Button>
{/if}
{/snippet}
{#if selectedSnapshot}
<div class="flex flex-col gap-6 text-sm">
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
<span class="text-secondary text-xs">Status</span>
<Badge color={statusColor(selectedSnapshot.status)} small>
{selectedSnapshot.status}
</Badge>
<span class="text-secondary text-xs">Size</span>
<span class="text-xs">{formatSize(selectedSnapshot.size_bytes)}</span>
<span class="text-secondary text-xs">Created</span>
<span class="text-xs">
{selectedSnapshot.created_by} &middot; {displayDate(selectedSnapshot.created_at)}
</span>
<span class="text-secondary text-xs">Updated</span>
<span class="text-xs">{displayDate(selectedSnapshot.updated_at)}</span>
</div>
<section>
<h4 class="font-semibold text-xs mb-2">Build Configuration</h4>
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
<span class="text-secondary text-xs">Docker Image</span>
<span class="text-xs font-mono">{selectedSnapshot.docker_image}</span>
<span class="text-secondary text-xs">Setup Script</span>
<div>
{#if selectedSnapshot.setup_script}
<pre class="bg-surface-secondary rounded p-2 text-xs font-mono whitespace-pre-wrap">{selectedSnapshot.setup_script}</pre>
{:else}
<span class="text-xs text-tertiary">None</span>
{/if}
</div>
</div>
</section>
{#if selectedSnapshot.build_job_id || selectedSnapshot.build_error}
<section>
<h4 class="font-semibold text-xs mb-2">Build Job</h4>
<div class="flex flex-col gap-2">
{#if selectedSnapshot.build_job_id}
<div class="flex items-center gap-2">
<span class="text-secondary text-xs">Job ID</span>
<a
href="{base}/run/{selectedSnapshot.build_job_id}?workspace={$workspaceStore}"
class="text-xs font-mono text-blue-500 hover:underline inline-flex items-center gap-1"
>
{selectedSnapshot.build_job_id}
<ExternalLink size={12} />
</a>
</div>
{/if}
{#if selectedSnapshot.build_error}
<div>
<span class="text-secondary text-xs">Error</span>
<pre class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded p-2 text-xs font-mono whitespace-pre-wrap mt-1">{selectedSnapshot.build_error}</pre>
</div>
{/if}
</div>
</section>
{/if}
<section>
<h4 class="font-semibold text-xs mb-2">Storage</h4>
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
<span class="text-secondary text-xs">S3 Key</span>
<span class="text-xs font-mono text-secondary break-all">{selectedSnapshot.s3_key}</span>
<span class="text-secondary text-xs">Content Hash</span>
<span class="text-xs font-mono text-secondary break-all">{selectedSnapshot.content_hash || '-'}</span>
</div>
</section>
</div>
{/if}
</DrawerContent>
</Drawer>
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
@@ -409,66 +713,13 @@ export async function main() &#123;
</Button>
{#if tab === 'snapshots'}
<Button
variant="border"
variant="accent"
unifiedSize="md"
startIcon={{ icon: Upload }}
on:click={() => uploadDrawer?.openDrawer()}
startIcon={{ icon: Plus }}
on:click={() => createDrawer?.openDrawer()}
>
Upload snapshot
New snapshot
</Button>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
contentClasses="flex flex-col gap-2 p-4 w-80"
>
{#snippet trigger()}
<Button variant="accent" unifiedSize="md" startIcon={{ icon: Plus }} nonCaptureEvent>
New snapshot
</Button>
{/snippet}
{#snippet content({ close })}
<label class="block">
<span class="text-xs font-semibold">Name</span>
<input
class="w-full mt-1"
bind:value={newSnapshotName}
placeholder="e.g. python-env"
/>
</label>
<label class="block">
<span class="text-xs font-semibold">Tag</span>
<input
class="w-full mt-1"
bind:value={newSnapshotTag}
placeholder="latest"
/>
</label>
<label class="block">
<span class="text-xs font-semibold">Docker image</span>
<input
class="w-full mt-1"
bind:value={newSnapshotDockerImage}
placeholder="e.g. python:3.11-slim"
/>
</label>
<label class="block">
<span class="text-xs font-semibold">Setup script (optional)</span>
<textarea
class="w-full mt-1 text-xs"
rows="3"
bind:value={newSnapshotSetupScript}
placeholder="pip install numpy pandas"
></textarea>
</label>
<Button
variant="accent"
startIcon={{ icon: Plus }}
disabled={!newSnapshotName || !newSnapshotDockerImage}
on:click={() => createSnapshot(close)}
>
Create
</Button>
{/snippet}
</Popover>
{:else}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
@@ -541,7 +792,7 @@ export async function main() &#123;
</tr>
{:else}
{#each snapshots as snapshot (snapshot.name + ':' + snapshot.tag)}
<Row hoverable>
<Row hoverable on:click={() => openSnapshotDetail(snapshot)}>
<Cell first>
<span class="text-emphasis text-xs font-semibold">{snapshot.name}</span>
</Cell>
@@ -549,14 +800,29 @@ export async function main() &#123;
<span class="text-xs font-mono">{snapshot.tag}</span>
</Cell>
<Cell>
<Badge color={statusColor(snapshot.status)} small>
{snapshot.status}
</Badge>
{#if snapshot.build_error}
<span class="text-red-500 text-2xs ml-1" title={snapshot.build_error}>
(error)
</span>
{/if}
<div class="flex items-center gap-1.5">
{#if snapshot.build_job_id}
<a href="{base}/run/{snapshot.build_job_id}?workspace={$workspaceStore}">
<Badge color={statusColor(snapshot.status)} small>
{snapshot.status}
</Badge>
</a>
{:else}
<Badge color={statusColor(snapshot.status)} small>
{snapshot.status}
</Badge>
{/if}
{#if snapshot.build_error}
<Tooltip small>
{#snippet text()}
<pre class="whitespace-pre-wrap text-2xs max-w-md">{snapshot.build_error}</pre>
{/snippet}
<span class="text-red-500 text-2xs cursor-help underline decoration-dotted">
(error)
</span>
</Tooltip>
{/if}
</div>
</Cell>
<Cell>
<span class="text-xs font-mono">{snapshot.docker_image}</span>
@@ -573,6 +839,15 @@ export async function main() &#123;
<Cell shouldStopPropagation>
<Dropdown
items={[
...(snapshot.build_job_id
? [
{
displayName: 'View build job',
icon: ExternalLink,
href: `${base}/run/${snapshot.build_job_id}?workspace=${$workspaceStore}`
}
]
: []),
{
displayName: 'Rebuild',
icon: RefreshCw,