Skip to content
Merged
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
32 changes: 31 additions & 1 deletion src/api/routes/cluster_agents/app_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;

Expand All @@ -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",
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion src/api/routes/cluster_agents/deploy_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ const ClusterReleaseTracker = struct {
id,
name,
name,
self.context.trigger.toString(),
self.context.source_release_id,
manifest_hash,
self.config_snapshot,
.in_progress,
.pending,
null,
) catch return ClusterApplyError.InternalError;

Expand Down
162 changes: 105 additions & 57 deletions src/manifest/apply_release.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ";
Expand Down Expand Up @@ -174,50 +195,79 @@ 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,
.outcome = outcome,
};
}

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 {
Expand All @@ -229,44 +279,22 @@ 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);
defer alloc.free(result.release_id.?);

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" {
const alloc = std.testing.allocator;

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;
Expand All @@ -279,12 +307,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" {
Expand Down Expand Up @@ -366,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",
Expand All @@ -385,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",
Expand Down
2 changes: 1 addition & 1 deletion src/manifest/local_apply_backend.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading