Uploaded images
Upload a container image — a docker save archive or an OCI image-layout tar — or a WebAssembly module (.wasm) through the console, the API or runr images push, and RunR pushes it into a region's registry. The file travels through RunR to the region, chunk by chunk, so a region that has no route to the internet (or to any registry outside it) gets its images this way. An uploaded image is then used like any other:
- as the image of a CaaS project (the CaaS form offers the region's uploaded images next to a registry reference);
- as the image of a runtime version (
upload:in the runtime manifest, Runtimes); - as the module of a wasm-exec function (
wasm.upload, WebAssembly) or of a WebAssembly runtime that ships an interpreter (Q on Wasm,examples/wasm/q-exec).
Uploaded container images go through the supply chain like the images RunR builds: SBOM, vulnerability scan, signature.
- How it works
- Formats
- Upload
- Resume and verification
- Using uploaded images
- Supply chain and data residency
- Quota, replacing and deleting
- Air-gapped regions
- Permissions
- Audit
- API
- Configuration
- Data model
- Testing
How it works#
console / runr images push / API
└─ POST /api/images/uploads {knative_region_id, filename, size, sha256, name}
└─ region: Job image-recv-<id> (runr-image-upload serve) + Service, emptyDir /work
└─ PUT /api/images/uploads/{id}/chunk?offset=N (raw bytes, 8 MiB)
└─ RunR API → Kubernetes API server → service proxy → receiver: written at offset N
(nothing is stored on the RunR API's disk; the region needs no route to RunR)
└─ POST /api/images/uploads/{id}/complete
└─ receiver: size + SHA-256 → format → push into the region registry
<registry>/runr-uploads/<owner>/<repository>:<tag> → digest read back from the registry
└─ CheckImageUploadJob (every 5 s) reads the receiver's status
└─ uploaded_images row, audit event, supply-chain run, receiver removed
The receiver (runtime/image-upload, image ghcr.io/brf-tech/runr/image-upload) is one static Go binary built on go-containerregistry. Its pod has no Kubernetes API access, runs as a non-root user with a read-only root filesystem and no capabilities, and uses the region's build RuntimeClass — gVisor or Kata where builds are sandboxed, since archive content from users is unpacked there.
The repository in the registry belongs to the owner of the upload: runr-uploads/<tenant id>/… for an organization of a multi-tenant install, runr-uploads/platform/… outside a tenant (single-tenant installs, and platform administrators in the central console). Users see and use their own owner's images only.
Formats#
| File | Detected by | Pushed as |
|---|---|---|
docker save archive (Docker 25 and later) |
oci-layout + index.json in the tar |
the OCI layout's image or image index, digests unchanged |
docker save archive (older Docker, manifest.json only) |
manifest.json |
the image of the archive; the registry gets compressed layers, so the digest is the one of the pushed image |
OCI image-layout tar (skopeo copy … oci-archive:, oras cp --to-oci-layout, crane pull --format=oci + tar) |
oci-layout + index.json |
the layout's image or index as it is |
any of these, gzip'ed (.tar.gz, .tgz) |
gzip magic | decompressed, then as above |
WebAssembly module or component (.wasm) |
\0asm |
an OCI artifact: one application/wasm layer titled module.wasm, config application/vnd.wasm.config.v0+json — the same file always gives the same digest |
Spin app (spin registry push, then oras cp --to-oci-layout + tar) |
config application/vnd.fermyon.spin.application.v1+config |
as it is, kind spin |
Multi-platform images. An image index is pushed with every platform. docker save of a multi-platform image may carry the layers of the local platform only; the receiver then pushes the platforms whose blobs are all in the archive — one as that platform's image, several as a new index — and the uploaded image's note says which. To keep every platform, export the index:
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 --output type=oci,dest=myapp.tar .
# or, from a registry you can reach:
skopeo copy --all docker://registry.example.com/myapp:1.0 oci-archive:myapp.tar
Archives with several images. A layout or archive holding several images is narrowed to the one named like the upload's tag (org.opencontainers.image.ref.name / io.containerd.image.name, or the RepoTags of a classic archive); otherwise the upload fails asking for one image per file.
Upload#
Console: Settings → Images → Upload image: region, file, name (repository[:tag], from the file name by default) and format (detected). The dialog shows the progress, the verification and the push; unfinished uploads stay listed with Resume and Abort.
CLI (CLI):
docker save myapp:1.0 -o myapp.tar
runr images push myapp.tar --region eu-1 --name myapp:1.0
runr images ls
runr images rm myapp:1.0 --region eu-1
API (any HTTP client; $API is https://<console>/api):
size=$(stat -c %s myapp.tar); sum=$(sha256sum myapp.tar | cut -d' ' -f1)
id=$(curl -sS -X POST "$API/images/uploads" -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d "{\"knative_region_id\":\"$REGION\",\"filename\":\"myapp.tar\",\"size\":$size,\"sha256\":\"$sum\",\"name\":\"myapp:1.0\"}" | jq -r .data.id)
# wait until GET $API/images/uploads/$id answers status "ready", then send 8 MiB chunks:
split -b 8388608 -d myapp.tar part.
offset=0
for part in part.*; do
curl -sS -X PUT "$API/images/uploads/$id/chunk?offset=$offset" -H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/octet-stream' --data-binary @"$part"
offset=$((offset + $(stat -c %s "$part")))
done
curl -sS -X POST "$API/images/uploads/$id/complete" -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
# poll GET $API/images/uploads/$id until status is "completed" (data.image) or "failed" (data.message)
A WebAssembly module goes the same way: runr images push q.wasm --name q-interpreter:1.0.
Resume and verification#
- Chunks are written at their offset: they may arrive in any order and be sent again. The receiver merges the byte ranges it has;
uploaded_bytesis the part from byte 0 without a gap — where a client goes on after a reload or a broken connection (the console andrunr images pushdo this by themselves:pushfinds the unfinished upload of the same file by name, region, size and SHA-256). completeis refused until every byte is there (code: upload_incomplete, withuploaded_bytes).- The receiver checks the size and the SHA-256 the upload declared. A mismatch reopens the upload with nothing received (status
ready, the message names both digests): send the file again. - The registry checks every blob against its digest when it is pushed, so a damaged layer fails the push; afterwards the receiver reads the tag back and requires the digest of what it pushed.
- An upload not completed within
RUNR_IMAGE_UPLOAD_TTL_HOURS(24) is aborted and its receiver removed (hourly).
Using uploaded images#
| Where | How | Reference |
|---|---|---|
| CaaS project | create form, step 3: Uploaded image — the region's container images; or config.image in POST /api/projects |
image_ref: <registry>/runr-uploads/<owner>/<repository>@sha256:…, pinned by digest |
| Runtime version | versions: [{version: "5.2", upload: "bash-airgap:5.2"}]; Settings → Runtimes → Version from an uploaded image |
resolved per region to the owner's upload of that name, by digest (Runtimes) |
| wasm-exec function | a FaaS file wasm.upload with one line, q-interpreter:1.0 |
the module is fetched into the build instead of compiling (WebAssembly) |
| WebAssembly runtime on an uploaded interpreter | a platform runtime whose version is upload:, built in build.image |
the module arrives in the build as RUNR_WASM_MODULE |
References into runr-uploads/ of the region registry are checked: a CaaS project may run an uploaded image of its own organization in its own region only, and registering a runtime with an upload: version requires that image in every region the runtime is offered in (platform runtimes name platform uploads, custom runtimes their organization's). A deploy or a build in a region without the image fails with a message naming the region.
Supply chain and data residency#
- Scan and signature. When an upload of a container image finishes, the region's supply-chain pipeline runs on it (report subject
uploaded_image, kinduploaded): SBOM, SBOM referrers, vulnerability scan, signature and attestations as the region's settings define — the full pipeline of a build, since the image is in RunR's own registry. Settings → Images shows the scan; re-scans follow the region's interval. A CaaS version that runs an uploaded image gets its own report of the same kind, so the policy gate (and Verify the signature on deploy) applies to it like to a build. The admission policy'srunr-signed-imagescovers<registry>/runr-uploads/**. WebAssembly modules are not scanned (no container filesystem). - Data residency. Starting an upload checks the organization's residency policy for the region (action
image.upload); a refusal answers 422 withcode: region_not_allowed.
Quota, replacing and deleting#
- Quota. An organization (the install, when single-tenant) stores up to
RUNR_IMAGES_QUOTA_GB(50 GiB) of uploaded images over every region: the size of its images plus the declared size of uploads still running. An upload that does not fit is refused (code: quota_exceeded); replacing an image of the same name gives its size back first. Settings → Images andGET /api/images/quotashow the use. - Replacing. Uploading the same
repository:taginto the same region again replaces the image; the manifest it replaced is removed from the registry unless another tag of the owner points at the same digest. - Deleting.
DELETE /api/images/{id}(Settings → Images,runr images rm) removes the record and the manifest in the region registry (RegistryImageLifecycleService, as the build retention removes old builds; Zot frees the blobs with its garbage collection). It is refused (409,code: image_in_use) while a CaaS project or a runtime version still uses the image — the answer names them.
Air-gapped regions#
- On a machine that reaches the image's registry:
docker pull myapp:1.0 && docker save myapp:1.0 -o myapp.tar(orskopeo copy --all docker://… oci-archive:myapp.tarfor every platform). - Bring the file to a machine that reaches the RunR console, and upload it (console, CLI or API).
- The region pulls nothing from outside: the receiver image itself must be in a registry the region reaches — mirror
ghcr.io/brf-tech/runr/image-upload:1.0.0into the region registry or your own mirror and setRUNR_IMAGE_UPLOAD_IMAGE(Helmimages.uploadImage). - Run it: a CaaS project on the uploaded image, or a runtime version / wasm-exec function on it.
Permissions#
| Permission | Grants | Built-in roles |
|---|---|---|
images.view |
the organization's uploaded images, their scans, the quota; the pickers in the CaaS form and the runtime editor | Owner, Admin, Developer, Viewer |
images.upload |
start, resume, complete and abort uploads | Owner, Admin, Developer |
images.delete |
delete uploaded images (and their manifests) | Owner, Admin, Developer |
Using an uploaded image in a project or a runtime needs nothing beyond changing that project or runtime (Roles and permissions).
Audit#
| Action | When |
|---|---|
image.upload.start |
POST /api/images/uploads (region, file name, size, format, whether a SHA-256 was given) |
image.upload.complete |
POST …/complete |
image.upload.abort |
DELETE /api/images/uploads/{id} |
image.delete |
DELETE /api/images/{id} (whether the manifest left the registry, what still used it when refused) |
image.upload.pushed |
the region pushed the image: name, digest, kind, format, size, the digest it replaced (recorded by ImageUploadService) |
image.upload.failed |
the push failed, with the region's reason |
Chunks are not audited (one event per upload is enough); the upload's start, completion and result are.
API#
All under /api, authenticated with a session or an API token; rules in api/config/route_permissions.php.
| Method & path | Permission | Purpose |
|---|---|---|
GET /images?knative_region_id=&kind=image|wasm|spin |
images.view | {data: UploadedImage[], quota: {limit_bytes, used_bytes, images}} |
GET /images/quota |
images.view | {limit_bytes, used_bytes, images} |
GET /images/{id} |
images.view | {data: UploadedImage} |
DELETE /images/{id} |
images.delete | {deleted, registry_removed, id, name}; 409 image_in_use with usages: {projects, runtimes} |
GET /images/uploads |
images.upload | the open uploads |
POST /images/uploads |
images.upload | {knative_region_id, filename, size, sha256?, name?, format?: auto|docker|oci|wasm} → 201 {data: ImageUpload}; 422 registry_unavailable / quota_exceeded / region_not_allowed, 409 upload_open |
GET /images/uploads/{id} |
images.upload | {data: ImageUpload} (status preparing, ready, verifying, pushing, completed with image, failed with message, aborted) |
PUT /images/uploads/{id}/chunk?offset=N |
images.upload | raw body (application/octet-stream, at most chunk_size) → {uploaded_bytes, size_bytes}; 409 upload_not_ready |
POST /images/uploads/{id}/complete |
images.upload | 202 {data: ImageUpload}; 422 upload_incomplete |
DELETE /images/uploads/{id} |
images.upload | 204 |
An uploaded image:
{
"id": "01JB…", "name": "myapp:1.0", "repository": "myapp", "tag": "1.0",
"knative_region_id": "01JA…", "region_name": "eu-1",
"image_ref": "10.43.0.5:5000/runr-uploads/platform/myapp@sha256:…",
"tag_ref": "10.43.0.5:5000/runr-uploads/platform/myapp:1.0",
"digest": "sha256:…", "media_type": "application/vnd.oci.image.index.v1+json",
"kind": "image", "source_format": "oci", "size_bytes": 52428800, "archive_bytes": 60000000,
"platforms": ["linux/amd64", "linux/arm64"], "layers": 4, "source_filename": "myapp.tar",
"note": null, "security": {"status": "completed", "scan_status": "ok", "counts": {"critical": 0, "high": 2}},
"created_at": "2026-10-11T08:00:00Z"
}
Configuration#
| Variable (Helm value) | Default | Meaning |
|---|---|---|
RUNR_IMAGE_UPLOAD_IMAGE (images.uploadImage) |
ghcr.io/brf-tech/runr/image-upload:1.0.0 |
the receiver image (runtime/ci.sh image-upload [--push]) |
RUNR_IMAGES_QUOTA_GB (images.quotaGb) |
50 |
storage per organization over every region, 0 = unlimited |
RUNR_IMAGE_UPLOAD_MAX_GB (images.maxUploadGb) |
20 |
largest file of one upload, in GiB |
RUNR_IMAGE_UPLOAD_TTL_HOURS (images.uploadTtlHours) |
24 |
an upload not completed within this time is aborted |
RUNR_IMAGE_UPLOAD_CHUNK_BYTES (via extraEnv) |
8388608 |
chunk size the clients use, at least 1 MiB (the API takes request bodies up to 64 MiB) |
RUNR_IMAGE_UPLOAD_PUSH_TIMEOUT (via extraEnv) |
3600 |
seconds the push may take after the verification, at least 60 |
RUNR_IMAGE_UPLOAD_CPU_REQUEST / _MEMORY_REQUEST / _CPU_LIMIT / _MEMORY_LIMIT (via extraEnv) |
100m / 128Mi / 2 / 1Gi |
the receiver container |
The images.* Helm values are empty by default, which keeps the defaults above (api/config/images.php).
The receiver's emptyDir (and ephemeral-storage request) is twice the file plus 512 MiB — the file and, for OCI layouts and gzip'ed archives, its extracted copy; a .wasm needs the file only. Uploads need the region's internal registry (registry_mode: internal, status ready).
Data model#
| Table | Schema | Holds |
|---|---|---|
uploaded_images |
central | owner (owner_key: tenant id or platform), region, repository, tag, registry repository, digest, media type, kind, format, sizes, platforms, layers, source file, uploader; unique per owner, region, repository and tag |
image_uploads |
central | one upload: owner, region, name, file, declared size and SHA-256, bytes received, chunk size, status, receiver, message, the resulting image |
The supply-chain reports of uploaded images are in image_security_reports of the central schema.
Testing#
- Receiver:
cd runtime/image-upload && go vet ./... && go test ./...— chunks in any order and the contiguous prefix, refused chunks, SHA-256 mismatch, failed pushes; pushes into an in-memory OCI registry ofdocker savearchives (gzip'ed, several images), OCI layouts, multi-platform indexes (complete and partial),.wasmmodules and the fetch of a module. - API:
cd api && php artisan test tests/Feature/Images tests/Unit/Images tests/Feature/Runtimes/RuntimeUploadSourceTest.php tests/Feature/Wasm/WasmExecTest.php tests/Unit/Wasm/WagiSpinManifestTest.php— the receiver Job and Service, chunk forwarding and resume, complete → push → uploaded image, replacing a name, SHA-256 mismatch, failed pushes, abort / expiry, registry / quota / residency checks, permissions; listing, deletion with the manifest and the in-use refusal, owner scoping, CaaS validation and the supply-chain kind; the runtimeuploadsource; wasm-exec builds. - CLI:
cd cli && go test ./internal/cmd ./pkg/runr -run 'Images|NameFromFilename|FindImage|PushImage'against the fake API. - UI:
cd vue && npx vitest run tests/modules/imagesandnpx playwright test e2e/images.spec.ts --project=mock. - End to end:
KIND_LOCK=/tmp/runr-kind.lock bash runtime/ci.sh test-images [--k8s=X.Y.Z] [--no-cleanup]uploads adocker savearchive andq.wasminto a Kind region, runs a CaaS project from the upload, a wasm-exec function in C and a Q (Wasm) function, and deletes everything again.