@synth-deploy/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { EnvoyClient, EnvoyHealthChecker } from "../src/agent/envoy-client.js";
|
|
3
|
+
import type { EnvoyHealthResponse } from "../src/agent/envoy-client.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Shared helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeHealthResponse(overrides: Partial<EnvoyHealthResponse> = {}): EnvoyHealthResponse {
|
|
10
|
+
return {
|
|
11
|
+
status: "healthy",
|
|
12
|
+
service: "envoy",
|
|
13
|
+
hostname: "test-host",
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
readiness: { ready: true, reason: "ok" },
|
|
16
|
+
summary: { totalDeployments: 5, succeeded: 4, failed: 1, executing: 0, environments: 2 },
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
globalThis.fetch = originalFetch;
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ===========================================================================
|
|
29
|
+
// EnvoyClient retry logic
|
|
30
|
+
// ===========================================================================
|
|
31
|
+
|
|
32
|
+
describe("EnvoyClient retry logic", () => {
|
|
33
|
+
it("succeeds on first attempt without retry", async () => {
|
|
34
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
35
|
+
new Response(JSON.stringify(makeHealthResponse()), { status: 200 }),
|
|
36
|
+
);
|
|
37
|
+
globalThis.fetch = mockFetch;
|
|
38
|
+
|
|
39
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
40
|
+
const result = await client.checkHealth();
|
|
41
|
+
|
|
42
|
+
expect(result.status).toBe("healthy");
|
|
43
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("retries on connection error and succeeds", async () => {
|
|
47
|
+
const mockFetch = vi.fn()
|
|
48
|
+
.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"))
|
|
49
|
+
.mockResolvedValueOnce(new Response(JSON.stringify(makeHealthResponse()), { status: 200 }));
|
|
50
|
+
globalThis.fetch = mockFetch;
|
|
51
|
+
|
|
52
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
53
|
+
const result = await client.checkHealth();
|
|
54
|
+
|
|
55
|
+
expect(result.status).toBe("healthy");
|
|
56
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("retries on 503 and succeeds", async () => {
|
|
60
|
+
const mockFetch = vi.fn()
|
|
61
|
+
.mockResolvedValueOnce(new Response("Service Unavailable", { status: 503 }))
|
|
62
|
+
.mockResolvedValueOnce(new Response(JSON.stringify(makeHealthResponse()), { status: 200 }));
|
|
63
|
+
globalThis.fetch = mockFetch;
|
|
64
|
+
|
|
65
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
66
|
+
const result = await client.checkHealth();
|
|
67
|
+
|
|
68
|
+
expect(result.status).toBe("healthy");
|
|
69
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not retry on 400 (permanent error)", async () => {
|
|
73
|
+
const mockFetch = vi.fn()
|
|
74
|
+
.mockResolvedValueOnce(new Response("Bad Request", { status: 400 }));
|
|
75
|
+
globalThis.fetch = mockFetch;
|
|
76
|
+
|
|
77
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
78
|
+
|
|
79
|
+
await expect(client.checkHealth()).rejects.toThrow("HTTP 400");
|
|
80
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// EnvoyClient.checkHealth — detailed scenarios
|
|
86
|
+
// ===========================================================================
|
|
87
|
+
|
|
88
|
+
describe("EnvoyClient.checkHealth", () => {
|
|
89
|
+
it("returns full health response with all fields", async () => {
|
|
90
|
+
const expected = makeHealthResponse({
|
|
91
|
+
summary: { totalDeployments: 10, succeeded: 8, failed: 2, executing: 0, environments: 3 },
|
|
92
|
+
});
|
|
93
|
+
globalThis.fetch = vi.fn().mockResolvedValueOnce(
|
|
94
|
+
new Response(JSON.stringify(expected), { status: 200 }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
98
|
+
const result = await client.checkHealth();
|
|
99
|
+
|
|
100
|
+
expect(result.status).toBe("healthy");
|
|
101
|
+
expect(result.service).toBe("envoy");
|
|
102
|
+
expect(result.hostname).toBe("test-host");
|
|
103
|
+
expect(result.readiness.ready).toBe(true);
|
|
104
|
+
expect(result.summary.totalDeployments).toBe(10);
|
|
105
|
+
expect(result.summary.succeeded).toBe(8);
|
|
106
|
+
expect(result.summary.environments).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("throws on non-retryable HTTP error without retrying", async () => {
|
|
110
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
111
|
+
new Response("Internal Server Error", { status: 500 }),
|
|
112
|
+
);
|
|
113
|
+
globalThis.fetch = mockFetch;
|
|
114
|
+
|
|
115
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
116
|
+
await expect(client.checkHealth()).rejects.toThrow("HTTP 500");
|
|
117
|
+
// 500 is not in the retryable set (502, 503, 504), so no retry
|
|
118
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws on malformed JSON response", async () => {
|
|
122
|
+
globalThis.fetch = vi.fn().mockResolvedValueOnce(
|
|
123
|
+
new Response("this is not json", { status: 200, headers: { "Content-Type": "text/plain" } }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
127
|
+
// response.json() will throw a SyntaxError on invalid JSON
|
|
128
|
+
await expect(client.checkHealth()).rejects.toThrow();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("throws on non-transient error without retrying", async () => {
|
|
132
|
+
// Errors that do NOT match isTransientError patterns are thrown immediately
|
|
133
|
+
const mockFetch = vi.fn().mockRejectedValueOnce(new Error("certificate expired"));
|
|
134
|
+
globalThis.fetch = mockFetch;
|
|
135
|
+
|
|
136
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
137
|
+
await expect(client.checkHealth()).rejects.toThrow("certificate expired");
|
|
138
|
+
// Non-transient error should not trigger retries
|
|
139
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("calls the correct URL for health endpoint", async () => {
|
|
143
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
144
|
+
new Response(JSON.stringify(makeHealthResponse()), { status: 200 }),
|
|
145
|
+
);
|
|
146
|
+
globalThis.fetch = mockFetch;
|
|
147
|
+
|
|
148
|
+
const client = new EnvoyClient("http://my-envoy:9090", 5000);
|
|
149
|
+
await client.checkHealth();
|
|
150
|
+
|
|
151
|
+
const [url] = mockFetch.mock.calls[0];
|
|
152
|
+
expect(url).toBe("http://my-envoy:9090/health");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ===========================================================================
|
|
157
|
+
// EnvoyClient.deploy
|
|
158
|
+
// ===========================================================================
|
|
159
|
+
|
|
160
|
+
describe("EnvoyClient.deploy", () => {
|
|
161
|
+
const deployInstruction = {
|
|
162
|
+
deploymentId: "dep-1",
|
|
163
|
+
partitionId: "part-1",
|
|
164
|
+
environmentId: "env-1",
|
|
165
|
+
operationId: "op-1",
|
|
166
|
+
version: "1.0.0",
|
|
167
|
+
variables: { DB_HOST: "localhost" },
|
|
168
|
+
environmentName: "staging",
|
|
169
|
+
partitionName: "acme-corp",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
it("sends POST with JSON body and returns deploy result", async () => {
|
|
173
|
+
const deployResult = {
|
|
174
|
+
deploymentId: "dep-1",
|
|
175
|
+
success: true,
|
|
176
|
+
workspacePath: "/tmp/deploy",
|
|
177
|
+
artifacts: ["artifact.tar.gz"],
|
|
178
|
+
executionDurationMs: 500,
|
|
179
|
+
totalDurationMs: 800,
|
|
180
|
+
verificationPassed: true,
|
|
181
|
+
verificationChecks: [],
|
|
182
|
+
failureReason: null,
|
|
183
|
+
debriefEntryIds: ["entry-1"],
|
|
184
|
+
debriefEntries: [],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
188
|
+
new Response(JSON.stringify(deployResult), { status: 200 }),
|
|
189
|
+
);
|
|
190
|
+
globalThis.fetch = mockFetch;
|
|
191
|
+
|
|
192
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
193
|
+
const result = await client.deploy(deployInstruction);
|
|
194
|
+
|
|
195
|
+
expect(result.deploymentId).toBe("dep-1");
|
|
196
|
+
expect(result.success).toBe(true);
|
|
197
|
+
expect(result.workspacePath).toBe("/tmp/deploy");
|
|
198
|
+
|
|
199
|
+
// Verify the fetch was called with correct URL and method
|
|
200
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
201
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
202
|
+
expect(url).toBe("http://envoy.test:8080/deploy");
|
|
203
|
+
expect(init.method).toBe("POST");
|
|
204
|
+
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
|
205
|
+
expect(JSON.parse(init.body as string)).toEqual(deployInstruction);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("uses 3x the base timeout for deploy calls", async () => {
|
|
209
|
+
const deployResult = {
|
|
210
|
+
deploymentId: "dep-1",
|
|
211
|
+
success: true,
|
|
212
|
+
workspacePath: "/tmp/deploy",
|
|
213
|
+
artifacts: [],
|
|
214
|
+
executionDurationMs: 100,
|
|
215
|
+
totalDurationMs: 200,
|
|
216
|
+
verificationPassed: true,
|
|
217
|
+
verificationChecks: [],
|
|
218
|
+
failureReason: null,
|
|
219
|
+
debriefEntryIds: [],
|
|
220
|
+
debriefEntries: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Track the abort signal to verify timeout is set
|
|
224
|
+
let capturedSignal: AbortSignal | undefined;
|
|
225
|
+
const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
226
|
+
capturedSignal = init.signal ?? undefined;
|
|
227
|
+
return Promise.resolve(new Response(JSON.stringify(deployResult), { status: 200 }));
|
|
228
|
+
});
|
|
229
|
+
globalThis.fetch = mockFetch;
|
|
230
|
+
|
|
231
|
+
const client = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
232
|
+
await client.deploy(deployInstruction);
|
|
233
|
+
|
|
234
|
+
// The signal should exist (fetchWithRetry always creates one)
|
|
235
|
+
expect(capturedSignal).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ===========================================================================
|
|
240
|
+
// EnvoyHealthChecker — adapter for ServiceHealthChecker interface
|
|
241
|
+
//
|
|
242
|
+
// These tests mock EnvoyClient.checkHealth directly to avoid going through
|
|
243
|
+
// the retry/fetch layer, which would introduce real sleep delays.
|
|
244
|
+
// ===========================================================================
|
|
245
|
+
|
|
246
|
+
describe("EnvoyHealthChecker", () => {
|
|
247
|
+
const context = { partitionId: "part-1", environmentName: "staging" };
|
|
248
|
+
|
|
249
|
+
it("returns healthy when no envoy is registered for serviceId", async () => {
|
|
250
|
+
const checker = new EnvoyHealthChecker();
|
|
251
|
+
const result = await checker.check("op-1/staging", context);
|
|
252
|
+
|
|
253
|
+
expect(result.reachable).toBe(true);
|
|
254
|
+
expect(result.responseTimeMs).toBe(0);
|
|
255
|
+
expect(result.error).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("returns healthy when registered envoy reports healthy", async () => {
|
|
259
|
+
const checker = new EnvoyHealthChecker();
|
|
260
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
261
|
+
vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(makeHealthResponse());
|
|
262
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
263
|
+
|
|
264
|
+
const result = await checker.check("op-1/staging", context);
|
|
265
|
+
expect(result.reachable).toBe(true);
|
|
266
|
+
expect(result.error).toBeNull();
|
|
267
|
+
expect(typeof result.responseTimeMs).toBe("number");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("returns not reachable when envoy reports degraded and not ready", async () => {
|
|
271
|
+
const checker = new EnvoyHealthChecker();
|
|
272
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
273
|
+
vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(
|
|
274
|
+
makeHealthResponse({
|
|
275
|
+
status: "degraded",
|
|
276
|
+
readiness: { ready: false, reason: "disk full" },
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
280
|
+
|
|
281
|
+
const result = await checker.check("op-1/staging", context);
|
|
282
|
+
expect(result.reachable).toBe(false);
|
|
283
|
+
expect(result.error).toContain("degraded");
|
|
284
|
+
expect(result.error).toContain("disk full");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns ETIMEDOUT error when envoy health check is aborted", async () => {
|
|
288
|
+
const checker = new EnvoyHealthChecker();
|
|
289
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 1);
|
|
290
|
+
vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
|
|
291
|
+
new DOMException("The operation was aborted", "AbortError"),
|
|
292
|
+
);
|
|
293
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
294
|
+
|
|
295
|
+
const result = await checker.check("op-1/staging", context);
|
|
296
|
+
expect(result.reachable).toBe(false);
|
|
297
|
+
expect(result.error).toContain("ETIMEDOUT");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns ECONNREFUSED error when envoy is not responding", async () => {
|
|
301
|
+
const checker = new EnvoyHealthChecker();
|
|
302
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
303
|
+
vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
|
|
304
|
+
new Error("fetch failed: ECONNREFUSED"),
|
|
305
|
+
);
|
|
306
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
307
|
+
|
|
308
|
+
const result = await checker.check("op-1/staging", context);
|
|
309
|
+
expect(result.reachable).toBe(false);
|
|
310
|
+
expect(result.error).toContain("ECONNREFUSED");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns generic error for unexpected failures", async () => {
|
|
314
|
+
const checker = new EnvoyHealthChecker();
|
|
315
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
316
|
+
vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
|
|
317
|
+
new Error("something completely unexpected"),
|
|
318
|
+
);
|
|
319
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
320
|
+
|
|
321
|
+
const result = await checker.check("op-1/staging", context);
|
|
322
|
+
expect(result.reachable).toBe(false);
|
|
323
|
+
expect(result.error).toContain("health check failed");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("returns healthy=true even when status is healthy but readiness is false", async () => {
|
|
327
|
+
const checker = new EnvoyHealthChecker();
|
|
328
|
+
const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
|
|
329
|
+
vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(
|
|
330
|
+
makeHealthResponse({
|
|
331
|
+
status: "healthy",
|
|
332
|
+
readiness: { ready: false, reason: "warming up" },
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
checker.registerEnvoy("op-1/staging", envoyClient);
|
|
336
|
+
|
|
337
|
+
const result = await checker.check("op-1/staging", context);
|
|
338
|
+
// healthy status but not ready -> not reachable
|
|
339
|
+
expect(result.reachable).toBe(false);
|
|
340
|
+
expect(result.error).toContain("warming up");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import type { FastifyInstance } from "fastify";
|
|
4
|
+
import { DecisionDebrief } from "@synth-deploy/core";
|
|
5
|
+
import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
|
|
6
|
+
import { registerEnvoyReportRoutes } from "../src/api/envoy-reports.js";
|
|
7
|
+
import type { EnvoyRegistry } from "../src/agent/envoy-registry.js";
|
|
8
|
+
|
|
9
|
+
const TEST_TOKEN = "test-envoy-token";
|
|
10
|
+
|
|
11
|
+
// Minimal mock registry — validates TEST_TOKEN, rejects all others
|
|
12
|
+
const mockRegistry = {
|
|
13
|
+
validateToken: (token: string) => token === TEST_TOKEN ? { id: "envoy-1" } : undefined,
|
|
14
|
+
} as unknown as EnvoyRegistry;
|
|
15
|
+
|
|
16
|
+
function makeReport(overrides: Record<string, unknown> = {}) {
|
|
17
|
+
return {
|
|
18
|
+
type: "deployment-result",
|
|
19
|
+
envoyId: "envoy-1",
|
|
20
|
+
deploymentId: "dep-1",
|
|
21
|
+
success: true,
|
|
22
|
+
failureReason: null,
|
|
23
|
+
debriefEntries: [
|
|
24
|
+
{
|
|
25
|
+
id: "entry-1",
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
partitionId: "part-1",
|
|
28
|
+
deploymentId: "dep-1",
|
|
29
|
+
agent: "envoy",
|
|
30
|
+
decisionType: "deployment-execution",
|
|
31
|
+
decision: "Ran step",
|
|
32
|
+
reasoning: "It was the next step",
|
|
33
|
+
context: {},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
summary: {
|
|
37
|
+
artifacts: [],
|
|
38
|
+
workspacePath: "/tmp/ws",
|
|
39
|
+
executionDurationMs: 100,
|
|
40
|
+
totalDurationMs: 200,
|
|
41
|
+
verificationPassed: true,
|
|
42
|
+
verificationChecks: [],
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("Envoy report ingestion", () => {
|
|
49
|
+
let app: FastifyInstance;
|
|
50
|
+
let debrief: DecisionDebrief;
|
|
51
|
+
let deployments: InMemoryDeploymentStore;
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
app = Fastify();
|
|
55
|
+
debrief = new DecisionDebrief();
|
|
56
|
+
deployments = new InMemoryDeploymentStore();
|
|
57
|
+
registerEnvoyReportRoutes(app, debrief, deployments, mockRegistry);
|
|
58
|
+
await app.ready();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await app?.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts a valid report when deployment belongs to partition", async () => {
|
|
66
|
+
deployments.save({
|
|
67
|
+
id: "dep-1",
|
|
68
|
+
operationId: "op-1",
|
|
69
|
+
partitionId: "part-1",
|
|
70
|
+
environmentId: "env-1",
|
|
71
|
+
version: "1.0",
|
|
72
|
+
status: "running",
|
|
73
|
+
variables: {},
|
|
74
|
+
debriefEntryIds: [],
|
|
75
|
+
orderId: null,
|
|
76
|
+
createdAt: new Date(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const res = await app.inject({
|
|
80
|
+
method: "POST",
|
|
81
|
+
url: "/api/envoy/report",
|
|
82
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
83
|
+
payload: makeReport(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(res.statusCode).toBe(200);
|
|
87
|
+
expect(res.json().accepted).toBe(true);
|
|
88
|
+
expect(res.json().entriesIngested).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects report with cross-partition deployment (403)", async () => {
|
|
92
|
+
// Deployment belongs to part-2, but report claims part-1
|
|
93
|
+
deployments.save({
|
|
94
|
+
id: "dep-1",
|
|
95
|
+
operationId: "op-1",
|
|
96
|
+
partitionId: "part-2",
|
|
97
|
+
environmentId: "env-1",
|
|
98
|
+
version: "1.0",
|
|
99
|
+
status: "running",
|
|
100
|
+
variables: {},
|
|
101
|
+
debriefEntryIds: [],
|
|
102
|
+
orderId: null,
|
|
103
|
+
createdAt: new Date(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const res = await app.inject({
|
|
107
|
+
method: "POST",
|
|
108
|
+
url: "/api/envoy/report",
|
|
109
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
110
|
+
payload: makeReport(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(res.statusCode).toBe(403);
|
|
114
|
+
expect(res.json().error).toContain("Partition boundary");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects report with unknown deployment (403)", async () => {
|
|
118
|
+
// No deployment saved — dep-1 doesn't exist
|
|
119
|
+
const res = await app.inject({
|
|
120
|
+
method: "POST",
|
|
121
|
+
url: "/api/envoy/report",
|
|
122
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
123
|
+
payload: makeReport(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(res.statusCode).toBe(403);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects invalid decisionType", async () => {
|
|
130
|
+
deployments.save({
|
|
131
|
+
id: "dep-1",
|
|
132
|
+
operationId: "op-1",
|
|
133
|
+
partitionId: "part-1",
|
|
134
|
+
environmentId: "env-1",
|
|
135
|
+
version: "1.0",
|
|
136
|
+
status: "running",
|
|
137
|
+
variables: {},
|
|
138
|
+
debriefEntryIds: [],
|
|
139
|
+
orderId: null,
|
|
140
|
+
createdAt: new Date(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const report = makeReport();
|
|
144
|
+
(report as any).debriefEntries[0].decisionType = "totally-fake-type";
|
|
145
|
+
|
|
146
|
+
const res = await app.inject({
|
|
147
|
+
method: "POST",
|
|
148
|
+
url: "/api/envoy/report",
|
|
149
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
150
|
+
payload: report,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(res.statusCode).toBe(400);
|
|
154
|
+
expect(res.json().error).toContain("Invalid");
|
|
155
|
+
});
|
|
156
|
+
});
|