Skip to content

Commit dcc3c4c

Browse files
committed
CC-6863: Add created-at volume labels and warn users about old volumes
1 parent 1399da0 commit dcc3c4c

5 files changed

Lines changed: 94 additions & 7 deletions

File tree

src/workerd/api/container.c++

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
#include "container.h"
66

7-
#include <cmath>
8-
97
#include <workerd/api/http.h>
108
#include <workerd/io/features.h>
119
#include <workerd/io/io-context.h>
1210

11+
#include <cmath>
12+
1313
namespace workerd::api {
1414

1515
// =======================================================================================
@@ -68,8 +68,7 @@ void Container::start(jsg::Lock& js, jsg::Optional<StartupOptions> maybeOptions)
6868
auto snapshot = list[i];
6969
double size = snapshots[i].size;
7070
JSG_REQUIRE(std::isfinite(size) && size >= 0 &&
71-
size <= static_cast<double>((1ull << 53) - 1) &&
72-
std::floor(size) == size,
71+
size <= static_cast<double>((1ull << 53) - 1) && std::floor(size) == size,
7372
RangeError, "Snapshot size must be a non-negative integer <= Number.MAX_SAFE_INTEGER");
7473
snapshot.setId(snapshots[i].id);
7574
snapshot.setSize(static_cast<uint64_t>(size));
@@ -104,7 +103,8 @@ jsg::Promise<DirectorySnapshot> Container::snapshotDirectory(
104103

105104
return IoContext::current()
106105
.awaitIo(js, req.send())
107-
.then(js, [](jsg::Lock& js, capnp::Response<rpc::Container::SnapshotDirectoryResults> results) {
106+
.then(
107+
js, [](jsg::Lock& js, capnp::Response<rpc::Container::SnapshotDirectoryResults> results) {
108108
auto snapshot = results.getSnapshot();
109109
jsg::Optional<kj::String> name = kj::none;
110110
auto snapshotName = snapshot.getName();

src/workerd/api/container.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ class Container: public jsg::Object {
8787
jsg::Promise<void> interceptOutboundHttp(
8888
jsg::Lock& js, kj::String addr, jsg::Ref<Fetcher> binding);
8989
jsg::Promise<void> interceptAllOutboundHttp(jsg::Lock& js, jsg::Ref<Fetcher> binding);
90-
jsg::Promise<DirectorySnapshot> snapshotDirectory(jsg::Lock& js, SnapshotDirectoryOptions options);
90+
jsg::Promise<DirectorySnapshot> snapshotDirectory(
91+
jsg::Lock& js, SnapshotDirectoryOptions options);
9192

9293
// TODO(containers): listenTcp()
9394

src/workerd/server/container-client.c++

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
#include <kj/exception.h>
2626
#include <kj/string.h>
2727

28+
#include <atomic>
29+
2830
namespace workerd::server {
2931

3032
namespace {
@@ -35,13 +37,17 @@ constexpr uint16_t SIDECAR_INGRESS_PORT = 39001;
3537
constexpr uint64_t MAX_JSON_RESPONSE_SIZE = 16ULL * 1024 * 1024;
3638

3739
constexpr kj::StringPtr SNAPSHOT_VOLUME_PREFIX = "workerd-snap-"_kj;
40+
constexpr kj::StringPtr SNAPSHOT_VOLUME_CREATED_AT_LABEL = "dev.workerd.snapshot-created-at"_kj;
41+
constexpr auto SNAPSHOT_STALE_AGE = 30 * kj::DAYS;
3842

3943
// Maximum size of a snapshot tar archive held in memory during snapshot create/restore.
4044
constexpr size_t MAX_SNAPSHOT_TAR_SIZE = 1ULL * 1024 * 1024 * 1024; // 1 GiB
4145

4246
static_assert(static_cast<double>(MAX_SNAPSHOT_TAR_SIZE) == MAX_SNAPSHOT_TAR_SIZE,
4347
"MAX_SNAPSHOT_TAR_SIZE must be exactly representable as double");
4448

49+
std::atomic<bool> staleSnapshotVolumeCheckScheduled = false;
50+
4551
// Validate a snapshot directory path. Rejects relative paths, embedded null bytes,
4652
// and path traversal components (".."). Returns the validated parent directory path
4753
// (with leading '/') suitable for the Docker archive API.
@@ -357,6 +363,64 @@ kj::Promise<DockerBinaryResponse> dockerApiBinaryRequest(kj::Network& network,
357363
bodyBytes, "application/x-tar"_kj, maxResponseSize);
358364
}
359365

366+
kj::String currentSnapshotVolumeTimestamp() {
367+
return kj::str((kj::systemPreciseCalendarClock().now() - kj::UNIX_EPOCH) / kj::SECONDS);
368+
}
369+
370+
kj::Maybe<int64_t> tryGetSnapshotCreatedAt(capnp::JsonValue::Reader labels) {
371+
if (!labels.isObject()) {
372+
return kj::none;
373+
}
374+
375+
for (auto field: labels.getObject()) {
376+
if (field.getName() != SNAPSHOT_VOLUME_CREATED_AT_LABEL) {
377+
continue;
378+
}
379+
380+
auto value = field.getValue();
381+
if (!value.isString()) {
382+
return kj::none;
383+
}
384+
return value.getString().tryParseAs<int64_t>();
385+
}
386+
387+
return kj::none;
388+
}
389+
390+
kj::Promise<void> warnAboutStaleSnapshotVolumes(kj::Network& network, kj::String dockerPath) {
391+
capnp::JsonCodec codec;
392+
codec.handleByAnnotation<docker_api::Docker::VolumeListFilters>();
393+
capnp::MallocMessageBuilder filterMessage;
394+
auto filters = filterMessage.initRoot<docker_api::Docker::VolumeListFilters>();
395+
auto names = filters.initName(1);
396+
names.set(0, SNAPSHOT_VOLUME_PREFIX);
397+
398+
auto response = co_await dockerApiRequest(network, kj::mv(dockerPath), kj::HttpMethod::GET,
399+
kj::str("/volumes?filters=", kj::encodeUriComponent(codec.encode(filters))));
400+
if (response.statusCode != 200) {
401+
co_return;
402+
}
403+
404+
auto message = decodeJsonResponse<docker_api::Docker::VolumeListResponse>(response.body);
405+
auto root = message->getRoot<docker_api::Docker::VolumeListResponse>();
406+
auto now = kj::systemPreciseCalendarClock().now();
407+
kj::Vector<kj::String> staleVolumes;
408+
409+
for (auto volume: root.getVolumes()) {
410+
KJ_IF_SOME(createdAtSeconds, tryGetSnapshotCreatedAt(volume.getLabels())) {
411+
auto createdAt = kj::UNIX_EPOCH + createdAtSeconds * kj::SECONDS;
412+
if (now - createdAt >= SNAPSHOT_STALE_AGE) {
413+
staleVolumes.add(kj::str(volume.getName()));
414+
}
415+
}
416+
}
417+
418+
if (staleVolumes.size() > 0) {
419+
KJ_LOG(WARNING, "the following snapshot volumes were created 30+ days ago and may be stale",
420+
kj::strArray(staleVolumes, ", "));
421+
}
422+
}
423+
360424
} // namespace
361425

362426
ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory,
@@ -381,7 +445,14 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory,
381445
waitUntilTasks(waitUntilTasks),
382446
pendingCleanup(kj::mv(pendingCleanup).fork()),
383447
cleanupCallback(kj::mv(cleanupCallback)),
384-
channelTokenHandler(channelTokenHandler) {}
448+
channelTokenHandler(channelTokenHandler) {
449+
if (!staleSnapshotVolumeCheckScheduled.exchange(true, std::memory_order_relaxed)) {
450+
waitUntilTasks.add(warnAboutStaleSnapshotVolumes(network, kj::str(this->dockerPath))
451+
.catch_([](kj::Exception&& e) {
452+
KJ_LOG(WARNING, "failed to inspect snapshot volumes for staleness", e);
453+
}));
454+
}
455+
}
385456

386457
ContainerClient::~ContainerClient() noexcept(false) {
387458
stopEgressListener();
@@ -1055,6 +1126,9 @@ kj::Promise<void> ContainerClient::createDockerVolume(kj::StringPtr volumeName)
10551126
capnp::MallocMessageBuilder message;
10561127
auto req = message.initRoot<docker_api::Docker::VolumeCreateRequest>();
10571128
req.setName(volumeName);
1129+
auto labels = req.initLabels().initObject(1);
1130+
labels[0].setName(SNAPSHOT_VOLUME_CREATED_AT_LABEL);
1131+
labels[0].initValue().setString(currentSnapshotVolumeTimestamp());
10581132

10591133
auto response = co_await dockerApiRequest(network, kj::str(dockerPath), kj::HttpMethod::POST,
10601134
kj::str("/volumes/create"), codec.encode(req));
@@ -1334,6 +1408,7 @@ kj::Promise<void> ContainerClient::snapshotDirectory(SnapshotDirectoryContext co
13341408
"': ", putResponse.statusCode);
13351409

13361410
volumeCommitted = true;
1411+
KJ_LOG(INFO, "created snapshot volume", volumeName, dir, tarSize);
13371412

13381413
// Populate the capnp response.
13391414
auto result = context.getResults().initSnapshot();

src/workerd/server/docker-api.capnp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,12 @@ struct Docker {
317317
# Volume create request (POST /volumes/create)
318318
struct VolumeCreateRequest {
319319
name @0 :Text $Json.name("Name");
320+
labels @1 :Json.Value $Json.name("Labels");
321+
}
322+
323+
# Volume list filters query parameter (GET /volumes?filters=...)
324+
struct VolumeListFilters {
325+
name @0 :List(Text) $Json.name("name");
320326
}
321327

322328
# Volume list response (GET /volumes)
@@ -326,6 +332,7 @@ struct Docker {
326332

327333
struct Volume {
328334
name @0 :Text $Json.name("Name");
335+
labels @1 :Json.Value $Json.name("Labels");
329336
}
330337
}
331338
}

src/workerd/server/tests/container-client/test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,10 @@ export class DurableObjectExample extends DurableObject {
492492
}
493493

494494
async restoreTransferredSnapshot(snapshot) {
495+
assert.ok(snapshot.id, 'snapshot must have a non-empty id');
496+
assert.ok(snapshot.size > 0, 'snapshot must have a positive size');
497+
assert.strictEqual(snapshot.dir, '/app/data');
498+
495499
const container = this.ctx.container;
496500
if (container.running) {
497501
const monitor = container.monitor().catch((_err) => {});

0 commit comments

Comments
 (0)