Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to virtkit will be documented in this file.

## [Unreleased]

### Added

- **`vk-registry` serves its store over WebDAV under `/dav/`.** `/dav/repos/` is a
read-only view of every repository the credential may read — tags and manifests download
as manifests, blobs as their bytes — and `/dav/files/` is a plain-file area any WebDAV
client can write to, scoped per top-level directory (`write:files/sccache`). Point
`SCCACHE_WEBDAV_ENDPOINT` at `https://<registry>/dav/files/<dir>` and every runner's jobs
share one cache of compiled units over the registry's existing TLS and credentials; a
read-only credential gives a pipeline the hits without letting it write. Objects are
stored in the OCI pool as raw-file manifests under `files/<dir>/…`, dedup and compress
with everything else, and expire under `gc`'s tag retention like any other tag. WebDAV is
enabled by default; set `webdav = false` in the server config to disable it.

## [0.72.0] - 2026-09-15

### Added
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,9 +605,16 @@ boundaries and interpretation.
### Caching and registries

Local image conversion and build caches require no server. `vk-registry` is optional and
is useful when several runners need a shared OCI store, pull-through cache, or build-once
coordination. Its lease and heartbeat protocol prevents runners from independently
building the same content while a healthy peer is already doing so.
is useful when several runners need a shared OCI store, pull-through cache, build-once
coordination, or a shared compiler cache. The whole store is also served over WebDAV under
`/dav/`, behind the same TLS and credentials as the rest of the server: `/dav/repos/` is a
read-only view of every repository's tags, manifests and blobs, and `/dav/files/` is a
plain-file area where an `sccache` pointed at `/dav/files/<dir>` lets jobs in throwaway
microVMs reuse each other's compiled units; the objects are stored in the OCI pool under
`files/<dir>` and expire under `vk-registry gc`'s tag retention like any other tag. Set
`webdav = false` in the server config to disable WebDAV. Its lease and
heartbeat protocol prevents runners from independently building the same content while
a healthy peer is already doing so.

Use `vk registry push|pull|inspect` for guest bundles and `vk registry status|gc` for a
local store. The central server and storage model are documented in
Expand All @@ -619,7 +626,7 @@ local store. The central server and storage model are documented in
| --- | --- |
| `vk` | Host CLI, VMM, image builder, userspace network, compose runner, and GitLab executor. It embeds the default guest kernel and `vk-agent`. |
| `vk-agent` | Guest PID 1 and command server. It configures mounts, networking, hostname, shared directories, optional SSH, and host-driven execution over vsock. |
| `vk-registry` | Optional OCI-distribution server with a pull-through cache, shared build cache, and build-once locking. |
| `vk-registry` | Optional OCI-distribution server with a pull-through cache, a WebDAV view of the store with a plain-file area for compiler caches, and build-once locking. |
| `vk-runnerctl` | Optional root-side helper that adjusts GitLab runner concurrency within an administrator-configured range. |

## Architecture
Expand Down
5 changes: 5 additions & 0 deletions vk-driver/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3216,6 +3216,7 @@ mod tests {
locks: vk_registry::lock::LockManager::new(),
auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None),
tls: None,
webdav: true,
}));
let rg = Registry::for_share(url, true, None, String::new(), None, None, None);
assert_eq!(
Expand Down Expand Up @@ -3266,6 +3267,7 @@ mod tests {
locks: vk_registry::lock::LockManager::new(),
auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None),
tls: None,
webdav: true,
}));
// plain HTTP on loopback, which `for_share`'s `insecure` flag is for. The directory
// push is the path that reaches `push_file`, and it goes through a `Config`.
Expand Down Expand Up @@ -3520,6 +3522,7 @@ mod tests {
locks: vk_registry::lock::LockManager::new(),
auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None),
tls: None,
webdav: true,
}));
let remote = Registry::for_share(url, true, None, String::new(), None, None, None);
for rg in [&local, &remote] {
Expand Down Expand Up @@ -3558,6 +3561,7 @@ mod tests {
locks: vk_registry::lock::LockManager::new(),
auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None),
tls: None,
webdav: true,
}));
let remote = Registry::for_share(url, true, None, String::new(), None, None, None);
// Manifest PUT returns a `Location` URL. Callers need the digest to pin
Expand Down Expand Up @@ -3591,6 +3595,7 @@ mod tests {
locks: vk_registry::lock::LockManager::new(),
auth: vk_registry::Authenticator::Shared(vk_registry::auth::Auth::None),
tls: None,
webdav: true,
}));
let remote = Registry::for_share(url, true, None, String::new(), None, None, None);
// A dense parent, then an untouched overlay with one dirty cluster: the diff
Expand Down
122 changes: 116 additions & 6 deletions vk-registry/DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# vk-registry design

