Skip to content
Merged
45 changes: 45 additions & 0 deletions src/api/routes/cluster_agents/app_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,51 @@ test "app apply route preserves failed release metadata across reads" {
try expectJsonContains(history_response.body, "\"message\":\"one or more placements failed\"");
}

test "app apply route preserves partially failed release metadata across reads" {
const alloc = std.testing.allocator;
const apply_body =
\\{"app_name":"demo-app","services":[{"name":"web","image":"alpine","command":["echo","hello"]},{"name":"db","image":"alpine","command":["echo","db"],"cpu_limit":999999,"memory_limit_mb":999999}]}
;

var harness = try RouteFlowHarness.init(alloc);
defer harness.deinit();

const apply_response = harness.appApply(apply_body);
defer freeResponse(alloc, apply_response);

try expectResponseOk(apply_response);
try expectJsonContains(apply_response.body, "\"trigger\":\"apply\"");
try expectJsonContains(apply_response.body, "\"status\":\"partially_failed\"");
try expectJsonContains(apply_response.body, "\"placed\":1");
try expectJsonContains(apply_response.body, "\"failed\":1");
try expectJsonContains(apply_response.body, "\"source_release_id\":null");
try expectJsonContains(apply_response.body, "\"message\":\"one or more placements failed\"");

const release_id = json_helpers.extractJsonString(apply_response.body, "release_id").?;

const status_response = harness.status("demo-app");
defer freeResponse(alloc, status_response);

try expectResponseOk(status_response);
try expectJsonContains(status_response.body, "\"release_id\":\"");
try expectJsonContains(status_response.body, release_id);
try expectJsonContains(status_response.body, "\"trigger\":\"apply\"");
try expectJsonContains(status_response.body, "\"status\":\"partially_failed\"");
try expectJsonContains(status_response.body, "\"source_release_id\":null");
try expectJsonContains(status_response.body, "\"message\":\"one or more placements failed\"");

const history_response = harness.history("demo-app");
defer freeResponse(alloc, history_response);

try expectResponseOk(history_response);
try expectJsonContains(history_response.body, "\"id\":\"");
try expectJsonContains(history_response.body, release_id);
try expectJsonContains(history_response.body, "\"trigger\":\"apply\"");
try expectJsonContains(history_response.body, "\"status\":\"partially_failed\"");
try expectJsonContains(history_response.body, "\"source_release_id\":null");
try expectJsonContains(history_response.body, "\"message\":\"one or more placements failed\"");
}

test "route rejects app rollback without cluster" {
const body = "{\"release_id\":\"abc123def456\"}";
const request = http.Request{
Expand Down
26 changes: 25 additions & 1 deletion src/api/routes/cluster_agents/deploy_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ const ClusterApplyBackend = struct {
}

return .{
.status = if (failed == 0) .completed else .failed,
.status = if (failed == 0)
.completed
else if (placed > 0)
.partially_failed
else
.failed,
.message = if (failed == 0) "all placements succeeded" else "one or more placements failed",
.placed = placed,
.failed = failed,
Expand Down Expand Up @@ -337,6 +342,25 @@ test "formatAppApplyResponse includes rollback trigger metadata" {
try std.testing.expect(std.mem.indexOf(u8, json, "\"message\":\"rollback to dep-1 completed: all placements succeeded\"") != null);
}

test "formatAppApplyResponse includes partially failed status" {
const alloc = std.testing.allocator;
const json = try formatAppApplyResponse(alloc, .{
.app_name = "demo-app",
.release_id = "dep-3",
.status = .partially_failed,
.service_count = 2,
.placed = 1,
.failed = 1,
.message = "one or more placements failed",
});
defer alloc.free(json);

try std.testing.expect(std.mem.indexOf(u8, json, "\"status\":\"partially_failed\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"placed\":1") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"failed\":1") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"message\":\"one or more placements failed\"") != null);
}

test "formatLegacyApplyResponse preserves compact deploy shape" {
const alloc = std.testing.allocator;
const json = try formatLegacyApplyResponse(alloc, 1, 1);
Expand Down
21 changes: 20 additions & 1 deletion src/manifest/apply_release.zig
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,12 @@ pub fn materializeMessage(
.apply => if (explicit) |message|
try alloc.dupe(u8, message)
else switch (status) {
.pending => try alloc.dupe(u8, "apply pending"),
.in_progress => try alloc.dupe(u8, "apply in progress"),
.completed => try alloc.dupe(u8, "apply completed"),
.partially_failed => try alloc.dupe(u8, "apply partially failed"),
.failed => try alloc.dupe(u8, "apply failed"),
else => try alloc.dupe(u8, status_text),
.rolled_back => try alloc.dupe(u8, "apply rolled back"),
},
.rollback => if (context.source_release_id) |source_id|
if (explicit) |message|
Expand Down Expand Up @@ -342,6 +345,22 @@ test "materializeMessage contextualizes rollback transitions" {
);
}

test "materializeMessage defaults are operator friendly for orchestration states" {
const alloc = std.testing.allocator;

const pending = try materializeMessage(alloc, .{}, .pending, null);
defer alloc.free(pending.?);
try std.testing.expectEqualStrings("apply pending", pending.?);

const partial = try materializeMessage(alloc, .{}, .partially_failed, null);
defer alloc.free(partial.?);
try std.testing.expectEqualStrings("apply partially failed", partial.?);

const rolled_back = try materializeMessage(alloc, .{}, .rolled_back, null);
defer alloc.free(rolled_back.?);
try std.testing.expectEqualStrings("apply rolled back", rolled_back.?);
}

test "reportFromDeployment preserves release metadata and counts services" {
const dep = store.DeploymentRecord{
.id = "dep-22",
Expand Down
28 changes: 28 additions & 0 deletions src/manifest/cli/ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,34 @@ test "writeHistoryJsonObject round-trips through remote parser" {
try std.testing.expectEqualStrings(entry.message.?, parsed.message.?);
}

test "historyEntryFromDeployment preserves partially failed local release state" {
const dep = store.DeploymentRecord{
.id = "dep-3",
.app_name = "demo-app",
.service_name = "demo-app",
.manifest_hash = "sha256:333",
.config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}",
.status = "partially_failed",
.message = "one or more placements failed",
.created_at = 300,
};

const local = historyEntryFromDeployment(dep);
const remote = parseHistoryObject(
\\{"id":"dep-3","app":"demo-app","service":"demo-app","trigger":"apply","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"source_release_id":null,"message":"one or more placements failed"}
);

try std.testing.expectEqualStrings(local.id, remote.id);
try std.testing.expectEqualStrings(local.app.?, remote.app.?);
try std.testing.expectEqualStrings(local.service, remote.service);
try std.testing.expectEqualStrings(local.trigger, remote.trigger);
try std.testing.expectEqualStrings(local.status, remote.status);
try std.testing.expectEqualStrings(local.manifest_hash, remote.manifest_hash);
try std.testing.expectEqual(local.created_at, remote.created_at);
try std.testing.expect(local.source_release_id == null);
try std.testing.expectEqualStrings(local.message.?, remote.message.?);
}

pub fn runWorker(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void {
var manifest_path: []const u8 = manifest_loader.default_filename;
var worker_name: ?[]const u8 = null;
Expand Down
Loading
Loading