@openpalm/lib 0.10.2 → 0.11.0-beta.2
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/README.md +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -24
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +103 -65
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +82 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +138 -239
- package/src/control-plane/setup.ts +215 -130
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +52 -142
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the registry component directory format.
|
|
3
3
|
*
|
|
4
|
-
* Validates that all components in .openpalm/registry/addons/ follow the
|
|
4
|
+
* Validates that all components in .openpalm/state/registry/addons/ follow the
|
|
5
5
|
* component conventions: compose.yml with required labels, .env.schema
|
|
6
6
|
* with documented variables, proper service naming, and no security
|
|
7
7
|
* violations.
|
|
8
|
+
*
|
|
9
|
+
* Two component shapes are accepted:
|
|
10
|
+
*
|
|
11
|
+
* 1. Full addons — compose.yml + .env.schema. They introduce a new
|
|
12
|
+
* service, declare env vars, and must satisfy the full structural
|
|
13
|
+
* checklist (labels, network, healthcheck, restart policy, sensitive
|
|
14
|
+
* fields).
|
|
15
|
+
* 2. Overlay-only addons — compose.yml only. They patch existing
|
|
16
|
+
* services (ports, env, volumes) instead of introducing new ones,
|
|
17
|
+
* so they have no env vars to document and no service-shaped
|
|
18
|
+
* requirements. They still must satisfy the security invariants:
|
|
19
|
+
* no INSTANCE_ID, no container_name, no INSTANCE_DIR, no vault
|
|
20
|
+
* directory mounts, no docker socket.
|
|
8
21
|
*/
|
|
9
22
|
import { describe, expect, it } from "bun:test";
|
|
10
23
|
import {
|
|
@@ -18,7 +31,7 @@ import { join, resolve } from "node:path";
|
|
|
18
31
|
|
|
19
32
|
/** Resolve path from repo root */
|
|
20
33
|
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
21
|
-
const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/registry/addons");
|
|
34
|
+
const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/state/registry/addons");
|
|
22
35
|
|
|
23
36
|
/** List all component directories in the registry */
|
|
24
37
|
function listComponentDirs(): string[] {
|
|
@@ -28,6 +41,19 @@ function listComponentDirs(): string[] {
|
|
|
28
41
|
.map((d) => d.name);
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
/** Overlay-only addons ship compose.yml only — no .env.schema. */
|
|
45
|
+
function isOverlayOnly(componentId: string): boolean {
|
|
46
|
+
return !existsSync(join(REGISTRY_DIR, componentId, ".env.schema"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listFullAddonIds(componentIds: string[]): string[] {
|
|
50
|
+
return componentIds.filter((id) => !isOverlayOnly(id));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listOverlayOnlyAddonIds(componentIds: string[]): string[] {
|
|
54
|
+
return componentIds.filter(isOverlayOnly);
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
/** Read a file from a component directory */
|
|
32
58
|
function readComponentFile(componentId: string, filename: string): string {
|
|
33
59
|
return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
|
|
@@ -118,24 +144,67 @@ describe("registry component discovery", () => {
|
|
|
118
144
|
|
|
119
145
|
describe("registry component required files", () => {
|
|
120
146
|
const componentIds = listComponentDirs();
|
|
147
|
+
const fullAddonIds = listFullAddonIds(componentIds);
|
|
121
148
|
|
|
122
149
|
for (const id of componentIds) {
|
|
123
150
|
it(`${id}: has compose.yml`, () => {
|
|
124
151
|
expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
|
|
125
152
|
});
|
|
153
|
+
}
|
|
126
154
|
|
|
127
|
-
|
|
155
|
+
for (const id of fullAddonIds) {
|
|
156
|
+
it(`${id}: has .env.schema (full addon)`, () => {
|
|
128
157
|
expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
|
|
129
158
|
});
|
|
130
159
|
}
|
|
131
160
|
});
|
|
132
161
|
|
|
162
|
+
// ── Overlay-only Addon Tests ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe("registry overlay-only addons", () => {
|
|
165
|
+
const componentIds = listComponentDirs();
|
|
166
|
+
const overlayIds = listOverlayOnlyAddonIds(componentIds);
|
|
167
|
+
|
|
168
|
+
it("at least one overlay-only addon (ssh) is recognized as valid", () => {
|
|
169
|
+
expect(overlayIds).toContain("ssh");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
for (const id of overlayIds) {
|
|
173
|
+
describe(id, () => {
|
|
174
|
+
it("ships only compose.yml (no .env.schema, no entrypoint, no Dockerfile)", () => {
|
|
175
|
+
const dirEntries = readdirSync(join(REGISTRY_DIR, id));
|
|
176
|
+
// compose.yml is required; an optional README.md is allowed; nothing
|
|
177
|
+
// else (no .env.schema, no entrypoint*, no Dockerfile, no scripts).
|
|
178
|
+
const allowed = new Set(["compose.yml", "README.md"]);
|
|
179
|
+
for (const file of dirEntries) {
|
|
180
|
+
expect(allowed.has(file)).toBe(true);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("compose.yml does not introduce a new service (no image: or build:)", () => {
|
|
185
|
+
// Overlay-only addons may patch existing services with new ports/env,
|
|
186
|
+
// but they MUST NOT introduce a new service that needs its own
|
|
187
|
+
// network/healthcheck/restart contract — those would belong in a
|
|
188
|
+
// full addon. Reject service definition keys that imply a new
|
|
189
|
+
// service body. A pure overlay only sets `ports:`, `environment:`,
|
|
190
|
+
// `volumes:`, etc. on already-defined services.
|
|
191
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
192
|
+
expect(compose).not.toMatch(/^\s+image:\s/m);
|
|
193
|
+
expect(compose).not.toMatch(/^\s+build:\s/m);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
133
199
|
// ── Compose Overlay Validation Tests ─────────────────────────────────────
|
|
134
200
|
|
|
135
201
|
describe("registry compose.yml validation", () => {
|
|
136
202
|
const componentIds = listComponentDirs();
|
|
203
|
+
const fullAddonIds = listFullAddonIds(componentIds);
|
|
137
204
|
|
|
138
|
-
|
|
205
|
+
// Full-addon-only assertions: anything that requires a service body
|
|
206
|
+
// (labels, network, healthcheck, restart policy) is checked here.
|
|
207
|
+
for (const id of fullAddonIds) {
|
|
139
208
|
describe(id, () => {
|
|
140
209
|
const compose = readComponentFile(id, "compose.yml");
|
|
141
210
|
|
|
@@ -147,18 +216,6 @@ describe("registry compose.yml validation", () => {
|
|
|
147
216
|
expect(compose).toMatch(/openpalm\.description:/);
|
|
148
217
|
});
|
|
149
218
|
|
|
150
|
-
it("uses static service name (no INSTANCE_ID)", () => {
|
|
151
|
-
expect(compose).not.toContain("${INSTANCE_ID}");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("does not use container_name", () => {
|
|
155
|
-
expect(compose).not.toMatch(/container_name:/);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("does not reference INSTANCE_DIR", () => {
|
|
159
|
-
expect(compose).not.toContain("${INSTANCE_DIR}");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
219
|
it("joins a valid stack network", () => {
|
|
163
220
|
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
164
221
|
expect(hasValidNetwork).toBe(true);
|
|
@@ -171,9 +228,28 @@ describe("registry compose.yml validation", () => {
|
|
|
171
228
|
it("has healthcheck", () => {
|
|
172
229
|
expect(compose).toMatch(/healthcheck:/);
|
|
173
230
|
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Security/hygiene assertions apply to ALL addons (full and overlay-only).
|
|
235
|
+
for (const id of componentIds) {
|
|
236
|
+
describe(`${id} (security)`, () => {
|
|
237
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
238
|
+
|
|
239
|
+
it("uses static service name (no INSTANCE_ID)", () => {
|
|
240
|
+
expect(compose).not.toContain("${INSTANCE_ID}");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does not use container_name", () => {
|
|
244
|
+
expect(compose).not.toMatch(/container_name:/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("does not reference INSTANCE_DIR", () => {
|
|
248
|
+
expect(compose).not.toContain("${INSTANCE_DIR}");
|
|
249
|
+
});
|
|
174
250
|
|
|
175
251
|
it("does not mount vault directory (single-file mounts allowed)", () => {
|
|
176
|
-
// Directory-level vault mounts are a security violation —
|
|
252
|
+
// Directory-level vault mounts are a security violation — no container may mount the full vault.
|
|
177
253
|
// Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
|
|
178
254
|
const lines = compose.split("\n");
|
|
179
255
|
for (const line of lines) {
|
|
@@ -193,8 +269,6 @@ describe("registry compose.yml validation", () => {
|
|
|
193
269
|
});
|
|
194
270
|
|
|
195
271
|
it("does not mount docker socket", () => {
|
|
196
|
-
// admin component is exempt — docker-socket-proxy IS the docker socket accessor by design
|
|
197
|
-
if (id === "admin") return;
|
|
198
272
|
expect(compose).not.toContain("/var/run/docker.sock");
|
|
199
273
|
});
|
|
200
274
|
|
|
@@ -209,8 +283,9 @@ describe("registry compose.yml validation", () => {
|
|
|
209
283
|
|
|
210
284
|
describe("registry .env.schema validation", () => {
|
|
211
285
|
const componentIds = listComponentDirs();
|
|
286
|
+
const fullAddonIds = listFullAddonIds(componentIds);
|
|
212
287
|
|
|
213
|
-
for (const id of
|
|
288
|
+
for (const id of fullAddonIds) {
|
|
214
289
|
describe(id, () => {
|
|
215
290
|
const schema = readComponentFile(id, ".env.schema");
|
|
216
291
|
const entries = parseEnvSchema(schema);
|
|
@@ -264,11 +339,13 @@ describe("registry .env.schema validation", () => {
|
|
|
264
339
|
|
|
265
340
|
describe("registry component sensitive fields", () => {
|
|
266
341
|
const componentIds = listComponentDirs();
|
|
342
|
+
const fullAddonIds = listFullAddonIds(componentIds);
|
|
267
343
|
|
|
268
|
-
for (const id of
|
|
344
|
+
for (const id of fullAddonIds) {
|
|
269
345
|
it(`${id}: has at least one @sensitive field (channel secret)`, () => {
|
|
270
|
-
// ollama
|
|
271
|
-
|
|
346
|
+
// ollama and voice are local inference servers — no channel secret
|
|
347
|
+
// or upstream API key needed (LAN-only, no auth by design).
|
|
348
|
+
if (id === "ollama" || id === "voice") return;
|
|
272
349
|
const schema = readComponentFile(id, ".env.schema");
|
|
273
350
|
const entries = parseEnvSchema(schema);
|
|
274
351
|
const sensitiveEntries = entries.filter((e) =>
|
|
@@ -283,10 +360,11 @@ describe("registry component sensitive fields", () => {
|
|
|
283
360
|
|
|
284
361
|
describe("cross-component consistency", () => {
|
|
285
362
|
const componentIds = listComponentDirs();
|
|
363
|
+
const fullAddonIds = listFullAddonIds(componentIds);
|
|
286
364
|
|
|
287
|
-
it("no duplicate openpalm.name labels across
|
|
365
|
+
it("no duplicate openpalm.name labels across full addons", () => {
|
|
288
366
|
const names = new Set<string>();
|
|
289
|
-
for (const id of
|
|
367
|
+
for (const id of fullAddonIds) {
|
|
290
368
|
const compose = readComponentFile(id, "compose.yml");
|
|
291
369
|
const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
|
|
292
370
|
expect(nameMatch).not.toBeNull();
|
|
@@ -296,8 +374,8 @@ describe("cross-component consistency", () => {
|
|
|
296
374
|
}
|
|
297
375
|
});
|
|
298
376
|
|
|
299
|
-
it("all
|
|
300
|
-
for (const id of
|
|
377
|
+
it("all full addons join a valid stack network", () => {
|
|
378
|
+
for (const id of fullAddonIds) {
|
|
301
379
|
const compose = readComponentFile(id, "compose.yml");
|
|
302
380
|
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
303
381
|
expect(hasValidNetwork).toBe(true);
|
|
@@ -201,75 +201,76 @@ describe("materialized registry catalog", () => {
|
|
|
201
201
|
|
|
202
202
|
it("materializes addons and automations into OP_HOME/registry", () => {
|
|
203
203
|
const sourceRoot = join(tmpDir, 'repo');
|
|
204
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
205
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
204
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
205
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
206
206
|
|
|
207
207
|
mkdirSync(addonDir, { recursive: true });
|
|
208
208
|
mkdirSync(automationsDir, { recursive: true });
|
|
209
209
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
210
210
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
211
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
211
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
212
212
|
|
|
213
213
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
214
214
|
|
|
215
|
-
expect(root).toBe(join(process.env.OP_HOME!, 'registry'));
|
|
215
|
+
expect(root).toBe(join(process.env.OP_HOME!, 'state', 'registry'));
|
|
216
216
|
expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
217
217
|
expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
|
|
218
|
-
expect(readFileSync(join(root, 'automations', 'cleanup.
|
|
218
|
+
expect(readFileSync(join(root, 'automations', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
it("discovers materialized registry entries", () => {
|
|
222
222
|
const sourceRoot = join(tmpDir, 'repo');
|
|
223
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
224
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
223
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
224
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
225
225
|
|
|
226
226
|
mkdirSync(addonDir, { recursive: true });
|
|
227
227
|
mkdirSync(automationsDir, { recursive: true });
|
|
228
228
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
229
229
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
230
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
230
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
231
231
|
|
|
232
232
|
materializeRegistryCatalog(sourceRoot);
|
|
233
233
|
|
|
234
234
|
const components = discoverRegistryComponents();
|
|
235
|
-
const
|
|
235
|
+
const stashDir = join(process.env.OP_HOME!, 'stash');
|
|
236
|
+
const automations = discoverRegistryAutomations(stashDir);
|
|
236
237
|
|
|
237
238
|
expect(Object.keys(components)).toEqual(['chat']);
|
|
238
239
|
expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
|
|
239
240
|
expect(automations.map((entry) => entry.name)).toEqual(['cleanup']);
|
|
240
|
-
expect(getRegistryAutomation('cleanup')).toContain('schedule:
|
|
241
|
+
expect(getRegistryAutomation('cleanup')).toContain('schedule: "0 3 * * *"');
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
it("returns addon config metadata from the materialized registry", () => {
|
|
244
245
|
const sourceRoot = join(tmpDir, 'repo');
|
|
245
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
246
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
246
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
247
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
247
248
|
|
|
248
249
|
mkdirSync(addonDir, { recursive: true });
|
|
249
250
|
mkdirSync(automationsDir, { recursive: true });
|
|
250
251
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
251
252
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
252
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
253
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
253
254
|
|
|
254
255
|
materializeRegistryCatalog(sourceRoot);
|
|
255
256
|
|
|
256
257
|
expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
|
|
257
|
-
schemaPath: 'registry/addons/chat/.env.schema',
|
|
258
|
-
userEnvPath: '
|
|
258
|
+
schemaPath: 'state/registry/addons/chat/.env.schema',
|
|
259
|
+
userEnvPath: 'config/stack/stack.env',
|
|
259
260
|
envSchema: 'CHANNEL_CHAT_SECRET=\n',
|
|
260
261
|
});
|
|
261
262
|
});
|
|
262
263
|
|
|
263
264
|
it("verifies the materialized registry and returns counts", () => {
|
|
264
265
|
const sourceRoot = join(tmpDir, 'repo');
|
|
265
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
266
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
266
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
267
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
267
268
|
|
|
268
269
|
mkdirSync(addonDir, { recursive: true });
|
|
269
270
|
mkdirSync(automationsDir, { recursive: true });
|
|
270
271
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
271
272
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
272
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
273
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
273
274
|
|
|
274
275
|
const root = materializeRegistryCatalog(sourceRoot);
|
|
275
276
|
|
|
@@ -286,77 +287,77 @@ describe("materialized registry catalog", () => {
|
|
|
286
287
|
|
|
287
288
|
it("fails when source catalog is incomplete", () => {
|
|
288
289
|
const sourceRoot = join(tmpDir, 'repo');
|
|
289
|
-
mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'addons'), { recursive: true });
|
|
290
|
-
mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'automations'), { recursive: true });
|
|
290
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'addons'), { recursive: true });
|
|
291
|
+
mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'automations'), { recursive: true });
|
|
291
292
|
|
|
292
293
|
expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
|
|
293
294
|
});
|
|
294
295
|
|
|
295
296
|
it("enables and disables addons through the runtime stack directory", () => {
|
|
296
297
|
const sourceRoot = join(tmpDir, 'repo');
|
|
297
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
298
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
298
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
299
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
299
300
|
|
|
300
301
|
mkdirSync(addonDir, { recursive: true });
|
|
301
302
|
mkdirSync(automationsDir, { recursive: true });
|
|
302
303
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
303
304
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
304
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
305
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
305
306
|
|
|
306
307
|
materializeRegistryCatalog(sourceRoot);
|
|
307
308
|
|
|
308
309
|
expect(enableAddon(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
|
|
309
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
310
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
310
311
|
|
|
311
312
|
expect(disableAddonByName(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
|
|
312
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
|
|
313
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
313
314
|
});
|
|
314
315
|
|
|
315
316
|
it("returns addon service names from stack or registry compose files", () => {
|
|
316
317
|
const sourceRoot = join(tmpDir, 'repo');
|
|
317
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', '
|
|
318
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
318
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'proxy-test');
|
|
319
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
319
320
|
|
|
320
321
|
mkdirSync(addonDir, { recursive: true });
|
|
321
322
|
mkdirSync(automationsDir, { recursive: true });
|
|
322
|
-
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n
|
|
323
|
-
writeFileSync(join(addonDir, '.env.schema'), '
|
|
324
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
323
|
+
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n svc-a:\n image: image-a\n svc-b:\n image: image-b\n');
|
|
324
|
+
writeFileSync(join(addonDir, '.env.schema'), 'PROXY_TOKEN=\n');
|
|
325
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
325
326
|
|
|
326
327
|
materializeRegistryCatalog(sourceRoot);
|
|
327
328
|
|
|
328
|
-
expect(getAddonServiceNames(process.env.OP_HOME!, '
|
|
329
|
+
expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['svc-a', 'svc-b']);
|
|
329
330
|
});
|
|
330
331
|
|
|
331
332
|
it("toggles addons and generates channel secrets when enabling channel addons", () => {
|
|
332
333
|
const sourceRoot = join(tmpDir, 'repo');
|
|
333
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
334
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
334
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
335
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
335
336
|
|
|
336
337
|
mkdirSync(addonDir, { recursive: true });
|
|
337
338
|
mkdirSync(automationsDir, { recursive: true });
|
|
338
339
|
writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
|
|
339
340
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
340
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
341
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
341
342
|
|
|
342
343
|
materializeRegistryCatalog(sourceRoot);
|
|
343
344
|
|
|
344
|
-
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, '
|
|
345
|
+
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', true)).toEqual({
|
|
345
346
|
ok: true,
|
|
346
347
|
enabled: true,
|
|
347
348
|
changed: true,
|
|
348
349
|
services: ['chat'],
|
|
349
350
|
});
|
|
350
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
351
|
-
expect(readFileSync(join(process.env.OP_HOME!, '
|
|
351
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
|
|
352
|
+
expect(readFileSync(join(process.env.OP_HOME!, 'config', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
|
|
352
353
|
|
|
353
|
-
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, '
|
|
354
|
+
expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
|
|
354
355
|
ok: true,
|
|
355
356
|
enabled: false,
|
|
356
357
|
changed: true,
|
|
357
358
|
services: ['chat'],
|
|
358
359
|
});
|
|
359
|
-
expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
|
|
360
|
+
expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
|
|
360
361
|
});
|
|
361
362
|
|
|
362
363
|
it("backs up OP_HOME without recursively copying backups", () => {
|
|
@@ -390,10 +391,10 @@ describe("materialized registry catalog", () => {
|
|
|
390
391
|
expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
|
|
391
392
|
});
|
|
392
393
|
|
|
393
|
-
it("installs and uninstalls automations through
|
|
394
|
+
it("installs and uninstalls automations through stash/tasks", () => {
|
|
394
395
|
const sourceRoot = join(tmpDir, 'repo');
|
|
395
|
-
const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
|
|
396
|
-
const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
|
|
396
|
+
const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
|
|
397
|
+
const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
|
|
397
398
|
const configDir = join(process.env.OP_HOME!, 'config');
|
|
398
399
|
|
|
399
400
|
mkdirSync(addonDir, { recursive: true });
|
|
@@ -401,14 +402,15 @@ describe("materialized registry catalog", () => {
|
|
|
401
402
|
mkdirSync(configDir, { recursive: true });
|
|
402
403
|
writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
|
|
403
404
|
writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
|
|
404
|
-
writeFileSync(join(automationsDir, 'cleanup.
|
|
405
|
+
writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
|
|
405
406
|
|
|
406
407
|
materializeRegistryCatalog(sourceRoot);
|
|
407
408
|
|
|
408
|
-
|
|
409
|
-
expect(
|
|
409
|
+
const stashDir = join(process.env.OP_HOME!, 'stash');
|
|
410
|
+
expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
|
|
411
|
+
expect(readFileSync(join(stashDir, 'tasks', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
|
|
410
412
|
|
|
411
|
-
expect(uninstallAutomation('cleanup',
|
|
412
|
-
expect(existsSync(join(
|
|
413
|
+
expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
|
|
414
|
+
expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
|
|
413
415
|
});
|
|
414
416
|
});
|