@lobu/gateway 3.0.5 → 3.0.6
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/package.json +2 -2
- package/src/__tests__/agent-config-routes.test.ts +254 -0
- package/src/__tests__/agent-history-routes.test.ts +72 -0
- package/src/__tests__/agent-routes.test.ts +68 -0
- package/src/__tests__/agent-schedules-routes.test.ts +59 -0
- package/src/__tests__/agent-settings-store.test.ts +323 -0
- package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
- package/src/__tests__/chat-response-bridge.test.ts +131 -0
- package/src/__tests__/config-memory-plugins.test.ts +92 -0
- package/src/__tests__/config-request-store.test.ts +127 -0
- package/src/__tests__/connection-routes.test.ts +144 -0
- package/src/__tests__/core-services-store-selection.test.ts +92 -0
- package/src/__tests__/docker-deployment.test.ts +1211 -0
- package/src/__tests__/embedded-deployment.test.ts +342 -0
- package/src/__tests__/grant-store.test.ts +148 -0
- package/src/__tests__/http-proxy.test.ts +281 -0
- package/src/__tests__/instruction-service.test.ts +37 -0
- package/src/__tests__/link-buttons.test.ts +112 -0
- package/src/__tests__/lobu.test.ts +32 -0
- package/src/__tests__/mcp-config-service.test.ts +347 -0
- package/src/__tests__/mcp-proxy.test.ts +696 -0
- package/src/__tests__/message-handler-bridge.test.ts +17 -0
- package/src/__tests__/model-selection.test.ts +172 -0
- package/src/__tests__/oauth-templates.test.ts +39 -0
- package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
- package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
- package/src/__tests__/provider-inheritance.test.ts +212 -0
- package/src/__tests__/routes/cli-auth.test.ts +337 -0
- package/src/__tests__/routes/interactions.test.ts +121 -0
- package/src/__tests__/secret-proxy.test.ts +85 -0
- package/src/__tests__/session-manager.test.ts +572 -0
- package/src/__tests__/setup.ts +133 -0
- package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
- package/src/__tests__/slack-routes.test.ts +161 -0
- package/src/__tests__/system-config-resolver.test.ts +75 -0
- package/src/__tests__/system-message-limiter.test.ts +89 -0
- package/src/__tests__/system-skills-service.test.ts +362 -0
- package/src/__tests__/transcription-service.test.ts +222 -0
- package/src/__tests__/utils/rate-limiter.test.ts +102 -0
- package/src/__tests__/worker-connection-manager.test.ts +497 -0
- package/src/__tests__/worker-job-router.test.ts +722 -0
- package/src/api/index.ts +1 -0
- package/src/api/platform.ts +292 -0
- package/src/api/response-renderer.ts +157 -0
- package/src/auth/agent-metadata-store.ts +168 -0
- package/src/auth/api-auth-middleware.ts +69 -0
- package/src/auth/api-key-provider-module.ts +213 -0
- package/src/auth/base-provider-module.ts +201 -0
- package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
- package/src/auth/chatgpt/device-code-client.ts +218 -0
- package/src/auth/chatgpt/index.ts +1 -0
- package/src/auth/claude/oauth-module.ts +280 -0
- package/src/auth/cli/token-service.ts +249 -0
- package/src/auth/external/client.ts +560 -0
- package/src/auth/external/device-code-client.ts +225 -0
- package/src/auth/mcp/config-service.ts +392 -0
- package/src/auth/mcp/proxy.ts +1088 -0
- package/src/auth/mcp/string-substitution.ts +17 -0
- package/src/auth/mcp/tool-cache.ts +90 -0
- package/src/auth/oauth/base-client.ts +267 -0
- package/src/auth/oauth/client.ts +153 -0
- package/src/auth/oauth/credentials.ts +7 -0
- package/src/auth/oauth/providers.ts +69 -0
- package/src/auth/oauth/state-store.ts +150 -0
- package/src/auth/oauth-templates.ts +179 -0
- package/src/auth/provider-catalog.ts +220 -0
- package/src/auth/provider-model-options.ts +41 -0
- package/src/auth/settings/agent-settings-store.ts +565 -0
- package/src/auth/settings/auth-profiles-manager.ts +216 -0
- package/src/auth/settings/index.ts +12 -0
- package/src/auth/settings/model-preference-store.ts +52 -0
- package/src/auth/settings/model-selection.ts +135 -0
- package/src/auth/settings/resolved-settings-view.ts +298 -0
- package/src/auth/settings/template-utils.ts +44 -0
- package/src/auth/settings/token-service.ts +88 -0
- package/src/auth/system-env-store.ts +98 -0
- package/src/auth/user-agents-store.ts +68 -0
- package/src/channels/binding-service.ts +214 -0
- package/src/channels/index.ts +4 -0
- package/src/cli/gateway.ts +1304 -0
- package/src/cli/index.ts +74 -0
- package/src/commands/built-in-commands.ts +80 -0
- package/src/commands/command-dispatcher.ts +94 -0
- package/src/commands/command-reply-adapters.ts +27 -0
- package/src/config/file-loader.ts +618 -0
- package/src/config/index.ts +588 -0
- package/src/config/network-allowlist.ts +71 -0
- package/src/connections/chat-instance-manager.ts +1284 -0
- package/src/connections/chat-response-bridge.ts +618 -0
- package/src/connections/index.ts +7 -0
- package/src/connections/interaction-bridge.ts +831 -0
- package/src/connections/message-handler-bridge.ts +415 -0
- package/src/connections/platform-auth-methods.ts +15 -0
- package/src/connections/types.ts +84 -0
- package/src/gateway/connection-manager.ts +291 -0
- package/src/gateway/index.ts +700 -0
- package/src/gateway/job-router.ts +201 -0
- package/src/gateway-main.ts +200 -0
- package/src/index.ts +41 -0
- package/src/infrastructure/queue/index.ts +12 -0
- package/src/infrastructure/queue/queue-producer.ts +148 -0
- package/src/infrastructure/queue/redis-queue.ts +361 -0
- package/src/infrastructure/queue/types.ts +133 -0
- package/src/infrastructure/redis/system-message-limiter.ts +94 -0
- package/src/interactions/config-request-store.ts +198 -0
- package/src/interactions.ts +363 -0
- package/src/lobu.ts +311 -0
- package/src/metrics/prometheus.ts +159 -0
- package/src/modules/module-system.ts +179 -0
- package/src/orchestration/base-deployment-manager.ts +900 -0
- package/src/orchestration/deployment-utils.ts +98 -0
- package/src/orchestration/impl/docker-deployment.ts +620 -0
- package/src/orchestration/impl/embedded-deployment.ts +268 -0
- package/src/orchestration/impl/index.ts +8 -0
- package/src/orchestration/impl/k8s/deployment.ts +1061 -0
- package/src/orchestration/impl/k8s/helpers.ts +610 -0
- package/src/orchestration/impl/k8s/index.ts +1 -0
- package/src/orchestration/index.ts +333 -0
- package/src/orchestration/message-consumer.ts +584 -0
- package/src/orchestration/scheduled-wakeup.ts +704 -0
- package/src/permissions/approval-policy.ts +36 -0
- package/src/permissions/grant-store.ts +219 -0
- package/src/platform/file-handler.ts +66 -0
- package/src/platform/link-buttons.ts +57 -0
- package/src/platform/renderer-utils.ts +44 -0
- package/src/platform/response-renderer.ts +84 -0
- package/src/platform/unified-thread-consumer.ts +187 -0
- package/src/platform.ts +318 -0
- package/src/proxy/http-proxy.ts +752 -0
- package/src/proxy/proxy-manager.ts +81 -0
- package/src/proxy/secret-proxy.ts +402 -0
- package/src/proxy/token-refresh-job.ts +143 -0
- package/src/routes/internal/audio.ts +141 -0
- package/src/routes/internal/device-auth.ts +566 -0
- package/src/routes/internal/files.ts +226 -0
- package/src/routes/internal/history.ts +69 -0
- package/src/routes/internal/images.ts +127 -0
- package/src/routes/internal/interactions.ts +84 -0
- package/src/routes/internal/middleware.ts +23 -0
- package/src/routes/internal/schedule.ts +226 -0
- package/src/routes/internal/types.ts +22 -0
- package/src/routes/openapi-auto.ts +239 -0
- package/src/routes/public/agent-access.ts +23 -0
- package/src/routes/public/agent-config.ts +675 -0
- package/src/routes/public/agent-history.ts +422 -0
- package/src/routes/public/agent-schedules.ts +296 -0
- package/src/routes/public/agent.ts +1086 -0
- package/src/routes/public/agents.ts +373 -0
- package/src/routes/public/channels.ts +191 -0
- package/src/routes/public/cli-auth.ts +883 -0
- package/src/routes/public/connections.ts +574 -0
- package/src/routes/public/landing.ts +16 -0
- package/src/routes/public/oauth.ts +147 -0
- package/src/routes/public/settings-auth.ts +104 -0
- package/src/routes/public/slack.ts +173 -0
- package/src/routes/shared/agent-ownership.ts +101 -0
- package/src/routes/shared/token-verifier.ts +34 -0
- package/src/services/core-services.ts +1053 -0
- package/src/services/image-generation-service.ts +257 -0
- package/src/services/instruction-service.ts +318 -0
- package/src/services/mcp-registry.ts +94 -0
- package/src/services/platform-helpers.ts +287 -0
- package/src/services/session-manager.ts +262 -0
- package/src/services/settings-resolver.ts +74 -0
- package/src/services/system-config-resolver.ts +90 -0
- package/src/services/system-skills-service.ts +229 -0
- package/src/services/transcription-service.ts +684 -0
- package/src/session.ts +110 -0
- package/src/spaces/index.ts +1 -0
- package/src/spaces/space-resolver.ts +17 -0
- package/src/stores/in-memory-agent-store.ts +403 -0
- package/src/stores/redis-agent-store.ts +279 -0
- package/src/utils/public-url.ts +44 -0
- package/src/utils/rate-limiter.ts +94 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import type {
|
|
12
|
+
MessagePayload,
|
|
13
|
+
OrchestratorConfig,
|
|
14
|
+
} from "../orchestration/base-deployment-manager";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Mock dockerode
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const mockContainer = {
|
|
21
|
+
id: "container-id-123",
|
|
22
|
+
start: mock(async () => {
|
|
23
|
+
/* noop */
|
|
24
|
+
}),
|
|
25
|
+
stop: mock(async () => {
|
|
26
|
+
/* noop */
|
|
27
|
+
}),
|
|
28
|
+
remove: mock(async () => {
|
|
29
|
+
/* noop */
|
|
30
|
+
}),
|
|
31
|
+
inspect: mock(async () => ({ State: { Running: true } })),
|
|
32
|
+
wait: mock(async () => {
|
|
33
|
+
/* noop */
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mockVolume = {
|
|
38
|
+
inspect: mock(async () => ({})),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const mockNetwork = {
|
|
42
|
+
inspect: mock(async () => ({ Internal: true })),
|
|
43
|
+
connect: mock(async () => {
|
|
44
|
+
/* noop */
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const mockDocker = {
|
|
49
|
+
info: mock(async () => ({ Runtimes: {} })),
|
|
50
|
+
createContainer: mock(async () => mockContainer),
|
|
51
|
+
getContainer: mock(() => mockContainer),
|
|
52
|
+
createVolume: mock(async () => mockVolume),
|
|
53
|
+
getVolume: mock(() => mockVolume),
|
|
54
|
+
createNetwork: mock(async () => mockNetwork),
|
|
55
|
+
getNetwork: mock(() => mockNetwork),
|
|
56
|
+
listContainers: mock(async () => []),
|
|
57
|
+
getImage: mock(() => ({ inspect: mock(async () => ({})) })),
|
|
58
|
+
pull: mock((_name: string, cb: Function) =>
|
|
59
|
+
cb(null, {
|
|
60
|
+
on: () => {
|
|
61
|
+
/* noop */
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
),
|
|
65
|
+
modem: { followProgress: (_stream: any, cb: Function) => cb(null) },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
mock.module("dockerode", () => ({
|
|
69
|
+
default: class MockDocker {
|
|
70
|
+
constructor() {
|
|
71
|
+
Object.assign(this, mockDocker);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
// Must import after mocks are set up
|
|
77
|
+
const { DockerDeploymentManager } = await import(
|
|
78
|
+
"../orchestration/impl/docker-deployment"
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Constants & helpers
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const TEST_ENCRYPTION_KEY =
|
|
86
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
87
|
+
|
|
88
|
+
const TEST_CONFIG: OrchestratorConfig = {
|
|
89
|
+
queues: {
|
|
90
|
+
connectionString: "redis://localhost:6379",
|
|
91
|
+
retryLimit: 3,
|
|
92
|
+
retryDelay: 5,
|
|
93
|
+
expireInSeconds: 300,
|
|
94
|
+
},
|
|
95
|
+
worker: {
|
|
96
|
+
image: {
|
|
97
|
+
repository: "lobu-worker",
|
|
98
|
+
tag: "latest",
|
|
99
|
+
pullPolicy: "IfNotPresent",
|
|
100
|
+
},
|
|
101
|
+
resources: {
|
|
102
|
+
requests: { cpu: "100m", memory: "128Mi" },
|
|
103
|
+
limits: { cpu: "500m", memory: "512Mi" },
|
|
104
|
+
},
|
|
105
|
+
idleCleanupMinutes: 30,
|
|
106
|
+
maxDeployments: 10,
|
|
107
|
+
},
|
|
108
|
+
kubernetes: { namespace: "default" },
|
|
109
|
+
cleanup: { initialDelayMs: 5000, intervalMs: 60000, veryOldDays: 7 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function createTestMessagePayload(
|
|
113
|
+
overrides?: Partial<MessagePayload>
|
|
114
|
+
): MessagePayload {
|
|
115
|
+
return {
|
|
116
|
+
userId: "user-1",
|
|
117
|
+
conversationId: "conv-1",
|
|
118
|
+
channelId: "ch-1",
|
|
119
|
+
messageId: "msg-1",
|
|
120
|
+
teamId: "team-1",
|
|
121
|
+
agentId: "test-agent",
|
|
122
|
+
botId: "bot-1",
|
|
123
|
+
platform: "slack",
|
|
124
|
+
messageText: "hello",
|
|
125
|
+
platformMetadata: {},
|
|
126
|
+
agentOptions: {},
|
|
127
|
+
...overrides,
|
|
128
|
+
} as MessagePayload;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const MOCK_DEFAULTS: Array<
|
|
132
|
+
[{ mockReset: Function; mockImplementation: Function }, Function]
|
|
133
|
+
> = [
|
|
134
|
+
[
|
|
135
|
+
mockContainer.start,
|
|
136
|
+
async () => {
|
|
137
|
+
/* noop */
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
mockContainer.stop,
|
|
142
|
+
async () => {
|
|
143
|
+
/* noop */
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
mockContainer.remove,
|
|
148
|
+
async () => {
|
|
149
|
+
/* noop */
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
[mockContainer.inspect, async () => ({ State: { Running: true } })],
|
|
153
|
+
[mockDocker.info, async () => ({ Runtimes: {} })],
|
|
154
|
+
[mockDocker.createContainer, async () => mockContainer],
|
|
155
|
+
[mockDocker.getContainer, () => mockContainer],
|
|
156
|
+
[mockDocker.createVolume, async () => mockVolume],
|
|
157
|
+
[mockDocker.getVolume, () => mockVolume],
|
|
158
|
+
[mockDocker.createNetwork, async () => mockNetwork],
|
|
159
|
+
[mockDocker.getNetwork, () => mockNetwork],
|
|
160
|
+
[mockDocker.listContainers, async () => []],
|
|
161
|
+
[mockDocker.getImage, () => ({ inspect: mock(async () => ({})) })],
|
|
162
|
+
[
|
|
163
|
+
mockDocker.pull,
|
|
164
|
+
(_name: string, cb: Function) =>
|
|
165
|
+
cb(null, {
|
|
166
|
+
on: () => {
|
|
167
|
+
/* noop */
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
[mockVolume.inspect, async () => ({})],
|
|
172
|
+
[mockNetwork.inspect, async () => ({ Internal: true })],
|
|
173
|
+
[
|
|
174
|
+
mockNetwork.connect,
|
|
175
|
+
async () => {
|
|
176
|
+
/* noop */
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
function resetAllMocks() {
|
|
182
|
+
for (const [mockFn, impl] of MOCK_DEFAULTS) {
|
|
183
|
+
mockFn.mockReset();
|
|
184
|
+
mockFn.mockImplementation(impl);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Extract the main container creation options (skips init containers like alpine). */
|
|
189
|
+
function getMainCreateOpts(): any {
|
|
190
|
+
const call = mockDocker.createContainer.mock.calls.find(
|
|
191
|
+
(c: any) => c[0]?.name === "test-deploy"
|
|
192
|
+
);
|
|
193
|
+
return call?.[0];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Test suite
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe("DockerDeploymentManager", () => {
|
|
201
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
202
|
+
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
savedEnv = { ...process.env };
|
|
205
|
+
process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
|
|
206
|
+
resetAllMocks();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
process.env = savedEnv;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
function createManager(configOverrides?: Partial<OrchestratorConfig>) {
|
|
214
|
+
const config = { ...TEST_CONFIG, ...configOverrides };
|
|
215
|
+
return new DockerDeploymentManager(config);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// =========================================================================
|
|
219
|
+
// ResourceParser (tested indirectly via createContainer args)
|
|
220
|
+
// =========================================================================
|
|
221
|
+
|
|
222
|
+
describe("ResourceParser (via createDeployment)", () => {
|
|
223
|
+
test("parseMemory: 512Mi -> 512 * 1024 * 1024", async () => {
|
|
224
|
+
const manager = createManager({
|
|
225
|
+
worker: {
|
|
226
|
+
...TEST_CONFIG.worker,
|
|
227
|
+
resources: {
|
|
228
|
+
requests: { cpu: "100m", memory: "128Mi" },
|
|
229
|
+
limits: { cpu: "500m", memory: "512Mi" },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await manager.createDeployment(
|
|
235
|
+
"test-deploy",
|
|
236
|
+
"user",
|
|
237
|
+
"user-id",
|
|
238
|
+
createTestMessagePayload()
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
|
|
242
|
+
expect(opts.HostConfig.Memory).toBe(512 * 1024 * 1024);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("parseMemory: 1Gi -> 1024 * 1024 * 1024", async () => {
|
|
246
|
+
const manager = createManager({
|
|
247
|
+
worker: {
|
|
248
|
+
...TEST_CONFIG.worker,
|
|
249
|
+
resources: {
|
|
250
|
+
requests: { cpu: "100m", memory: "128Mi" },
|
|
251
|
+
limits: { cpu: "1", memory: "1Gi" },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await manager.createDeployment(
|
|
257
|
+
"test-deploy",
|
|
258
|
+
"user",
|
|
259
|
+
"user-id",
|
|
260
|
+
createTestMessagePayload()
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
|
|
264
|
+
expect(opts.HostConfig.Memory).toBe(1024 * 1024 * 1024);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("parseCpu: 500m -> 500_000_000 nanocpus", async () => {
|
|
268
|
+
const manager = createManager();
|
|
269
|
+
|
|
270
|
+
await manager.createDeployment(
|
|
271
|
+
"test-deploy",
|
|
272
|
+
"user",
|
|
273
|
+
"user-id",
|
|
274
|
+
createTestMessagePayload()
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
|
|
278
|
+
expect(opts.HostConfig.NanoCpus).toBe(500_000_000);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("parseCpu: 1 core -> 1_000_000_000 nanocpus", async () => {
|
|
282
|
+
const manager = createManager({
|
|
283
|
+
worker: {
|
|
284
|
+
...TEST_CONFIG.worker,
|
|
285
|
+
resources: {
|
|
286
|
+
requests: { cpu: "100m", memory: "128Mi" },
|
|
287
|
+
limits: { cpu: "1", memory: "512Mi" },
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await manager.createDeployment(
|
|
293
|
+
"test-deploy",
|
|
294
|
+
"user",
|
|
295
|
+
"user-id",
|
|
296
|
+
createTestMessagePayload()
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const opts = mockDocker.createContainer.mock.calls[0]?.[0] as any;
|
|
300
|
+
expect(opts.HostConfig.NanoCpus).toBe(1_000_000_000);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// =========================================================================
|
|
305
|
+
// Container creation & security
|
|
306
|
+
// =========================================================================
|
|
307
|
+
|
|
308
|
+
describe("createDeployment", () => {
|
|
309
|
+
test("calls docker.createContainer with correct image", async () => {
|
|
310
|
+
const manager = createManager();
|
|
311
|
+
await manager.createDeployment(
|
|
312
|
+
"test-deploy",
|
|
313
|
+
"user",
|
|
314
|
+
"user-id",
|
|
315
|
+
createTestMessagePayload()
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const opts = getMainCreateOpts();
|
|
319
|
+
expect(opts).toBeDefined();
|
|
320
|
+
expect(opts.Image).toBe("lobu-worker:latest");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("drops all capabilities: CapDrop=['ALL']", async () => {
|
|
324
|
+
const manager = createManager();
|
|
325
|
+
await manager.createDeployment(
|
|
326
|
+
"test-deploy",
|
|
327
|
+
"user",
|
|
328
|
+
"user-id",
|
|
329
|
+
createTestMessagePayload()
|
|
330
|
+
);
|
|
331
|
+
expect(getMainCreateOpts().HostConfig.CapDrop).toEqual(["ALL"]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("adds configurable capabilities via WORKER_CAPABILITIES env var", async () => {
|
|
335
|
+
process.env.WORKER_CAPABILITIES = "NET_BIND_SERVICE,SYS_PTRACE";
|
|
336
|
+
const manager = createManager();
|
|
337
|
+
await manager.createDeployment(
|
|
338
|
+
"test-deploy",
|
|
339
|
+
"user",
|
|
340
|
+
"user-id",
|
|
341
|
+
createTestMessagePayload()
|
|
342
|
+
);
|
|
343
|
+
expect(getMainCreateOpts().HostConfig.CapAdd).toEqual([
|
|
344
|
+
"NET_BIND_SERVICE",
|
|
345
|
+
"SYS_PTRACE",
|
|
346
|
+
]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("empty CapAdd when WORKER_CAPABILITIES not set", async () => {
|
|
350
|
+
delete process.env.WORKER_CAPABILITIES;
|
|
351
|
+
const manager = createManager();
|
|
352
|
+
await manager.createDeployment(
|
|
353
|
+
"test-deploy",
|
|
354
|
+
"user",
|
|
355
|
+
"user-id",
|
|
356
|
+
createTestMessagePayload()
|
|
357
|
+
);
|
|
358
|
+
expect(getMainCreateOpts().HostConfig.CapAdd).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("enables no-new-privileges security option", async () => {
|
|
362
|
+
const manager = createManager();
|
|
363
|
+
await manager.createDeployment(
|
|
364
|
+
"test-deploy",
|
|
365
|
+
"user",
|
|
366
|
+
"user-id",
|
|
367
|
+
createTestMessagePayload()
|
|
368
|
+
);
|
|
369
|
+
expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
|
|
370
|
+
"no-new-privileges:true"
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("uses readonly rootfs by default", async () => {
|
|
375
|
+
const manager = createManager();
|
|
376
|
+
await manager.createDeployment(
|
|
377
|
+
"test-deploy",
|
|
378
|
+
"user",
|
|
379
|
+
"user-id",
|
|
380
|
+
createTestMessagePayload()
|
|
381
|
+
);
|
|
382
|
+
expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("disables readonly rootfs when Nix packages configured", async () => {
|
|
386
|
+
const manager = createManager();
|
|
387
|
+
await manager.createDeployment(
|
|
388
|
+
"test-deploy",
|
|
389
|
+
"user",
|
|
390
|
+
"user-id",
|
|
391
|
+
createTestMessagePayload({ nixConfig: { packages: ["nodejs"] } })
|
|
392
|
+
);
|
|
393
|
+
expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("disables readonly rootfs when Nix flakeUrl configured", async () => {
|
|
397
|
+
const manager = createManager();
|
|
398
|
+
await manager.createDeployment(
|
|
399
|
+
"test-deploy",
|
|
400
|
+
"user",
|
|
401
|
+
"user-id",
|
|
402
|
+
createTestMessagePayload({
|
|
403
|
+
nixConfig: { flakeUrl: "github:owner/repo" },
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
expect(getMainCreateOpts().HostConfig.ReadonlyRootfs).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("adds tmpfs for /tmp when readonly rootfs enabled", async () => {
|
|
410
|
+
const manager = createManager();
|
|
411
|
+
await manager.createDeployment(
|
|
412
|
+
"test-deploy",
|
|
413
|
+
"user",
|
|
414
|
+
"user-id",
|
|
415
|
+
createTestMessagePayload()
|
|
416
|
+
);
|
|
417
|
+
expect(getMainCreateOpts().HostConfig.Tmpfs).toEqual({
|
|
418
|
+
"/tmp": "rw,noexec,nosuid,size=100m",
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("does not add tmpfs when Nix packages configured", async () => {
|
|
423
|
+
const manager = createManager();
|
|
424
|
+
await manager.createDeployment(
|
|
425
|
+
"test-deploy",
|
|
426
|
+
"user",
|
|
427
|
+
"user-id",
|
|
428
|
+
createTestMessagePayload({ nixConfig: { packages: ["nodejs"] } })
|
|
429
|
+
);
|
|
430
|
+
expect(getMainCreateOpts().HostConfig.Tmpfs).toBeUndefined();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("sets ShmSize to 256MB (268435456)", async () => {
|
|
434
|
+
const manager = createManager();
|
|
435
|
+
await manager.createDeployment(
|
|
436
|
+
"test-deploy",
|
|
437
|
+
"user",
|
|
438
|
+
"user-id",
|
|
439
|
+
createTestMessagePayload()
|
|
440
|
+
);
|
|
441
|
+
expect(getMainCreateOpts().HostConfig.ShmSize).toBe(268435456);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("uses gvisor runtime when available", async () => {
|
|
445
|
+
mockDocker.info.mockImplementation(async () => ({
|
|
446
|
+
Runtimes: { runsc: {} },
|
|
447
|
+
}));
|
|
448
|
+
const manager = createManager();
|
|
449
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
450
|
+
|
|
451
|
+
await manager.createDeployment(
|
|
452
|
+
"test-deploy",
|
|
453
|
+
"user",
|
|
454
|
+
"user-id",
|
|
455
|
+
createTestMessagePayload()
|
|
456
|
+
);
|
|
457
|
+
expect(getMainCreateOpts().HostConfig.Runtime).toBe("runsc");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("uses default runtime when gvisor unavailable", async () => {
|
|
461
|
+
const manager = createManager();
|
|
462
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
463
|
+
|
|
464
|
+
await manager.createDeployment(
|
|
465
|
+
"test-deploy",
|
|
466
|
+
"user",
|
|
467
|
+
"user-id",
|
|
468
|
+
createTestMessagePayload()
|
|
469
|
+
);
|
|
470
|
+
expect(getMainCreateOpts().HostConfig.Runtime).toBeUndefined();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("starts container after creation", async () => {
|
|
474
|
+
const manager = createManager();
|
|
475
|
+
await manager.createDeployment(
|
|
476
|
+
"test-deploy",
|
|
477
|
+
"user",
|
|
478
|
+
"user-id",
|
|
479
|
+
createTestMessagePayload()
|
|
480
|
+
);
|
|
481
|
+
expect(mockContainer.start).toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("removes container if start fails", async () => {
|
|
485
|
+
const removeMock = mock(async () => {
|
|
486
|
+
/* noop */
|
|
487
|
+
});
|
|
488
|
+
mockDocker.createContainer.mockImplementation(async (opts: any) => {
|
|
489
|
+
if (opts?.name === "test-deploy") {
|
|
490
|
+
return {
|
|
491
|
+
...mockContainer,
|
|
492
|
+
id: "failed-container",
|
|
493
|
+
start: mock(async () => {
|
|
494
|
+
throw new Error("start failed");
|
|
495
|
+
}),
|
|
496
|
+
remove: removeMock,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
return mockContainer;
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const manager = createManager();
|
|
503
|
+
|
|
504
|
+
await expect(
|
|
505
|
+
manager.createDeployment(
|
|
506
|
+
"test-deploy",
|
|
507
|
+
"user",
|
|
508
|
+
"user-id",
|
|
509
|
+
createTestMessagePayload()
|
|
510
|
+
)
|
|
511
|
+
).rejects.toThrow();
|
|
512
|
+
|
|
513
|
+
expect(removeMock).toHaveBeenCalledWith({ force: true });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("sets WorkingDir to /workspace", async () => {
|
|
517
|
+
const manager = createManager();
|
|
518
|
+
await manager.createDeployment(
|
|
519
|
+
"test-deploy",
|
|
520
|
+
"user",
|
|
521
|
+
"user-id",
|
|
522
|
+
createTestMessagePayload()
|
|
523
|
+
);
|
|
524
|
+
expect(getMainCreateOpts().WorkingDir).toBe("/workspace");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// =========================================================================
|
|
529
|
+
// Docker volume management
|
|
530
|
+
// =========================================================================
|
|
531
|
+
|
|
532
|
+
describe("volume management", () => {
|
|
533
|
+
test("volume created as lobu-workspace-{agentId}", async () => {
|
|
534
|
+
// Make getVolume throw so ensureVolume creates a new one
|
|
535
|
+
mockDocker.getVolume.mockImplementation(() => ({
|
|
536
|
+
inspect: mock(async () => {
|
|
537
|
+
throw new Error("no such volume");
|
|
538
|
+
}),
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
const manager = createManager();
|
|
542
|
+
|
|
543
|
+
await manager.createDeployment(
|
|
544
|
+
"test-deploy",
|
|
545
|
+
"user",
|
|
546
|
+
"user-id",
|
|
547
|
+
createTestMessagePayload({ agentId: "my-agent" })
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(mockDocker.createVolume).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({
|
|
552
|
+
Name: "lobu-workspace-my-agent",
|
|
553
|
+
})
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("volume shared across threads with same agentId", async () => {
|
|
558
|
+
const manager = createManager();
|
|
559
|
+
|
|
560
|
+
// First deployment
|
|
561
|
+
await manager.createDeployment(
|
|
562
|
+
"deploy-1",
|
|
563
|
+
"user",
|
|
564
|
+
"user-id",
|
|
565
|
+
createTestMessagePayload({ agentId: "shared-agent" })
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// Second deployment with same agentId
|
|
569
|
+
await manager.createDeployment(
|
|
570
|
+
"deploy-2",
|
|
571
|
+
"user",
|
|
572
|
+
"user-id",
|
|
573
|
+
createTestMessagePayload({ agentId: "shared-agent" })
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Both main container calls should reference the same volume name
|
|
577
|
+
const mainCalls = mockDocker.createContainer.mock.calls.filter(
|
|
578
|
+
(call: any) =>
|
|
579
|
+
call[0]?.name === "deploy-1" || call[0]?.name === "deploy-2"
|
|
580
|
+
);
|
|
581
|
+
expect(mainCalls.length).toBe(2);
|
|
582
|
+
|
|
583
|
+
// In production mode (non-development), uses Mounts with volume
|
|
584
|
+
for (const call of mainCalls) {
|
|
585
|
+
const opts = call[0] as any;
|
|
586
|
+
if (opts.HostConfig.Mounts) {
|
|
587
|
+
expect(opts.HostConfig.Mounts[0].Source).toBe(
|
|
588
|
+
"lobu-workspace-shared-agent"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("handles race condition on concurrent volume creation (409 conflict)", async () => {
|
|
595
|
+
mockDocker.getVolume.mockImplementation(() => ({
|
|
596
|
+
inspect: mock(async () => {
|
|
597
|
+
throw new Error("no such volume");
|
|
598
|
+
}),
|
|
599
|
+
}));
|
|
600
|
+
mockDocker.createVolume.mockImplementation(async () => {
|
|
601
|
+
const err: any = new Error("already exists");
|
|
602
|
+
err.statusCode = 409;
|
|
603
|
+
throw err;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const manager = createManager();
|
|
607
|
+
|
|
608
|
+
// Should not throw despite 409
|
|
609
|
+
await expect(
|
|
610
|
+
manager.createDeployment(
|
|
611
|
+
"test-deploy",
|
|
612
|
+
"user",
|
|
613
|
+
"user-id",
|
|
614
|
+
createTestMessagePayload()
|
|
615
|
+
)
|
|
616
|
+
).resolves.toBeUndefined();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("development mode uses bind mounts", async () => {
|
|
620
|
+
process.env.NODE_ENV = "development";
|
|
621
|
+
process.env.DEPLOYMENT_MODE = "docker";
|
|
622
|
+
process.env.LOBU_DEV_PROJECT_PATH = "/app";
|
|
623
|
+
|
|
624
|
+
const manager = createManager();
|
|
625
|
+
|
|
626
|
+
await manager.createDeployment(
|
|
627
|
+
"test-deploy",
|
|
628
|
+
"user",
|
|
629
|
+
"user-id",
|
|
630
|
+
createTestMessagePayload({ agentId: "dev-agent" })
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const mainCall = mockDocker.createContainer.mock.calls.find(
|
|
634
|
+
(call: any) => call[0]?.name === "test-deploy"
|
|
635
|
+
);
|
|
636
|
+
const opts = mainCall![0] as any;
|
|
637
|
+
expect(opts.HostConfig.Binds).toBeDefined();
|
|
638
|
+
expect(opts.HostConfig.Binds[0]).toContain(
|
|
639
|
+
"/app/workspaces/dev-agent:/workspace"
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// =========================================================================
|
|
645
|
+
// Docker network
|
|
646
|
+
// =========================================================================
|
|
647
|
+
|
|
648
|
+
describe("network management", () => {
|
|
649
|
+
test("internal network created/checked with Internal flag", async () => {
|
|
650
|
+
// The constructor calls ensureInternalNetwork
|
|
651
|
+
delete process.env.WORKER_NETWORK;
|
|
652
|
+
mockNetwork.inspect.mockImplementation(async () => ({ Internal: true }));
|
|
653
|
+
|
|
654
|
+
createManager();
|
|
655
|
+
// ensureInternalNetwork is fire-and-forget, give it time
|
|
656
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
657
|
+
|
|
658
|
+
expect(mockDocker.getNetwork).toHaveBeenCalled();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("WORKER_NETWORK env var overrides network name", async () => {
|
|
662
|
+
process.env.WORKER_NETWORK = "custom-network";
|
|
663
|
+
const manager = createManager();
|
|
664
|
+
await manager.createDeployment(
|
|
665
|
+
"test-deploy",
|
|
666
|
+
"user",
|
|
667
|
+
"user-id",
|
|
668
|
+
createTestMessagePayload()
|
|
669
|
+
);
|
|
670
|
+
expect(getMainCreateOpts().HostConfig.NetworkMode).toBe("custom-network");
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("uses compose project name for default network", async () => {
|
|
674
|
+
delete process.env.WORKER_NETWORK;
|
|
675
|
+
process.env.COMPOSE_PROJECT_NAME = "myproject";
|
|
676
|
+
const manager = createManager();
|
|
677
|
+
await manager.createDeployment(
|
|
678
|
+
"test-deploy",
|
|
679
|
+
"user",
|
|
680
|
+
"user-id",
|
|
681
|
+
createTestMessagePayload()
|
|
682
|
+
);
|
|
683
|
+
expect(getMainCreateOpts().HostConfig.NetworkMode).toBe(
|
|
684
|
+
"myproject_lobu-internal"
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("host mode connects to public network too", async () => {
|
|
689
|
+
delete process.env.WORKER_NETWORK;
|
|
690
|
+
// Simulate running on host (not in container)
|
|
691
|
+
delete process.env.CONTAINER;
|
|
692
|
+
const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
|
|
693
|
+
|
|
694
|
+
const manager = createManager();
|
|
695
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
696
|
+
|
|
697
|
+
await manager.createDeployment(
|
|
698
|
+
"test-deploy",
|
|
699
|
+
"user",
|
|
700
|
+
"user-id",
|
|
701
|
+
createTestMessagePayload()
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// Should attempt to connect to public network
|
|
705
|
+
expect(mockNetwork.connect).toHaveBeenCalled();
|
|
706
|
+
|
|
707
|
+
existsSpy.mockRestore();
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// =========================================================================
|
|
712
|
+
// Dispatcher host
|
|
713
|
+
// =========================================================================
|
|
714
|
+
|
|
715
|
+
describe("dispatcher host", () => {
|
|
716
|
+
function getDispatcherUrlEnv() {
|
|
717
|
+
return (getMainCreateOpts().Env as string[]).find((e: string) =>
|
|
718
|
+
e.startsWith("DISPATCHER_URL=")
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
test('returns "gateway" when running in container (/.dockerenv exists)', async () => {
|
|
723
|
+
const existsSpy = spyOn(fs, "existsSync").mockImplementation(
|
|
724
|
+
(p: any) => String(p) === "/.dockerenv"
|
|
725
|
+
);
|
|
726
|
+
const manager = createManager();
|
|
727
|
+
await manager.createDeployment(
|
|
728
|
+
"test-deploy",
|
|
729
|
+
"user",
|
|
730
|
+
"user-id",
|
|
731
|
+
createTestMessagePayload()
|
|
732
|
+
);
|
|
733
|
+
expect(getDispatcherUrlEnv()).toContain("gateway");
|
|
734
|
+
existsSpy.mockRestore();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('returns "gateway" when CONTAINER=true', async () => {
|
|
738
|
+
process.env.CONTAINER = "true";
|
|
739
|
+
const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
|
|
740
|
+
const manager = createManager();
|
|
741
|
+
await manager.createDeployment(
|
|
742
|
+
"test-deploy",
|
|
743
|
+
"user",
|
|
744
|
+
"user-id",
|
|
745
|
+
createTestMessagePayload()
|
|
746
|
+
);
|
|
747
|
+
expect(getDispatcherUrlEnv()).toContain("gateway");
|
|
748
|
+
existsSpy.mockRestore();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('returns "host.docker.internal" when running on host', async () => {
|
|
752
|
+
delete process.env.CONTAINER;
|
|
753
|
+
const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
|
|
754
|
+
const manager = createManager();
|
|
755
|
+
await manager.createDeployment(
|
|
756
|
+
"test-deploy",
|
|
757
|
+
"user",
|
|
758
|
+
"user-id",
|
|
759
|
+
createTestMessagePayload()
|
|
760
|
+
);
|
|
761
|
+
expect(getDispatcherUrlEnv()).toContain("host.docker.internal");
|
|
762
|
+
existsSpy.mockRestore();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// =========================================================================
|
|
767
|
+
// Docker image reference
|
|
768
|
+
// =========================================================================
|
|
769
|
+
|
|
770
|
+
describe("image reference", () => {
|
|
771
|
+
test("uses digest reference when configured: repo@sha256:abc123", async () => {
|
|
772
|
+
const manager = createManager({
|
|
773
|
+
worker: {
|
|
774
|
+
...TEST_CONFIG.worker,
|
|
775
|
+
image: {
|
|
776
|
+
repository: "lobu-worker",
|
|
777
|
+
tag: "latest",
|
|
778
|
+
digest: "abc123def456",
|
|
779
|
+
pullPolicy: "IfNotPresent",
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
await manager.createDeployment(
|
|
784
|
+
"test-deploy",
|
|
785
|
+
"user",
|
|
786
|
+
"user-id",
|
|
787
|
+
createTestMessagePayload()
|
|
788
|
+
);
|
|
789
|
+
expect(getMainCreateOpts().Image).toBe("lobu-worker@sha256:abc123def456");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("uses tag reference when no digest: repo:tag", async () => {
|
|
793
|
+
const manager = createManager();
|
|
794
|
+
await manager.createDeployment(
|
|
795
|
+
"test-deploy",
|
|
796
|
+
"user",
|
|
797
|
+
"user-id",
|
|
798
|
+
createTestMessagePayload()
|
|
799
|
+
);
|
|
800
|
+
expect(getMainCreateOpts().Image).toBe("lobu-worker:latest");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("handles digest that already has sha256: prefix", async () => {
|
|
804
|
+
const manager = createManager({
|
|
805
|
+
worker: {
|
|
806
|
+
...TEST_CONFIG.worker,
|
|
807
|
+
image: {
|
|
808
|
+
repository: "lobu-worker",
|
|
809
|
+
tag: "latest",
|
|
810
|
+
digest: "sha256:abc123def456",
|
|
811
|
+
pullPolicy: "IfNotPresent",
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
await manager.createDeployment(
|
|
816
|
+
"test-deploy",
|
|
817
|
+
"user",
|
|
818
|
+
"user-id",
|
|
819
|
+
createTestMessagePayload()
|
|
820
|
+
);
|
|
821
|
+
expect(getMainCreateOpts().Image).toBe("lobu-worker@sha256:abc123def456");
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// =========================================================================
|
|
826
|
+
// deleteDeployment
|
|
827
|
+
// =========================================================================
|
|
828
|
+
|
|
829
|
+
describe("deleteDeployment", () => {
|
|
830
|
+
test("calls stop + remove on container", async () => {
|
|
831
|
+
const manager = createManager();
|
|
832
|
+
|
|
833
|
+
await manager.deleteDeployment("test-deploy");
|
|
834
|
+
|
|
835
|
+
expect(mockContainer.stop).toHaveBeenCalled();
|
|
836
|
+
expect(mockContainer.remove).toHaveBeenCalled();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("handles 404 (container already deleted) gracefully", async () => {
|
|
840
|
+
const stopMock = mock(async () => {
|
|
841
|
+
/* noop */
|
|
842
|
+
});
|
|
843
|
+
const removeMock = mock(async () => {
|
|
844
|
+
const err: any = new Error("not found");
|
|
845
|
+
err.statusCode = 404;
|
|
846
|
+
throw err;
|
|
847
|
+
});
|
|
848
|
+
mockDocker.getContainer.mockImplementation(() => ({
|
|
849
|
+
stop: stopMock,
|
|
850
|
+
remove: removeMock,
|
|
851
|
+
}));
|
|
852
|
+
|
|
853
|
+
const manager = createManager();
|
|
854
|
+
|
|
855
|
+
// Should not throw on 404
|
|
856
|
+
await expect(
|
|
857
|
+
manager.deleteDeployment("nonexistent")
|
|
858
|
+
).resolves.toBeUndefined();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("handles already-stopped container gracefully", async () => {
|
|
862
|
+
mockContainer.stop.mockImplementation(async () => {
|
|
863
|
+
throw new Error("container already stopped");
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const manager = createManager();
|
|
867
|
+
|
|
868
|
+
// Should not throw - stop failure is caught
|
|
869
|
+
await expect(
|
|
870
|
+
manager.deleteDeployment("test-deploy")
|
|
871
|
+
).resolves.toBeUndefined();
|
|
872
|
+
expect(mockContainer.remove).toHaveBeenCalled();
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// =========================================================================
|
|
877
|
+
// scaleDeployment
|
|
878
|
+
// =========================================================================
|
|
879
|
+
|
|
880
|
+
describe("scaleDeployment", () => {
|
|
881
|
+
test("scaleDeployment(0) stops running container", async () => {
|
|
882
|
+
mockContainer.inspect.mockImplementation(async () => ({
|
|
883
|
+
State: { Running: true },
|
|
884
|
+
}));
|
|
885
|
+
|
|
886
|
+
const manager = createManager();
|
|
887
|
+
await manager.scaleDeployment("test-deploy", 0);
|
|
888
|
+
|
|
889
|
+
expect(mockContainer.stop).toHaveBeenCalled();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("scaleDeployment(1) starts stopped container", async () => {
|
|
893
|
+
mockContainer.inspect.mockImplementation(async () => ({
|
|
894
|
+
State: { Running: false },
|
|
895
|
+
}));
|
|
896
|
+
|
|
897
|
+
const manager = createManager();
|
|
898
|
+
await manager.scaleDeployment("test-deploy", 1);
|
|
899
|
+
|
|
900
|
+
expect(mockContainer.start).toHaveBeenCalled();
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("scaleDeployment(0) is a no-op if container already stopped", async () => {
|
|
904
|
+
mockContainer.inspect.mockImplementation(async () => ({
|
|
905
|
+
State: { Running: false },
|
|
906
|
+
}));
|
|
907
|
+
|
|
908
|
+
const manager = createManager();
|
|
909
|
+
await manager.scaleDeployment("test-deploy", 0);
|
|
910
|
+
|
|
911
|
+
expect(mockContainer.stop).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("scaleDeployment(1) is a no-op if container already running", async () => {
|
|
915
|
+
mockContainer.inspect.mockImplementation(async () => ({
|
|
916
|
+
State: { Running: true },
|
|
917
|
+
}));
|
|
918
|
+
|
|
919
|
+
const manager = createManager();
|
|
920
|
+
await manager.scaleDeployment("test-deploy", 1);
|
|
921
|
+
|
|
922
|
+
expect(mockContainer.start).not.toHaveBeenCalled();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// =========================================================================
|
|
927
|
+
// listDeployments
|
|
928
|
+
// =========================================================================
|
|
929
|
+
|
|
930
|
+
describe("listDeployments", () => {
|
|
931
|
+
test("with no containers returns empty", async () => {
|
|
932
|
+
mockDocker.listContainers.mockImplementation(async () => []);
|
|
933
|
+
|
|
934
|
+
const manager = createManager();
|
|
935
|
+
const result = await manager.listDeployments();
|
|
936
|
+
|
|
937
|
+
expect(result).toEqual([]);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("with containers returns DeploymentInfo entries", async () => {
|
|
941
|
+
const now = Math.floor(Date.now() / 1000);
|
|
942
|
+
mockDocker.listContainers.mockImplementation(async () => [
|
|
943
|
+
{
|
|
944
|
+
Names: ["/lobu-worker-test-123"],
|
|
945
|
+
State: "running",
|
|
946
|
+
Created: now - 60, // 60 seconds ago
|
|
947
|
+
Labels: {
|
|
948
|
+
"app.kubernetes.io/component": "worker",
|
|
949
|
+
"lobu.io/created": new Date((now - 60) * 1000).toISOString(),
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
Names: ["/lobu-worker-test-456"],
|
|
954
|
+
State: "exited",
|
|
955
|
+
Created: now - 3600, // 1 hour ago
|
|
956
|
+
Labels: {
|
|
957
|
+
"app.kubernetes.io/component": "worker",
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
]);
|
|
961
|
+
|
|
962
|
+
const manager = createManager();
|
|
963
|
+
const result = await manager.listDeployments();
|
|
964
|
+
|
|
965
|
+
expect(result).toHaveLength(2);
|
|
966
|
+
expect(result[0].deploymentName).toBe("lobu-worker-test-123");
|
|
967
|
+
expect(result[0].replicas).toBe(1); // running
|
|
968
|
+
expect(result[1].deploymentName).toBe("lobu-worker-test-456");
|
|
969
|
+
expect(result[1].replicas).toBe(0); // exited
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("filters by worker label", async () => {
|
|
973
|
+
const manager = createManager();
|
|
974
|
+
await manager.listDeployments();
|
|
975
|
+
|
|
976
|
+
expect(mockDocker.listContainers).toHaveBeenCalledWith({
|
|
977
|
+
all: true,
|
|
978
|
+
filters: {
|
|
979
|
+
label: ["app.kubernetes.io/component=worker"],
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// =========================================================================
|
|
986
|
+
// Activity tracking
|
|
987
|
+
// =========================================================================
|
|
988
|
+
|
|
989
|
+
describe("activity tracking", () => {
|
|
990
|
+
test("updateDeploymentActivity stores timestamp", async () => {
|
|
991
|
+
const manager = createManager();
|
|
992
|
+
|
|
993
|
+
await manager.updateDeploymentActivity("test-deploy");
|
|
994
|
+
|
|
995
|
+
// Verify by listing deployments with a container matching the name
|
|
996
|
+
const now = Math.floor(Date.now() / 1000);
|
|
997
|
+
mockDocker.listContainers.mockImplementation(async () => [
|
|
998
|
+
{
|
|
999
|
+
Names: ["/test-deploy"],
|
|
1000
|
+
State: "running",
|
|
1001
|
+
Created: now - 86400, // 1 day ago
|
|
1002
|
+
Labels: {
|
|
1003
|
+
"app.kubernetes.io/component": "worker",
|
|
1004
|
+
"lobu.io/created": new Date((now - 86400) * 1000).toISOString(),
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
|
|
1009
|
+
const deployments = await manager.listDeployments();
|
|
1010
|
+
// The tracked activity should be very recent (just now), not 1 day ago
|
|
1011
|
+
expect(deployments[0].minutesIdle).toBeLessThan(1);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("listDeployments uses most recent of tracked vs label activity", async () => {
|
|
1015
|
+
const manager = createManager();
|
|
1016
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1017
|
+
|
|
1018
|
+
// Set up a container with an old label timestamp
|
|
1019
|
+
mockDocker.listContainers.mockImplementation(async () => [
|
|
1020
|
+
{
|
|
1021
|
+
Names: ["/tracked-deploy"],
|
|
1022
|
+
State: "running",
|
|
1023
|
+
Created: now - 7200, // 2 hours ago
|
|
1024
|
+
Labels: {
|
|
1025
|
+
"app.kubernetes.io/component": "worker",
|
|
1026
|
+
"lobu.io/last-activity": new Date(
|
|
1027
|
+
(now - 7200) * 1000
|
|
1028
|
+
).toISOString(),
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
]);
|
|
1032
|
+
|
|
1033
|
+
// Update activity in-memory (very recent)
|
|
1034
|
+
await manager.updateDeploymentActivity("tracked-deploy");
|
|
1035
|
+
|
|
1036
|
+
const deployments = await manager.listDeployments();
|
|
1037
|
+
// Should use tracked (recent) not label (2 hours ago)
|
|
1038
|
+
expect(deployments[0].minutesIdle).toBeLessThan(1);
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// =========================================================================
|
|
1043
|
+
// validateWorkerImage
|
|
1044
|
+
// =========================================================================
|
|
1045
|
+
|
|
1046
|
+
describe("validateWorkerImage", () => {
|
|
1047
|
+
test("succeeds when image exists locally", async () => {
|
|
1048
|
+
const manager = createManager();
|
|
1049
|
+
await expect(manager.validateWorkerImage()).resolves.toBeUndefined();
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("pulls image when not found locally", async () => {
|
|
1053
|
+
mockDocker.getImage.mockImplementation(() => ({
|
|
1054
|
+
inspect: mock(async () => {
|
|
1055
|
+
throw new Error("No such image");
|
|
1056
|
+
}),
|
|
1057
|
+
}));
|
|
1058
|
+
|
|
1059
|
+
const manager = createManager();
|
|
1060
|
+
await expect(manager.validateWorkerImage()).resolves.toBeUndefined();
|
|
1061
|
+
expect(mockDocker.pull).toHaveBeenCalled();
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("throws when image not found and pull fails", async () => {
|
|
1065
|
+
mockDocker.getImage.mockImplementation(() => ({
|
|
1066
|
+
inspect: mock(async () => {
|
|
1067
|
+
throw new Error("No such image");
|
|
1068
|
+
}),
|
|
1069
|
+
}));
|
|
1070
|
+
mockDocker.pull.mockImplementation((_name: string, cb: Function) =>
|
|
1071
|
+
cb(new Error("pull failed"))
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const manager = createManager();
|
|
1075
|
+
await expect(manager.validateWorkerImage()).rejects.toThrow(
|
|
1076
|
+
"does not exist locally and pull failed"
|
|
1077
|
+
);
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// =========================================================================
|
|
1082
|
+
// Labels & compose integration
|
|
1083
|
+
// =========================================================================
|
|
1084
|
+
|
|
1085
|
+
describe("labels", () => {
|
|
1086
|
+
test("sets base worker labels", async () => {
|
|
1087
|
+
const manager = createManager();
|
|
1088
|
+
await manager.createDeployment(
|
|
1089
|
+
"test-deploy",
|
|
1090
|
+
"user",
|
|
1091
|
+
"user-id",
|
|
1092
|
+
createTestMessagePayload()
|
|
1093
|
+
);
|
|
1094
|
+
const labels = getMainCreateOpts().Labels;
|
|
1095
|
+
expect(labels["app.kubernetes.io/name"]).toBe("lobu");
|
|
1096
|
+
expect(labels["app.kubernetes.io/component"]).toBe("worker");
|
|
1097
|
+
expect(labels["lobu/managed-by"]).toBe("orchestrator");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("sets Docker Compose project labels", async () => {
|
|
1101
|
+
process.env.COMPOSE_PROJECT_NAME = "myproject";
|
|
1102
|
+
const manager = createManager();
|
|
1103
|
+
await manager.createDeployment(
|
|
1104
|
+
"test-deploy",
|
|
1105
|
+
"user",
|
|
1106
|
+
"user-id",
|
|
1107
|
+
createTestMessagePayload()
|
|
1108
|
+
);
|
|
1109
|
+
const labels = getMainCreateOpts().Labels;
|
|
1110
|
+
expect(labels["com.docker.compose.project"]).toBe("myproject");
|
|
1111
|
+
expect(labels["com.docker.compose.service"]).toBe("test-deploy");
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("sets agent-id label", async () => {
|
|
1115
|
+
const manager = createManager();
|
|
1116
|
+
await manager.createDeployment(
|
|
1117
|
+
"test-deploy",
|
|
1118
|
+
"user",
|
|
1119
|
+
"user-id",
|
|
1120
|
+
createTestMessagePayload({ agentId: "my-agent-123" })
|
|
1121
|
+
);
|
|
1122
|
+
expect(getMainCreateOpts().Labels["lobu.io/agent-id"]).toBe(
|
|
1123
|
+
"my-agent-123"
|
|
1124
|
+
);
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// =========================================================================
|
|
1129
|
+
// Extra hosts & host mode
|
|
1130
|
+
// =========================================================================
|
|
1131
|
+
|
|
1132
|
+
describe("extra hosts", () => {
|
|
1133
|
+
test("adds ExtraHosts when running on host (not in container)", async () => {
|
|
1134
|
+
delete process.env.CONTAINER;
|
|
1135
|
+
const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
|
|
1136
|
+
const manager = createManager();
|
|
1137
|
+
await manager.createDeployment(
|
|
1138
|
+
"test-deploy",
|
|
1139
|
+
"user",
|
|
1140
|
+
"user-id",
|
|
1141
|
+
createTestMessagePayload()
|
|
1142
|
+
);
|
|
1143
|
+
expect(getMainCreateOpts().HostConfig.ExtraHosts).toEqual([
|
|
1144
|
+
"host.docker.internal:host-gateway",
|
|
1145
|
+
]);
|
|
1146
|
+
existsSpy.mockRestore();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("does not add ExtraHosts when running in container", async () => {
|
|
1150
|
+
process.env.CONTAINER = "true";
|
|
1151
|
+
const existsSpy = spyOn(fs, "existsSync").mockReturnValue(false);
|
|
1152
|
+
const manager = createManager();
|
|
1153
|
+
await manager.createDeployment(
|
|
1154
|
+
"test-deploy",
|
|
1155
|
+
"user",
|
|
1156
|
+
"user-id",
|
|
1157
|
+
createTestMessagePayload()
|
|
1158
|
+
);
|
|
1159
|
+
expect(getMainCreateOpts().HostConfig.ExtraHosts).toBeUndefined();
|
|
1160
|
+
existsSpy.mockRestore();
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// =========================================================================
|
|
1165
|
+
// Security options
|
|
1166
|
+
// =========================================================================
|
|
1167
|
+
|
|
1168
|
+
describe("advanced security options", () => {
|
|
1169
|
+
test("adds seccomp profile when WORKER_SECCOMP_PROFILE set", async () => {
|
|
1170
|
+
process.env.WORKER_SECCOMP_PROFILE = "/path/to/seccomp.json";
|
|
1171
|
+
const manager = createManager();
|
|
1172
|
+
await manager.createDeployment(
|
|
1173
|
+
"test-deploy",
|
|
1174
|
+
"user",
|
|
1175
|
+
"user-id",
|
|
1176
|
+
createTestMessagePayload()
|
|
1177
|
+
);
|
|
1178
|
+
expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
|
|
1179
|
+
"seccomp=/path/to/seccomp.json"
|
|
1180
|
+
);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("adds apparmor profile when WORKER_APPARMOR_PROFILE set", async () => {
|
|
1184
|
+
process.env.WORKER_APPARMOR_PROFILE = "docker-custom";
|
|
1185
|
+
const manager = createManager();
|
|
1186
|
+
await manager.createDeployment(
|
|
1187
|
+
"test-deploy",
|
|
1188
|
+
"user",
|
|
1189
|
+
"user-id",
|
|
1190
|
+
createTestMessagePayload()
|
|
1191
|
+
);
|
|
1192
|
+
expect(getMainCreateOpts().HostConfig.SecurityOpt).toContain(
|
|
1193
|
+
"apparmor=docker-custom"
|
|
1194
|
+
);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test("disables readonly rootfs when WORKER_READONLY_ROOTFS=false", async () => {
|
|
1198
|
+
process.env.WORKER_READONLY_ROOTFS = "false";
|
|
1199
|
+
const manager = createManager();
|
|
1200
|
+
await manager.createDeployment(
|
|
1201
|
+
"test-deploy",
|
|
1202
|
+
"user",
|
|
1203
|
+
"user-id",
|
|
1204
|
+
createTestMessagePayload()
|
|
1205
|
+
);
|
|
1206
|
+
const hc = getMainCreateOpts().HostConfig;
|
|
1207
|
+
expect(hc.ReadonlyRootfs).toBe(false);
|
|
1208
|
+
expect(hc.Tmpfs).toBeUndefined();
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
});
|