upload a Docker image .tar through the UI (docker load) for air-gapped hosts

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent f43736d94a
commit c91f38eddf
5 files changed
+246 -24

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import { loadImage } from '$lib/server/images';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import type { RequestHandler } from './$types';
/**
* POST /api/images/load - load a Docker image from an uploaded tar (docker load),
* for air-gapped hosts. The request BODY is the raw tar (Content-Type
* application/x-tar); it is streamed straight to the daemon without buffering, so a
* large image does not OOM.
*
* This is SYNCHRONOUS on purpose - it must NOT use the job-polling pattern. That
* pattern returns immediately and runs the operation in the background, but the tar
* lives in the request body, which is torn down once the response is sent; a
* backgrounded read would hang on a dead stream. So we consume the body and stream it
* to the daemon while the request is still open, then return the result.
*
* Local/socket and direct TCP only (loadImage rejects Hawser).
*/
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
// Loading a foreign tar is a distinct, higher-trust operation than pulling from a
// registry (no scan-on-pull, arbitrary user-supplied image), so it has its own
// permission rather than reusing images:pull.
if (auth.authEnabled && !(await auth.can('images', 'load', envId))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (envId && auth.isEnterprise && !(await auth.canAccessEnvironment(envId))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
if (!request.body) {
return json({ error: 'Request body (an image tar) is required' }, { status: 400 });
}
const tar = request.body as ReadableStream<Uint8Array>;
await auditImage(event, 'load', 'tar-upload', 'tar-upload', envId);
try {
let lastStream = '';
await loadImage(
tar,
(data) => {
// The daemon's final `stream` line is e.g. "Loaded image: alpine:3.20".
if (data.stream) lastStream = data.stream.trim();
},
envId
);
return json({ success: true, loaded: lastStream });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[images/load] failed:', message);
return json({ success: false, error: message }, { status: 500 });
}
};
+46
View File
@@ -99,6 +99,39 @@
// Pull modal state
let showPullModal = $state(false);
// Load-from-tar state
let loadFileInput = $state<HTMLInputElement | null>(null);
let loadingImage = $state(false);
async function handleLoadTar(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // reset so re-selecting the same file fires change again
if (!file) return;
loadingImage = true;
const t = toast.loading(`Loading ${file.name}...`);
try {
// Stream the file body straight to the endpoint (no in-memory copy).
const response = await fetch(appendEnvParam('/api/images/load', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/x-tar' },
body: file
});
const data = await response.json();
if (data?.success) {
toast.success(data.loaded || `Loaded image from ${file.name}`, { id: t });
await fetchImages();
} else {
toast.error(data?.error || 'Failed to load image', { id: t });
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load image', { id: t });
} finally {
loadingImage = false;
}
}
// Run modal state
let showRunModal = $state(false);
let prefilledImage = $state('');
@@ -1032,6 +1065,19 @@
Pull
</Button>
{/if}
{#if $canAccess('images', 'load')}
<Button size="sm" variant="outline" onclick={() => loadFileInput?.click()} disabled={loadingImage}>
<Upload class="w-3.5 h-3.5 mr-1.5" />
{loadingImage ? 'Loading...' : 'Load from tar'}
</Button>
<input
bind:this={loadFileInput}
type="file"
accept=".tar,application/x-tar"
class="hidden"
onchange={handleLoadTar}
/>
{/if}
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
</div>
{/if}
@@ -176,6 +176,7 @@
images: [
{ key: 'view', label: 'View images' },
{ key: 'pull', label: 'Pull images' },
{ key: 'load', label: 'Load images from tar' },
{ key: 'push', label: 'Push images' },
{ key: 'remove', label: 'Remove images' },
{ key: 'build', label: 'Build images' },