From 3a5732defa202f5280581513bbf8fdc2a41d5939 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Wed, 8 Apr 2026 20:29:54 +0000 Subject: [PATCH 1/2] Track pending app release transitions --- .../routes/cluster_agents/deploy_routes.zig | 2 +- src/manifest/apply_release.zig | 112 +++++++++--------- src/manifest/release_history.zig | 4 +- 3 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/api/routes/cluster_agents/deploy_routes.zig b/src/api/routes/cluster_agents/deploy_routes.zig index 0963e9a..54c19cd 100644 --- a/src/api/routes/cluster_agents/deploy_routes.zig +++ b/src/api/routes/cluster_agents/deploy_routes.zig @@ -45,7 +45,7 @@ const ClusterReleaseTracker = struct { name, manifest_hash, self.config_snapshot, - .in_progress, + .pending, null, ) catch return ClusterApplyError.InternalError; diff --git a/src/manifest/apply_release.zig b/src/manifest/apply_release.zig index 29094f9..3599e47 100644 --- a/src/manifest/apply_release.zig +++ b/src/manifest/apply_release.zig @@ -174,20 +174,29 @@ fn countServices(snapshot: []const u8) usize { return count; } +fn markReleaseIfPresent( + tracker: anytype, + release_id: ?[]const u8, + status: update_common.DeploymentStatus, + message: ?[]const u8, +) !void { + if (release_id) |id| { + try tracker.mark(id, status, message); + } +} + pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { const release_id = try tracker.begin(); + errdefer if (release_id) |id| tracker.freeReleaseId(id); + + try markReleaseIfPresent(tracker, release_id, .in_progress, null); const outcome = backend.apply() catch |err| { - if (release_id) |id| { - try tracker.mark(id, .failed, backend.failureMessage(err)); - tracker.freeReleaseId(id); - } + try markReleaseIfPresent(tracker, release_id, .failed, backend.failureMessage(err)); return err; }; - if (release_id) |id| { - try tracker.mark(id, outcome.status, outcome.message); - } + try markReleaseIfPresent(tracker, release_id, outcome.status, outcome.message); return .{ .release_id = release_id, @@ -195,29 +204,49 @@ pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { }; } -test "execute marks completed releases on backend success" { - const alloc = std.testing.allocator; +const TestTracker = struct { + alloc: std.mem.Allocator, + release_id: []const u8, + statuses: [2]?update_common.DeploymentStatus = [_]?update_common.DeploymentStatus{null} ** 2, + messages: [2]?[]const u8 = [_]?[]const u8{null} ** 2, + mark_count: usize = 0, + + fn begin(self: *@This()) !?[]const u8 { + const id = try self.alloc.dupe(u8, self.release_id); + return id; + } - const Tracker = struct { - alloc: std.mem.Allocator, - last_status: ?update_common.DeploymentStatus = null, - last_message: ?[]const u8 = null, + fn mark(self: *@This(), id: []const u8, status: update_common.DeploymentStatus, message: ?[]const u8) !void { + try std.testing.expectEqualStrings(self.release_id, id); + self.statuses[self.mark_count] = status; + self.messages[self.mark_count] = message; + self.mark_count += 1; + } - fn begin(self: *@This()) !?[]const u8 { - const id = try self.alloc.dupe(u8, "dep123"); - return id; - } + fn freeReleaseId(self: *@This(), id: []const u8) void { + self.alloc.free(id); + } +}; - fn mark(self: *@This(), id: []const u8, status: update_common.DeploymentStatus, message: ?[]const u8) !void { - try std.testing.expectEqualStrings("dep123", id); - self.last_status = status; - self.last_message = message; - } +fn expectTrackedStatuses( + tracker: *const TestTracker, + first_status: update_common.DeploymentStatus, + second_status: update_common.DeploymentStatus, + second_message: ?[]const u8, +) !void { + try std.testing.expectEqual(@as(usize, 2), tracker.mark_count); + try std.testing.expectEqual(first_status, tracker.statuses[0].?); + try std.testing.expect(tracker.messages[0] == null); + try std.testing.expectEqual(second_status, tracker.statuses[1].?); + if (second_message) |message| { + try std.testing.expectEqualStrings(message, tracker.messages[1].?); + } else { + try std.testing.expect(tracker.messages[1] == null); + } +} - fn freeReleaseId(self: *@This(), id: []const u8) void { - self.alloc.free(id); - } - }; +test "execute marks completed releases on backend success" { + const alloc = std.testing.allocator; const Backend = struct { fn apply(_: *@This()) !ApplyOutcome { @@ -229,7 +258,7 @@ test "execute marks completed releases on backend success" { } }; - var tracker = Tracker{ .alloc = alloc }; + var tracker = TestTracker{ .alloc = alloc, .release_id = "dep123" }; var backend = Backend{}; const result = try execute(&tracker, &backend); @@ -237,8 +266,7 @@ test "execute marks completed releases on backend success" { try std.testing.expectEqualStrings("dep123", result.release_id.?); try std.testing.expectEqual(update_common.DeploymentStatus.completed, result.outcome.status); - try std.testing.expectEqual(update_common.DeploymentStatus.completed, tracker.last_status.?); - try std.testing.expect(tracker.last_message == null); + try expectTrackedStatuses(&tracker, .in_progress, .completed, null); } test "execute marks failed releases on backend error" { @@ -246,27 +274,6 @@ test "execute marks failed releases on backend error" { const BackendError = error{StartupFailed}; - const Tracker = struct { - alloc: std.mem.Allocator, - last_status: ?update_common.DeploymentStatus = null, - last_message: ?[]const u8 = null, - - fn begin(self: *@This()) !?[]const u8 { - const id = try self.alloc.dupe(u8, "dep456"); - return id; - } - - fn mark(self: *@This(), id: []const u8, status: update_common.DeploymentStatus, message: ?[]const u8) !void { - try std.testing.expectEqualStrings("dep456", id); - self.last_status = status; - self.last_message = message; - } - - fn freeReleaseId(self: *@This(), id: []const u8) void { - self.alloc.free(id); - } - }; - const Backend = struct { fn apply(_: *@This()) BackendError!ApplyOutcome { return BackendError.StartupFailed; @@ -279,12 +286,11 @@ test "execute marks failed releases on backend error" { } }; - var tracker = Tracker{ .alloc = alloc }; + var tracker = TestTracker{ .alloc = alloc, .release_id = "dep456" }; var backend = Backend{}; try std.testing.expectError(BackendError.StartupFailed, execute(&tracker, &backend)); - try std.testing.expectEqual(update_common.DeploymentStatus.failed, tracker.last_status.?); - try std.testing.expectEqualStrings("service startup failed", tracker.last_message.?); + try expectTrackedStatuses(&tracker, .in_progress, .failed, "service startup failed"); } test "ApplyResult projects to shared apply report" { diff --git a/src/manifest/release_history.zig b/src/manifest/release_history.zig index ed6a76c..a17722b 100644 --- a/src/manifest/release_history.zig +++ b/src/manifest/release_history.zig @@ -14,7 +14,7 @@ pub fn recordAppReleaseStart(plan: *const release_plan.ReleasePlan) ![]const u8 plan.app.app_name, plan.manifest_hash, plan.config_snapshot, - .in_progress, + .pending, null, ); return id; @@ -74,7 +74,7 @@ test "recordAppReleaseStart stores app-scoped deployment metadata" { try std.testing.expectEqual(@as(usize, 1), deployments.items.len); try std.testing.expectEqualStrings("demo-app", deployments.items[0].app_name.?); try std.testing.expectEqualStrings("demo-app", deployments.items[0].service_name); - try std.testing.expectEqualStrings("in_progress", deployments.items[0].status); + try std.testing.expectEqualStrings("pending", deployments.items[0].status); } test "markAppReleaseStatus persists partially failed state" { From c92e31b23216601dc7d77228648b12a237b0befe Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 00:39:44 +0000 Subject: [PATCH 2/2] Persist structured app release transitions --- src/api/routes/cluster_agents/app_routes.zig | 32 +++++++++++- .../routes/cluster_agents/deploy_routes.zig | 2 + src/manifest/apply_release.zig | 50 +++++++++++++++++-- src/manifest/local_apply_backend.zig | 2 +- src/manifest/release_history.zig | 42 ++++++++++++++-- src/manifest/update.zig | 2 + src/manifest/update/deployment_store.zig | 8 +++ src/state/schema/migrations.zig | 30 +++++++++++ src/state/schema/tables.zig | 2 + src/state/store/deployments.zig | 42 ++++++++++++++-- 10 files changed, 199 insertions(+), 13 deletions(-) diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index 7c890c9..401f818 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -313,12 +313,14 @@ test "formatAppStatusResponse summarizes latest release" { try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":null") != null); } -test "formatAppStatusResponse includes rollback metadata inferred from stored release message" { +test "formatAppStatusResponse includes structured rollback metadata" { const alloc = std.testing.allocator; const latest = store.DeploymentRecord{ .id = "dep-3", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "rollback", + .source_release_id = "dep-1", .manifest_hash = "sha256:333", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", .status = "completed", @@ -333,6 +335,26 @@ test "formatAppStatusResponse includes rollback metadata inferred from stored re try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":\"dep-1\"") != null); } +test "formatAppStatusResponse falls back to rollback metadata inferred from legacy message" { + const alloc = std.testing.allocator; + const latest = store.DeploymentRecord{ + .id = "dep-4", + .app_name = "demo-app", + .service_name = "demo-app", + .manifest_hash = "sha256:444", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", + .status = "completed", + .message = "rollback to dep-1 completed: all placements succeeded", + .created_at = 400, + }; + + const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + defer alloc.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"trigger\":\"rollback\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":\"dep-1\"") != null); +} + test "app status and history surface rollback release metadata from persisted rows" { const alloc = std.testing.allocator; @@ -354,6 +376,8 @@ test "app status and history surface rollback release metadata from persisted ro .id = "dep-2", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "rollback", + .source_release_id = "dep-1", .manifest_hash = "sha256:222", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", .status = "completed", @@ -469,6 +493,12 @@ test "app apply then rollback routes preserve release transition metadata" { try expectJsonContains(rollback_response.body, "\"source_release_id\":\""); try expectJsonContains(rollback_response.body, source_release_id); + const latest = try store.getLatestDeploymentByAppInDb(harness.node.stateMachineDb(), alloc, "demo-app"); + defer latest.deinit(alloc); + + try std.testing.expectEqualStrings("rollback", latest.trigger.?); + try std.testing.expectEqualStrings(source_release_id, latest.source_release_id.?); + const status_response = harness.status("demo-app"); defer freeResponse(alloc, status_response); diff --git a/src/api/routes/cluster_agents/deploy_routes.zig b/src/api/routes/cluster_agents/deploy_routes.zig index 54c19cd..8842def 100644 --- a/src/api/routes/cluster_agents/deploy_routes.zig +++ b/src/api/routes/cluster_agents/deploy_routes.zig @@ -43,6 +43,8 @@ const ClusterReleaseTracker = struct { id, name, name, + self.context.trigger.toString(), + self.context.source_release_id, manifest_hash, self.config_snapshot, .pending, diff --git a/src/manifest/apply_release.zig b/src/manifest/apply_release.zig index 3599e47..811d918 100644 --- a/src/manifest/apply_release.zig +++ b/src/manifest/apply_release.zig @@ -98,7 +98,7 @@ pub const ApplyReport = struct { }; pub fn reportFromDeployment(dep: store.DeploymentRecord) ApplyReport { - const inferred = inferContextFromStoredMessage(dep.message); + const context = deploymentContext(dep); return .{ .app_name = dep.app_name orelse dep.service_name, .release_id = dep.id, @@ -109,11 +109,32 @@ pub fn reportFromDeployment(dep: store.DeploymentRecord) ApplyReport { .message = dep.message, .manifest_hash = dep.manifest_hash, .created_at = dep.created_at, - .trigger = inferred.trigger, - .source_release_id = inferred.source_release_id, + .trigger = context.trigger, + .source_release_id = context.source_release_id, }; } +fn deploymentContext(dep: store.DeploymentRecord) ApplyContext { + if (dep.trigger) |trigger| { + const structured_trigger: ApplyTrigger = if (std.mem.eql(u8, trigger, ApplyTrigger.rollback.toString())) + .rollback + else + .apply; + if (structured_trigger == .rollback or dep.source_release_id != null) { + return .{ + .trigger = structured_trigger, + .source_release_id = dep.source_release_id, + }; + } + + const inferred = inferContextFromStoredMessage(dep.message); + if (inferred.trigger == .rollback) return inferred; + + return .{ .trigger = structured_trigger }; + } + return inferContextFromStoredMessage(dep.message); +} + fn inferContextFromStoredMessage(message: ?[]const u8) ApplyContext { const text = message orelse return .{}; const prefix = "rollback to "; @@ -372,6 +393,7 @@ test "reportFromDeployment preserves release metadata and counts services" { .id = "dep-22", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "apply", .manifest_hash = "sha256:xyz", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}", .status = "completed", @@ -391,11 +413,31 @@ test "reportFromDeployment preserves release metadata and counts services" { try std.testing.expect(report.source_release_id == null); } -test "reportFromDeployment infers rollback context from stored message" { +test "reportFromDeployment preserves structured rollback context" { const dep = store.DeploymentRecord{ .id = "dep-23", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "rollback", + .source_release_id = "dep-11", + .manifest_hash = "sha256:zzz", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", + .status = "completed", + .message = "rollback to dep-11 completed: all placements succeeded", + .created_at = 230, + }; + + const report = reportFromDeployment(dep); + try std.testing.expectEqual(ApplyTrigger.rollback, report.trigger); + try std.testing.expectEqualStrings("dep-11", report.source_release_id.?); +} + +test "reportFromDeployment falls back to rollback context inferred from legacy message" { + const dep = store.DeploymentRecord{ + .id = "dep-24", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", .manifest_hash = "sha256:zzz", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", .status = "completed", diff --git a/src/manifest/local_apply_backend.zig b/src/manifest/local_apply_backend.zig index c93f341..8e12092 100644 --- a/src/manifest/local_apply_backend.zig +++ b/src/manifest/local_apply_backend.zig @@ -297,7 +297,7 @@ const LocalReleaseTracker = struct { context: apply_release.ApplyContext = .{}, pub fn begin(self: *const LocalReleaseTracker) !?[]const u8 { - return release_history.recordAppReleaseStart(self.plan) catch null; + return release_history.recordAppReleaseStart(self.plan, self.context) catch null; } pub fn mark(self: *const LocalReleaseTracker, id: []const u8, status: @import("update/common.zig").DeploymentStatus, message: ?[]const u8) !void { diff --git a/src/manifest/release_history.zig b/src/manifest/release_history.zig index a17722b..d47a8d7 100644 --- a/src/manifest/release_history.zig +++ b/src/manifest/release_history.zig @@ -1,10 +1,11 @@ const std = @import("std"); +const apply_release = @import("apply_release.zig"); const store = @import("../state/store.zig"); const deployment_store = @import("update/deployment_store.zig"); const release_plan = @import("release_plan.zig"); const update_common = @import("update/common.zig"); -pub fn recordAppReleaseStart(plan: *const release_plan.ReleasePlan) ![]const u8 { +pub fn recordAppReleaseStart(plan: *const release_plan.ReleasePlan, context: apply_release.ApplyContext) ![]const u8 { const id = try deployment_store.generateDeploymentId(plan.alloc); errdefer plan.alloc.free(id); @@ -12,6 +13,8 @@ pub fn recordAppReleaseStart(plan: *const release_plan.ReleasePlan) ![]const u8 id, plan.app.app_name, plan.app.app_name, + context.trigger.toString(), + context.source_release_id, plan.manifest_hash, plan.config_snapshot, .pending, @@ -62,7 +65,7 @@ test "recordAppReleaseStart stores app-scoped deployment metadata" { var plan = try release_plan.ReleasePlan.fromAppSpec(alloc, &app, &.{}); defer plan.deinit(); - const id = try recordAppReleaseStart(&plan); + const id = try recordAppReleaseStart(&plan, .{}); defer alloc.free(id); var deployments = try listAppReleases(alloc, "demo-app"); @@ -97,7 +100,7 @@ test "markAppReleaseStatus persists partially failed state" { var plan = try release_plan.ReleasePlan.fromAppSpec(alloc, &app, &.{}); defer plan.deinit(); - const id = try recordAppReleaseStart(&plan); + const id = try recordAppReleaseStart(&plan, .{}); defer alloc.free(id); try markAppReleaseStatus(id, .partially_failed, "one or more placements failed"); @@ -109,3 +112,36 @@ test "markAppReleaseStatus persists partially failed state" { try std.testing.expectEqualStrings("partially_failed", dep.status); try std.testing.expectEqualStrings("one or more placements failed", dep.message.?); } + +test "recordAppReleaseStart persists rollback transition metadata" { + const alloc = std.testing.allocator; + try store.initTestDb(); + defer store.deinitTestDb(); + + const app_spec = @import("app_spec.zig"); + const loader = @import("loader.zig"); + + var manifest = try loader.loadFromString(alloc, + \\[service.web] + \\image = "nginx:latest" + ); + defer manifest.deinit(); + + var app = try app_spec.fromManifest(alloc, "demo-app", &manifest); + defer app.deinit(); + + var plan = try release_plan.ReleasePlan.fromAppSpec(alloc, &app, &.{}); + defer plan.deinit(); + + const id = try recordAppReleaseStart(&plan, .{ + .trigger = .rollback, + .source_release_id = "dep-1", + }); + defer alloc.free(id); + + const dep = try store.getLatestDeploymentByApp(alloc, "demo-app"); + defer dep.deinit(alloc); + + try std.testing.expectEqualStrings("rollback", dep.trigger.?); + try std.testing.expectEqualStrings("dep-1", dep.source_release_id.?); +} diff --git a/src/manifest/update.zig b/src/manifest/update.zig index 2ddaae3..7844b65 100644 --- a/src/manifest/update.zig +++ b/src/manifest/update.zig @@ -77,6 +77,8 @@ pub fn performRollingUpdate( did, null, context.service_name, + "apply", + null, context.manifest_hash, context.config_snapshot, .in_progress, diff --git a/src/manifest/update/deployment_store.zig b/src/manifest/update/deployment_store.zig index 382af5d..44d1530 100644 --- a/src/manifest/update/deployment_store.zig +++ b/src/manifest/update/deployment_store.zig @@ -28,6 +28,8 @@ pub fn recordDeployment( id: []const u8, app_name: ?[]const u8, service_name: []const u8, + trigger: []const u8, + source_release_id: ?[]const u8, manifest_hash: []const u8, config_snapshot: []const u8, status: common.DeploymentStatus, @@ -37,6 +39,8 @@ pub fn recordDeployment( .id = id, .app_name = app_name, .service_name = service_name, + .trigger = trigger, + .source_release_id = source_release_id, .manifest_hash = manifest_hash, .config_snapshot = config_snapshot, .status = status.toString(), @@ -50,6 +54,8 @@ pub fn recordDeploymentInDb( id: []const u8, app_name: ?[]const u8, service_name: []const u8, + trigger: []const u8, + source_release_id: ?[]const u8, manifest_hash: []const u8, config_snapshot: []const u8, status: common.DeploymentStatus, @@ -59,6 +65,8 @@ pub fn recordDeploymentInDb( .id = id, .app_name = app_name, .service_name = service_name, + .trigger = trigger, + .source_release_id = source_release_id, .manifest_hash = manifest_hash, .config_snapshot = config_snapshot, .status = status.toString(), diff --git a/src/state/schema/migrations.zig b/src/state/schema/migrations.zig index d8f36fb..048698d 100644 --- a/src/state/schema/migrations.zig +++ b/src/state/schema/migrations.zig @@ -128,6 +128,9 @@ fn migrateServices(db: *sqlite.Db) void { fn migrateDeployments(db: *sqlite.Db) void { addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN app_name TEXT;") catch {}; + addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN trigger TEXT NOT NULL DEFAULT 'apply';") catch {}; + addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN source_release_id TEXT;") catch {}; + db.exec("UPDATE deployments SET trigger = 'apply' WHERE trigger IS NULL OR trigger = '';", .{}, .{}) catch {}; } fn addColumnIfMissing(db: *sqlite.Db, sql: []const u8) SchemaError!void { @@ -176,3 +179,30 @@ test "migrateServices adds http proxy columns" { .{"api"}, ) catch unreachable; } + +test "migrateDeployments adds release transition columns" { + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + + db.exec( + "CREATE TABLE deployments (" ++ + "id TEXT PRIMARY KEY, " ++ + "service_name TEXT NOT NULL, " ++ + "manifest_hash TEXT NOT NULL, " ++ + "config_snapshot TEXT NOT NULL DEFAULT '', " ++ + "status TEXT NOT NULL DEFAULT 'pending', " ++ + "message TEXT, " ++ + "created_at INTEGER NOT NULL" ++ + ");", + .{}, + .{}, + ) catch unreachable; + + try apply(&db); + + db.exec( + "INSERT INTO deployments (id, app_name, service_name, trigger, source_release_id, manifest_hash, config_snapshot, status, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + .{}, + .{ "dep-1", "demo-app", "demo-app", "rollback", "dep-0", "sha256:test", "{}", "completed", "rollback completed", @as(i64, 100) }, + ) catch unreachable; +} diff --git a/src/state/schema/tables.zig b/src/state/schema/tables.zig index 22beaa7..3cbd21e 100644 --- a/src/state/schema/tables.zig +++ b/src/state/schema/tables.zig @@ -162,6 +162,8 @@ pub fn initCoreTables(db: *sqlite.Db) SchemaError!void { \\ id TEXT PRIMARY KEY, \\ app_name TEXT, \\ service_name TEXT NOT NULL, + \\ trigger TEXT NOT NULL DEFAULT 'apply', + \\ source_release_id TEXT, \\ manifest_hash TEXT NOT NULL, \\ config_snapshot TEXT NOT NULL DEFAULT '', \\ status TEXT NOT NULL DEFAULT 'pending', diff --git a/src/state/store/deployments.zig b/src/state/store/deployments.zig index f65cd03..bfe7d27 100644 --- a/src/state/store/deployments.zig +++ b/src/state/store/deployments.zig @@ -10,6 +10,8 @@ pub const DeploymentRecord = struct { id: []const u8, app_name: ?[]const u8 = null, service_name: []const u8, + trigger: ?[]const u8 = null, + source_release_id: ?[]const u8 = null, manifest_hash: []const u8, config_snapshot: []const u8, status: []const u8, @@ -20,6 +22,8 @@ pub const DeploymentRecord = struct { alloc.free(self.id); if (self.app_name) |app_name| alloc.free(app_name); alloc.free(self.service_name); + if (self.trigger) |trigger| alloc.free(trigger); + if (self.source_release_id) |source_release_id| alloc.free(source_release_id); alloc.free(self.manifest_hash); alloc.free(self.config_snapshot); alloc.free(self.status); @@ -28,12 +32,14 @@ pub const DeploymentRecord = struct { }; const deployment_columns = - "id, app_name, service_name, manifest_hash, config_snapshot, status, message, created_at"; + "id, app_name, service_name, trigger, source_release_id, manifest_hash, config_snapshot, status, message, created_at"; const DeploymentRow = struct { id: sqlite.Text, app_name: ?sqlite.Text, service_name: sqlite.Text, + trigger: ?sqlite.Text, + source_release_id: ?sqlite.Text, manifest_hash: sqlite.Text, config_snapshot: sqlite.Text, status: sqlite.Text, @@ -46,6 +52,8 @@ fn rowToRecord(row: DeploymentRow) DeploymentRecord { .id = row.id.data, .app_name = if (row.app_name) |app_name| app_name.data else null, .service_name = row.service_name.data, + .trigger = if (row.trigger) |trigger| trigger.data else null, + .source_release_id = if (row.source_release_id) |source_release_id| source_release_id.data else null, .manifest_hash = row.manifest_hash.data, .config_snapshot = row.config_snapshot.data, .status = row.status.data, @@ -61,12 +69,14 @@ pub fn saveDeployment(record: DeploymentRecord) StoreError!void { pub fn saveDeploymentInDb(db: *sqlite.Db, record: DeploymentRecord) StoreError!void { db.exec( - "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", .{}, .{ record.id, record.app_name, record.service_name, + record.trigger, + record.source_release_id, record.manifest_hash, record.config_snapshot, record.status, @@ -210,9 +220,9 @@ test "deployment record round-trip via sqlite" { try schema.init(&db); db.exec( - "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", .{}, - .{ "dep001", "demo-app", "web", "sha256:abc", "{\"image\":\"nginx:latest\"}", "completed", "initial deploy", @as(i64, 1000) }, + .{ "dep001", "demo-app", "web", "apply", null, "sha256:abc", "{\"image\":\"nginx:latest\"}", "completed", "initial deploy", @as(i64, 1000) }, ) catch unreachable; const alloc = std.testing.allocator; @@ -223,6 +233,8 @@ test "deployment record round-trip via sqlite" { try std.testing.expectEqualStrings("dep001", record.id); try std.testing.expectEqualStrings("demo-app", record.app_name.?); try std.testing.expectEqualStrings("web", record.service_name); + try std.testing.expectEqualStrings("apply", record.trigger.?); + try std.testing.expect(record.source_release_id == null); try std.testing.expectEqualStrings("sha256:abc", record.manifest_hash); try std.testing.expectEqualStrings("{\"image\":\"nginx:latest\"}", record.config_snapshot); try std.testing.expectEqualStrings("completed", record.status); @@ -248,6 +260,28 @@ test "deployment with null message" { try std.testing.expect(record.message == null); try std.testing.expect(record.app_name == null); + try std.testing.expectEqualStrings("apply", record.trigger.?); + try std.testing.expect(record.source_release_id == null); +} + +test "deployment stores rollback transition metadata" { + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + db.exec( + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + .{}, + .{ "dep-rb", "demo-app", "demo-app", "rollback", "dep-1", "sha256:rb", "{}", "completed", "rollback completed", @as(i64, 2100) }, + ) catch unreachable; + + const alloc = std.testing.allocator; + const row = (db.oneAlloc(DeploymentRow, alloc, "SELECT " ++ deployment_columns ++ " FROM deployments WHERE id = ?;", .{}, .{"dep-rb"}) catch unreachable).?; + const record = rowToRecord(row); + defer record.deinit(alloc); + + try std.testing.expectEqualStrings("rollback", record.trigger.?); + try std.testing.expectEqualStrings("dep-1", record.source_release_id.?); } test "deployment list ordered by timestamp desc" {