From 09fbc1aca7a0df2e66dc556215d3686df938105f Mon Sep 17 00:00:00 2001 From: Andrew Martinez Date: Wed, 27 May 2026 17:58:42 -0500 Subject: [PATCH 1/2] containers: add setLabels API to mutate container labels at runtime Adds a new container.setLabels(labels: Record): Promise JS method, gated behind the workerdExperimental compatibility flag, backed by a new capnp RPC method setLabels @15 (labels :List(Label)) on the Container interface. Each call fully replaces the existing label set; the container must be running. Label-name and label-value validation is shared with start() via a new requireValidLabels() helper. --- src/workerd/api/container.c++ | 41 +++++++++++++++---- src/workerd/api/container.h | 3 ++ src/workerd/io/container.capnp | 3 ++ .../experimental/index.d.ts | 1 + .../generated-snapshot/experimental/index.ts | 1 + 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/workerd/api/container.c++ b/src/workerd/api/container.c++ index 4e1fb2eebb2..03b3bb47b10 100644 --- a/src/workerd/api/container.c++ +++ b/src/workerd/api/container.c++ @@ -54,6 +54,21 @@ kj::Array emptyByteArray() { return kj::heapArray(0); } +void requireValidLabels(jsg::Dict& labels) { + for (auto i: kj::indices(labels.fields)) { + auto& field = labels.fields[i]; + JSG_REQUIRE(field.name.size() > 0, Error, "Label names cannot be empty"); + for (auto c: field.name) { + JSG_REQUIRE(static_cast(c) >= 0x20, Error, + "Label names cannot contain control characters (index ", i, ")"); + } + for (auto c: field.value) { + JSG_REQUIRE(static_cast(c) >= 0x20, Error, + "Label values cannot contain control characters (index ", i, ")"); + } + } +} + capnp::ByteStream::Client makeExecPipe( capnp::ByteStreamFactory& factory, kj::Own output) { return factory.kjToCapnp(capnp::ExplicitEndOutputStream::wrap(kj::mv(output), []() {})); @@ -237,18 +252,10 @@ void Container::start(jsg::Lock& js, jsg::Optional maybeOptions) } KJ_IF_SOME(labels, options.labels) { + requireValidLabels(labels); auto list = req.initLabels(labels.fields.size()); for (auto i: kj::indices(labels.fields)) { auto& field = labels.fields[i]; - JSG_REQUIRE(field.name.size() > 0, Error, "Label names cannot be empty"); - for (auto c: field.name) { - JSG_REQUIRE(static_cast(c) >= 0x20, Error, - "Label names cannot contain control characters (index ", i, ")"); - } - for (auto c: field.value) { - JSG_REQUIRE(static_cast(c) >= 0x20, Error, - "Label values cannot contain control characters (index ", i, ")"); - } list[i].setName(field.name); list[i].setValue(field.value); } @@ -284,6 +291,22 @@ void Container::start(jsg::Lock& js, jsg::Optional maybeOptions) running = true; } +jsg::Promise Container::setLabels(jsg::Lock& js, jsg::Dict labels) { + JSG_REQUIRE(running, Error, "setLabels() cannot be called on a container that is not running."); + + requireValidLabels(labels); + + auto req = rpcClient->setLabelsRequest(); + auto list = req.initLabels(labels.fields.size()); + for (auto i: kj::indices(labels.fields)) { + auto& field = labels.fields[i]; + list[i].setName(field.name); + list[i].setValue(field.value); + } + + return IoContext::current().awaitIo(js, req.sendIgnoringResult()); +} + jsg::Promise> Container::inspect(jsg::Lock& js) { return IoContext::current().awaitIo(js, rpcClient->inspectRequest().send(), [](jsg::Lock& js, diff --git a/src/workerd/api/container.h b/src/workerd/api/container.h index 2bbd745a188..a86e330d133 100644 --- a/src/workerd/api/container.h +++ b/src/workerd/api/container.h @@ -258,6 +258,8 @@ class Container: public jsg::Object { jsg::Promise> inspect(jsg::Lock& js); + jsg::Promise setLabels(jsg::Lock& js, jsg::Dict labels); + // TODO(containers): listenTcp() JSG_RESOURCE_TYPE(Container, CompatibilityFlags::Reader flags) { @@ -278,6 +280,7 @@ class Container: public jsg::Object { JSG_METHOD(exec); JSG_METHOD(interceptOutboundTcp); JSG_METHOD(inspect); + JSG_METHOD(setLabels); } } diff --git a/src/workerd/io/container.capnp b/src/workerd/io/container.capnp index 7fdf41005af..9d8eeac5f26 100644 --- a/src/workerd/io/container.capnp +++ b/src/workerd/io/container.capnp @@ -253,6 +253,9 @@ interface Container @0x9aaceefc06523bca { inspect @14 () -> (info :InspectInfo); # Returns information about the container, or `none` if the container has not been started. + setLabels @15 (labels :List(Label)); + # Replaces the container's current label set with the provided list. + struct InspectInfo { union { none @0 :Void; diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 81bdc3137ab..dbfa432d1b7 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -4004,6 +4004,7 @@ interface Container { exec(cmd: string[], options?: ContainerExecOptions): Promise; interceptOutboundTcp(addr: string, binding: Fetcher): Promise; inspect(): Promise; + setLabels(labels: Record): Promise; } interface ContainerDirectorySnapshot { id: string; diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index 2ac8e8855d4..723e503ec27 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -4010,6 +4010,7 @@ export interface Container { exec(cmd: string[], options?: ContainerExecOptions): Promise; interceptOutboundTcp(addr: string, binding: Fetcher): Promise; inspect(): Promise; + setLabels(labels: Record): Promise; } export interface ContainerDirectorySnapshot { id: string; From 5ba0ea4ef6f4f7c2bd048f75ea6b1ce812137266 Mon Sep 17 00:00:00 2001 From: Andrew Martinez Date: Wed, 27 May 2026 22:29:26 -0500 Subject: [PATCH 2/2] containers: store local labels in memory Keep the local ContainerClient label state entirely in memory so setLabels() reflects the real runtime behavior by actually replacing the current label set, without depending on Docker object labels. --- src/workerd/io/container.capnp | 3 +- src/workerd/server/container-client.c++ | 70 +++-- src/workerd/server/container-client.h | 7 + .../server/tests/container-client/test.js | 289 +++++++++++++++++- 4 files changed, 329 insertions(+), 40 deletions(-) diff --git a/src/workerd/io/container.capnp b/src/workerd/io/container.capnp index 9d8eeac5f26..441577ae957 100644 --- a/src/workerd/io/container.capnp +++ b/src/workerd/io/container.capnp @@ -261,7 +261,8 @@ interface Container @0x9aaceefc06523bca { none @0 :Void; started :group { labels @1 :List(Label); - # Echo of StartParams.labels. Empty list means start() was called with no labels. + # Current in-memory label set. Initially populated from StartParams.labels and replaced by + # setLabels(). Empty list means the current set is empty. } } } diff --git a/src/workerd/server/container-client.c++ b/src/workerd/server/container-client.c++ index 69f2f0c6201..50f9e500166 100644 --- a/src/workerd/server/container-client.c++ +++ b/src/workerd/server/container-client.c++ @@ -49,10 +49,6 @@ constexpr kj::StringPtr SNAPSHOT_CLONE_VOLUME_PREFIX = "workerd-snap-clone-"_kj; constexpr kj::StringPtr CONTAINER_SNAPSHOT_IMAGE_PREFIX = "workerd-container-snap-"_kj; constexpr kj::StringPtr SNAPSHOT_VOLUME_CREATED_AT_LABEL = "dev.workerd.snapshot-created-at"_kj; -// Prefix applied to user-supplied labels when writing them to the Docker container, and -// stripped back out when reading them via inspect(). Lets us distinguish labels the worker -// set via start() from labels that came from the image (via Dockerfile LABEL) or engine. -constexpr kj::StringPtr WORKERD_LABEL_PREFIX = "workerd-"_kj; constexpr auto SNAPSHOT_STALE_AGE = 30 * kj::DAYS; // Maximum size of a snapshot tar archive held in memory during snapshot create/restore. @@ -1545,20 +1541,11 @@ kj::Promise> ContainerClient::inspec bool running = status == "running" || status == "restarting"; kj::Vector