`vk-registry` is a central OCI Distribution server for virtkit runners. It provides three
`vk-registry` is a central OCI Distribution server for virtkit runners. It provides four
services behind one listener:

- a content-addressed OCI store shared by all runners;
- a pull-through cache for upstream registries; and
- a leased lock service that coordinates build-once work across runners.
- a pull-through cache for upstream registries;
- a leased lock service that coordinates build-once work across runners; and
- a WebDAV view of the store, with a plain-file area that build caches such as
`sccache`'s write to — stored in the same pool as everything else.

The server is intended to run on a dedicated host or as a user service. Local virtkit use
does not require it: `vk` uses the same `Store` implementation directly for its default
Expand All @@ -19,8 +21,8 @@ scope.
## Architecture

The `vk-registry` crate contains both the reusable store library and the server binary.
The library provides the store, OCI routes, pull-through relay, build lock service, and
authentication. In accounts mode it also provides OIDC login, browser, upload, and local
The library provides the store, OCI routes, pull-through relay, build lock service, WebDAV
view, and authentication. In accounts mode it also provides OIDC login, browser, upload, and local
administration surfaces.

The binary provides `serve`, `status`, `gc`, `install-service`, `accounts`, and `update`.
Expand All @@ -42,11 +44,15 @@ Repository names and tags are metadata over that shared blob pool.
repos/<name>/tags/<tag> manifest digest referenced by the tag
repos/<name>/manifests/<hex> manifest media type and repository membership
repos/<name>/blobs/<hex> blob membership marker
uploads/<id> in-progress upload
uploads/<id> in-progress upload, or a WebDAV PUT being staged
uploads/owners/<id> repository that opened the upload
accounts/accounts.db account data, when accounts mode is enabled
```

The plain-file area `/dav/files/` has no storage of its own: an object is a tag on a
raw-file manifest in a repository under `files/`, and its bytes a blob in the pool. See
"WebDAV view".

The `sha256` and `zstd` directories are two physical encodings of the same logical
namespace. A digest always identifies the uncompressed bytes. Deduplication is therefore
independent of whether a client or the server performed compression.
Expand Down Expand Up @@ -388,6 +394,97 @@ The normal build-once sequence for content key `K` is:
5. Build and push on a miss, or record the failure.
6. Release the lease.

## WebDAV view (`/dav/`)

The whole store is reachable over WebDAV under one root, through the registry's existing
listener, TLS and client auth, with the permission model the OCI API enforces. WebDAV is
enabled by default. Set `webdav = false` to disable it: `/dav/` requests return 404 after
authentication, and the server logs this setting at startup.

The verb set is what opendal's `webdav` service issues — the client behind `sccache`, `oli`
and other opendal-based tools: `PROPFIND` at `Depth` 0 and 1, `GET`, `HEAD`, `PUT`, `MKCOL`, `DELETE`
and `OPTIONS`. `Depth: infinity` is refused with 403, as RFC 4918 allows; `COPY`, `MOVE`,
`LOCK` and `PROPPATCH` are 405. No client XML is parsed: a `PROPFIND` body is drained and
ignored, `allprop` being both what the client sends and what an empty body means.

```text
/dav/ repos/ files/
/dav/repos/<name>/ tags/ manifests/ blobs/ (+ nested repositories)
/dav/repos/<name>/tags/<tag> the manifest the tag resolves to, with its media type
/dav/repos/<name>/manifests/<hex> that manifest
/dav/repos/<name>/blobs/<hex> the blob's canonical bytes; a stored zstd frame is decoded
/dav/files/<dir>/<path…> plain files: repositories and tags under files/, read-write
```

**`repos/` is a read-only view, not an export.** The disk tree is not what a client wants:
a stored blob may be a zstd frame, a tag file holds a digest rather than a manifest, and the
blob pool is readable per repository, not as one directory. So tags and manifests download
as the manifest bytes with their media type and blobs as their canonical bytes, through the
same handlers `/v2/` uses and under the same authorization — `Read` on the repository, and
membership for anything addressed by digest. The tree is derived from the list of
repositories the principal may read, never from a `read_dir` of `repos/`: a repository the
principal cannot read is a 404, as `/browse` answers, and a scope such as `read:team-a/*`
lists `team-a/` and nothing beside it. Every write verb there is 405: an OCI write verifies
a digest, records membership and holds the store lock, and a DAV client cannot supply a
manifest's media type. Those go through `/v2/`. A listing of `blobs/` carries each member's
canonical length, which for a zstd-stored blob is one `open` and a frame-header read; a
repository of chunked bundles lists thousands of members, so that listing is an
interactive operation and never on a CI path.

**`files/` is a directory tree over the pool.** A path's directories are a repository under
`files/` and its leaf a tag on a single-layer raw-file manifest, the shape `/upload` writes:
`/dav/files/sccache/a/b/abcdef` is the tag `abcdef` of the repository `files/sccache/a/b`,
and its bytes are the layer blob. An object therefore dedups, compresses and is collected
like any other content, and is reachable over `/v2/` and `/browse` under that name. A
top-level directory authorizes as the repository `files/<dir>` for the whole tree below it:
`GET`, `HEAD`, `PROPFIND` and `OPTIONS` are reads, `PUT`, `MKCOL` and `DELETE` writes, so
scopes such as `write:files/*` or `read:files/sccache` apply unchanged. Nothing in the
server knows what `sccache` is; it is a directory that a compiler cache happens to write to.
Every component obeys the OCI name rules — `[A-Za-z0-9._-]`, at most 16 components counting
`files` and `<dir>`, none named `tags`, `manifests` or `blobs` — and anything else is 400. A
tag whose manifest is not a raw file, an image pushed over `/v2/` into a `files/`
repository, is absent from this view.

| Verb on `files/` | Answer |
|---|---|
| `GET`, `HEAD` | 200 with the layer blob (`application/octet-stream`, `nosniff`, `Content-Disposition: attachment`, `Last-Modified` from the tag); a directory or a missing path is 404. A `GET` refreshes the tag's mtime once it is an hour old — the use the gc's retention keys on; a `HEAD` does not |
| `PUT` | streamed to `uploads/` and hashed on the way, then promoted into the pool and tied to the tag by a manifest under the shared store lock — 201 when the tag is new, 204 when it is replaced, the previous content left for the gc; 413 past 4 GiB; 405 for directories and top-level names, after draining the body |
| `PROPFIND` | 207 for an object or a directory; at `Depth: 1` the nested repositories and the tags follow it, each object with `getcontentlength` (the layer's canonical size) and `getlastmodified` (the tag's), which opendal requires; 404 when absent |
| `MKCOL` | 201, creating an empty repository (a `tags/` with nothing in it) and its missing ancestors — a top-level directory included, which is how one comes to exist; 405 when a directory is at the name; 409 when an object is |
| `DELETE` | 204 for an object (the tag goes; the bytes wait for the gc) or a directory holding no tag and no repository; 403 for a directory with members; 404 when absent |
| `OPTIONS` | `DAV: 1` and the `Allow` list; every other verb is 405 with the list the resource serves |

A `PUT` answered early still reads its body through: a status sent with request bytes still
unread closes the socket with a reset, and `sccache` takes a reset on its startup probe as
an unwritable store and runs the whole build read-only. A read-only key makes that probe
fail with 403, which the client reports as read-only mode: an untrusted pipeline consumes
the cache without writing to it. Since a writer with `Write` on a directory owns its
content, give write access only to trusted pipelines (protected branches), hand everything
else a read-only key, and use one directory per trust level when that is not enough.

Paths are split on raw `/` before each component is percent-decoded on its own, so `%2F`
cannot smuggle a separator; `.`, `..`, empty, over-long (255 bytes) and control-byte
components, and depth past 32 (16 under `files/`, the repository name bound), are refused.
Hrefs in a 207 are rebuilt from the decoded
components, percent-encoded, with a trailing slash on a collection. `/dav/` is not a human
path, so an unauthenticated client gets the 401 challenge rather than a login redirect.

**Enumeration** is the one disclosure a listing makes beyond what the caller named. A
`Depth: 1` on `/dav/`, `/dav/repos/`, `/dav/files/` or a path component above repositories
shows only what the principal may read, and on a server with no credential configured at
all it is refused with 403, for the reason `/browse` does not exist in shared-secret mode:
a catalog is not something anyone who can reach the port gets for free. `Depth: 0` there,
which is what opendal's parent walk asks, always answers.

### Lifecycle

An object is a tag, so `gc` expires it as it expires every tag: dropped once idle past the
retention window, its blobs swept once unreferenced and past the grace window, both windows
store-wide. Idleness is the tag's mtime, which a `PUT` sets and a `GET` refreshes once it is an
hour old, so a read-only pipeline's hits keep an entry alive as a writer's do. There is no
per-directory policy and no size cap; a replaced or deleted object frees no disk until the
next `gc`. A staging file a crashed `PUT` left in `uploads/` goes with the idle uploads.

## Accounts administration

`vk-registry accounts` manages users, sessions, administrators, and API keys. The command
Expand Down Expand Up @@ -464,6 +561,9 @@ phase does not traverse child manifests. The pass aborts before deleting anythin
indexes can be stored and mounted, but a store containing a live tagged index cannot be
collected until the mark phase supports that graph.

The `/dav/files/` objects are tags and blobs under `files/` and need no pass of their own:
`gc` and `status` treat them as the repositories they are.

## Guest credential proxy

With `vk run --registry-proxy` or `[registry] proxy_guests = true`, the host starts a
Expand All @@ -481,6 +581,16 @@ layers. The feature is opt-in and requires guest networking.
- The lock manager and accounts database assume one server process. Multi-replica operation
requires a distributed lock implementation and a replicated account store.
- Pull-through cache eviction is retention-based; there is no size-capped LRU policy.
- A `files/` directory has no eviction until an operator attaches a policy to it; there is
no default, by design (see "Eviction policy"). Setting a policy needs access to the host
holding the store; an admin-gated HTTP route for it is a natural addition in accounts mode
and does not exist yet.
- The `files/` area trusts its writers: a stored compiler-cache entry is linked into every
project computing the same key, so write access belongs to trusted pipelines only (see
"WebDAV view").
- The store directory must be writable only by trusted local users. Requests and eviction
can follow symlinks in parent directories under `files/`; checks reject only a symlink at
the final path component. Preventing this requires descriptor-relative path resolution.
- Chunk boundaries are client-defined. Clients using different chunkers share the blob pool
but may not deduplicate the same artifact effectively.
- Expired sessions are removed when presented, not by a periodic sweep.
Expand Down
8 changes: 8 additions & 0 deletions vk-registry/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ pub struct ServerConfig {
/// `[oidc]`, required in `mode = "accounts"` — it is the only login path that mode
/// has.
pub oidc: Option<OidcSpec>,
/// Enable `/dav/` (default: true). When disabled, requests return 404 after auth.
/// File eviction continues regardless.
pub webdav: bool,
}

/// The `[oidc]` config table, as declared (before its client secret is read and checked
Expand Down Expand Up @@ -133,6 +136,8 @@ struct FileConfig {
accounts_db: Option<PathBuf>,
/// `false` to bind no admin socket, a path to move it, `true` for the default one.
admin_socket: Option<FileAdminSocket>,
/// Enable `/dav/`; defaults to true.
webdav: Option<bool>,
oidc: Option<FileOidc>,
#[serde(default)]
upstream: Vec<FileUpstream>,
Expand Down Expand Up @@ -225,6 +230,7 @@ impl ServerConfig {
accounts_db: None,
admin_socket: AdminSocket::Unset,
oidc: None,
webdav: true,
}
}

Expand Down Expand Up @@ -401,6 +407,7 @@ impl ServerConfig {
client_secret_file: o.client_secret_file,
public_url: o.public_url,
}),
webdav: f.webdav.unwrap_or(true),
};
// Also here, not only in `build_auth`: `load` is where a file becomes a config, so
// a contradictory file is refused by parsing it at all, not only by the path that
Expand Down Expand Up @@ -658,6 +665,7 @@ impl ServerConfig {
locks: LockManager::new(),
auth,
tls: None,
webdav: self.webdav,
})
}
}
Expand Down
7 changes: 7 additions & 0 deletions vk-registry/src/config/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ const KEYS: &[Key] = &[
downtime; false binds none, in accounts mode only\n\
[default: admin.sock beside accounts_db]",
},
Key {
table: Table::Top,
name: "webdav",
help: "enable /dav/; false returns 404 [default: true]",
},
Key {
table: Table::Top,
name: "oidc",
Expand Down Expand Up @@ -152,6 +157,7 @@ root = \"/srv/vk-registry\"
tls_cert = \"/etc/vk-registry/fullchain.pem\"
tls_key = \"/etc/vk-registry/privkey.pem\"
token_file = \"/etc/vk-registry/token\"
webdav = true

[[upstream]]
prefix = \"docker.io\"
Expand All @@ -170,6 +176,7 @@ tls_cert = \"/etc/vk-registry/fullchain.pem\"
tls_key = \"/etc/vk-registry/privkey.pem\"
mode = \"accounts\"
accounts_db = \"/srv/vk-registry/accounts/accounts.db\"
webdav = true

[oidc]
issuer = \"https://id.example.com\"
Expand Down
Loading