@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.1
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 +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the 0.11.0 auth-migration shim.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
5
|
-
import {
|
|
6
|
-
existsSync,
|
|
7
|
-
mkdirSync,
|
|
8
|
-
mkdtempSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
statSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
chmodSync,
|
|
14
|
-
} from "node:fs";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { migrateAuth0110 } from "./migrate-0110.js";
|
|
18
|
-
import type { ControlPlaneState } from "./types.js";
|
|
19
|
-
|
|
20
|
-
function makeState(homeDir: string): ControlPlaneState {
|
|
21
|
-
return {
|
|
22
|
-
homeDir,
|
|
23
|
-
configDir: join(homeDir, "config"),
|
|
24
|
-
stashDir: join(homeDir, "stash"),
|
|
25
|
-
workspaceDir: join(homeDir, "workspace"),
|
|
26
|
-
cacheDir: join(homeDir, "cache"),
|
|
27
|
-
stateDir: join(homeDir, "state"),
|
|
28
|
-
stackDir: join(homeDir, "config", "stack"),
|
|
29
|
-
services: {},
|
|
30
|
-
artifacts: { compose: "" },
|
|
31
|
-
artifactMeta: [],
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function seedStackEnv(stackDir: string, content: string): string {
|
|
36
|
-
mkdirSync(stackDir, { recursive: true });
|
|
37
|
-
const path = join(stackDir, "stack.env");
|
|
38
|
-
writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
|
|
39
|
-
chmodSync(path, 0o600);
|
|
40
|
-
return path;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe("migrateAuth0110", () => {
|
|
44
|
-
let homeDir: string;
|
|
45
|
-
|
|
46
|
-
beforeEach(() => {
|
|
47
|
-
homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
rmSync(homeDir, { recursive: true, force: true });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("no-ops on a fresh install (no stack.env)", () => {
|
|
55
|
-
const state = makeState(homeDir);
|
|
56
|
-
const result = migrateAuth0110(state);
|
|
57
|
-
expect(result.migrated).toBe(false);
|
|
58
|
-
expect(result.reason).toContain("fresh install");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
|
|
62
|
-
const state = makeState(homeDir);
|
|
63
|
-
const stackEnvPath = seedStackEnv(
|
|
64
|
-
state.stackDir,
|
|
65
|
-
[
|
|
66
|
-
"# header",
|
|
67
|
-
"OP_UI_TOKEN=legacy-token-value",
|
|
68
|
-
"OP_ASSISTANT_TOKEN=some-assistant-token",
|
|
69
|
-
"OP_OPENCODE_PASSWORD=opencode-secret",
|
|
70
|
-
"",
|
|
71
|
-
].join("\n"),
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
const result = migrateAuth0110(state);
|
|
75
|
-
expect(result.migrated).toBe(true);
|
|
76
|
-
expect(result.reason).toContain("promoted OP_UI_TOKEN");
|
|
77
|
-
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
78
|
-
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
79
|
-
|
|
80
|
-
const after = readFileSync(stackEnvPath, "utf-8");
|
|
81
|
-
expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
|
|
82
|
-
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
83
|
-
expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
84
|
-
// Unrelated keys preserved
|
|
85
|
-
expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
|
|
86
|
-
|
|
87
|
-
// Perms preserved
|
|
88
|
-
expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
|
|
89
|
-
|
|
90
|
-
// Migration log appended
|
|
91
|
-
const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
|
|
92
|
-
expect(existsSync(logPath)).toBe(true);
|
|
93
|
-
const log = readFileSync(logPath, "utf-8");
|
|
94
|
-
expect(log).toContain("migrate-auth-0110");
|
|
95
|
-
expect(log).toContain("promoted OP_UI_TOKEN");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
|
|
99
|
-
const state = makeState(homeDir);
|
|
100
|
-
const stackEnvPath = seedStackEnv(
|
|
101
|
-
state.stackDir,
|
|
102
|
-
[
|
|
103
|
-
"OP_UI_LOGIN_PASSWORD=new-password",
|
|
104
|
-
"OP_UI_TOKEN=legacy-value",
|
|
105
|
-
"",
|
|
106
|
-
].join("\n"),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const result = migrateAuth0110(state);
|
|
110
|
-
expect(result.migrated).toBe(true);
|
|
111
|
-
expect(result.reason).not.toContain("promoted");
|
|
112
|
-
expect(result.reason).toContain("removed OP_UI_TOKEN");
|
|
113
|
-
|
|
114
|
-
const after = readFileSync(stackEnvPath, "utf-8");
|
|
115
|
-
expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
|
|
116
|
-
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
|
|
120
|
-
const state = makeState(homeDir);
|
|
121
|
-
const stackEnvPath = seedStackEnv(
|
|
122
|
-
state.stackDir,
|
|
123
|
-
[
|
|
124
|
-
"OP_UI_LOGIN_PASSWORD=pw",
|
|
125
|
-
"OP_ASSISTANT_TOKEN=stale",
|
|
126
|
-
"",
|
|
127
|
-
].join("\n"),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const result = migrateAuth0110(state);
|
|
131
|
-
expect(result.migrated).toBe(true);
|
|
132
|
-
expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
|
|
133
|
-
expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("is idempotent: second run reports already-migrated", () => {
|
|
137
|
-
const state = makeState(homeDir);
|
|
138
|
-
seedStackEnv(
|
|
139
|
-
state.stackDir,
|
|
140
|
-
[
|
|
141
|
-
"OP_UI_TOKEN=t",
|
|
142
|
-
"OP_ASSISTANT_TOKEN=t2",
|
|
143
|
-
"",
|
|
144
|
-
].join("\n"),
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
const first = migrateAuth0110(state);
|
|
148
|
-
expect(first.migrated).toBe(true);
|
|
149
|
-
|
|
150
|
-
const second = migrateAuth0110(state);
|
|
151
|
-
expect(second.migrated).toBe(false);
|
|
152
|
-
expect(second.reason).toContain("already migrated");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
|
|
156
|
-
const state = makeState(homeDir);
|
|
157
|
-
const stackEnvPath = seedStackEnv(
|
|
158
|
-
state.stackDir,
|
|
159
|
-
[
|
|
160
|
-
"OP_UI_TOKEN=",
|
|
161
|
-
"OP_ASSISTANT_TOKEN=foo",
|
|
162
|
-
"",
|
|
163
|
-
].join("\n"),
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const result = migrateAuth0110(state);
|
|
167
|
-
expect(result.migrated).toBe(true);
|
|
168
|
-
// Empty-string OP_UI_TOKEN should NOT be promoted as a password.
|
|
169
|
-
expect(result.reason).not.toContain("promoted");
|
|
170
|
-
|
|
171
|
-
const after = readFileSync(stackEnvPath, "utf-8");
|
|
172
|
-
// The empty OP_UI_TOKEN line is still removed.
|
|
173
|
-
expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
|
|
174
|
-
// No OP_UI_LOGIN_PASSWORD added (would be an empty value).
|
|
175
|
-
expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-shot migration for the 0.11.0 auth refactor.
|
|
3
|
-
*
|
|
4
|
-
* Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
|
|
5
|
-
* config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
|
|
6
|
-
* replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
|
|
7
|
-
* operators get locked out the moment they run the new UI build because the
|
|
8
|
-
* login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
|
|
9
|
-
* which is empty on existing installs.
|
|
10
|
-
*
|
|
11
|
-
* Migration logic (idempotent):
|
|
12
|
-
* - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
|
|
13
|
-
* OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
|
|
14
|
-
* - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
|
|
15
|
-
* no longer used).
|
|
16
|
-
* - Append a one-line summary to state/logs/migration-0.11.0.log.
|
|
17
|
-
* - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
|
|
18
|
-
* already migrated or set up fresh.
|
|
19
|
-
*
|
|
20
|
-
* Called from ensureSecrets so it runs before any auth-required code path
|
|
21
|
-
* gets a chance to see the half-migrated state.
|
|
22
|
-
*/
|
|
23
|
-
import {
|
|
24
|
-
existsSync,
|
|
25
|
-
readFileSync,
|
|
26
|
-
writeFileSync,
|
|
27
|
-
chmodSync,
|
|
28
|
-
appendFileSync,
|
|
29
|
-
mkdirSync,
|
|
30
|
-
} from "node:fs";
|
|
31
|
-
import { dirname } from "node:path";
|
|
32
|
-
import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
|
|
33
|
-
import { migration0110LogPath } from "./paths.js";
|
|
34
|
-
import type { ControlPlaneState } from "./types.js";
|
|
35
|
-
|
|
36
|
-
export type MigrateAuth0110Result = {
|
|
37
|
-
/** True if any change was written to stack.env. */
|
|
38
|
-
migrated: boolean;
|
|
39
|
-
/** Human-readable description of what changed (or why nothing did). */
|
|
40
|
-
reason: string;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
|
|
44
|
-
const stackEnvPath = `${state.stackDir}/stack.env`;
|
|
45
|
-
if (!existsSync(stackEnvPath)) {
|
|
46
|
-
return { migrated: false, reason: "no stack.env yet (fresh install)" };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const before = readFileSync(stackEnvPath, "utf-8");
|
|
50
|
-
const parsed = parseEnvContent(before);
|
|
51
|
-
const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
|
|
52
|
-
const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
|
|
53
|
-
const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
|
|
54
|
-
const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
|
|
55
|
-
|
|
56
|
-
if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
|
|
57
|
-
return { migrated: false, reason: "already migrated" };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let content = before;
|
|
61
|
-
const changes: string[] = [];
|
|
62
|
-
|
|
63
|
-
if (!hasLoginPw && hasUiToken) {
|
|
64
|
-
content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
|
|
65
|
-
changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
|
|
66
|
-
}
|
|
67
|
-
if (hasUiTokenLine) {
|
|
68
|
-
content = removeEnvKey(content, "OP_UI_TOKEN");
|
|
69
|
-
changes.push("removed OP_UI_TOKEN");
|
|
70
|
-
}
|
|
71
|
-
if (hasAssistantToken) {
|
|
72
|
-
content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
|
|
73
|
-
changes.push("removed OP_ASSISTANT_TOKEN");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (changes.length === 0) {
|
|
77
|
-
return { migrated: false, reason: "no changes needed" };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Preserve the 0600 mode the existing file should already have.
|
|
81
|
-
writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
82
|
-
try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
|
|
83
|
-
|
|
84
|
-
// Best-effort audit line. The migration log is small and append-only;
|
|
85
|
-
// if it fails (perm error, fs full), we don't roll back the migration.
|
|
86
|
-
try {
|
|
87
|
-
const logPath = migration0110LogPath(state);
|
|
88
|
-
mkdirSync(dirname(logPath), { recursive: true });
|
|
89
|
-
appendFileSync(
|
|
90
|
-
logPath,
|
|
91
|
-
`${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
|
|
92
|
-
"utf-8",
|
|
93
|
-
);
|
|
94
|
-
} catch {
|
|
95
|
-
/* best-effort */
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { migrated: true, reason: changes.join("; ") };
|
|
99
|
-
}
|
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the registry component directory format.
|
|
3
|
-
*
|
|
4
|
-
* Validates that all components in .openpalm/state/registry/addons/ follow the
|
|
5
|
-
* component conventions: compose.yml with required labels, .env.schema
|
|
6
|
-
* with documented variables, proper service naming, and no security
|
|
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.
|
|
21
|
-
*/
|
|
22
|
-
import { describe, expect, it } from "bun:test";
|
|
23
|
-
import {
|
|
24
|
-
existsSync,
|
|
25
|
-
readdirSync,
|
|
26
|
-
readFileSync,
|
|
27
|
-
} from "node:fs";
|
|
28
|
-
import { join, resolve } from "node:path";
|
|
29
|
-
|
|
30
|
-
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/** Resolve path from repo root */
|
|
33
|
-
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
34
|
-
const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/state/registry/addons");
|
|
35
|
-
|
|
36
|
-
/** List all component directories in the registry */
|
|
37
|
-
function listComponentDirs(): string[] {
|
|
38
|
-
if (!existsSync(REGISTRY_DIR)) return [];
|
|
39
|
-
return readdirSync(REGISTRY_DIR, { withFileTypes: true })
|
|
40
|
-
.filter((d) => d.isDirectory())
|
|
41
|
-
.map((d) => d.name);
|
|
42
|
-
}
|
|
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
|
-
|
|
57
|
-
/** Read a file from a component directory */
|
|
58
|
-
function readComponentFile(componentId: string, filename: string): string {
|
|
59
|
-
return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Parse .env.schema into { variable, annotations, defaultValue, comments } entries */
|
|
63
|
-
function parseEnvSchema(content: string): Array<{
|
|
64
|
-
variable: string;
|
|
65
|
-
defaultValue: string;
|
|
66
|
-
annotations: string[];
|
|
67
|
-
comments: string[];
|
|
68
|
-
}> {
|
|
69
|
-
const entries: Array<{
|
|
70
|
-
variable: string;
|
|
71
|
-
defaultValue: string;
|
|
72
|
-
annotations: string[];
|
|
73
|
-
comments: string[];
|
|
74
|
-
}> = [];
|
|
75
|
-
|
|
76
|
-
const lines = content.split("\n");
|
|
77
|
-
let pendingComments: string[] = [];
|
|
78
|
-
|
|
79
|
-
for (const line of lines) {
|
|
80
|
-
const trimmed = line.trim();
|
|
81
|
-
|
|
82
|
-
if (trimmed.startsWith("#")) {
|
|
83
|
-
pendingComments.push(trimmed);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (trimmed === "" || trimmed === "---") {
|
|
88
|
-
// Blank line or section separator — keep accumulating comments
|
|
89
|
-
// for the next variable.
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)/);
|
|
94
|
-
if (match) {
|
|
95
|
-
const variable = match[1];
|
|
96
|
-
const defaultValue = match[2];
|
|
97
|
-
|
|
98
|
-
// Extract @annotations from pending comments
|
|
99
|
-
const annotations: string[] = [];
|
|
100
|
-
for (const c of pendingComments) {
|
|
101
|
-
const annots = c.match(/@[a-z]+/g);
|
|
102
|
-
if (annots) annotations.push(...annots);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
entries.push({
|
|
106
|
-
variable,
|
|
107
|
-
defaultValue,
|
|
108
|
-
annotations,
|
|
109
|
-
comments: [...pendingComments],
|
|
110
|
-
});
|
|
111
|
-
pendingComments = [];
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return entries;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── Discovery Tests ──────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
describe("registry component discovery", () => {
|
|
121
|
-
const componentIds = listComponentDirs();
|
|
122
|
-
|
|
123
|
-
it("finds at least one component in the registry", () => {
|
|
124
|
-
expect(componentIds.length).toBeGreaterThan(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("contains the expected core components", () => {
|
|
128
|
-
expect(componentIds).toContain("chat");
|
|
129
|
-
expect(componentIds).toContain("api");
|
|
130
|
-
expect(componentIds).toContain("discord");
|
|
131
|
-
expect(componentIds).toContain("slack");
|
|
132
|
-
expect(componentIds).toContain("voice");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("component IDs are valid (lowercase alphanumeric + hyphens)", () => {
|
|
136
|
-
const validIdRe = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
137
|
-
for (const id of componentIds) {
|
|
138
|
-
expect(validIdRe.test(id)).toBe(true);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// ── Required Files Tests ─────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
describe("registry component required files", () => {
|
|
146
|
-
const componentIds = listComponentDirs();
|
|
147
|
-
const fullAddonIds = listFullAddonIds(componentIds);
|
|
148
|
-
|
|
149
|
-
for (const id of componentIds) {
|
|
150
|
-
it(`${id}: has compose.yml`, () => {
|
|
151
|
-
expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
for (const id of fullAddonIds) {
|
|
156
|
-
it(`${id}: has .env.schema (full addon)`, () => {
|
|
157
|
-
expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
});
|
|
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
|
-
|
|
199
|
-
// ── Compose Overlay Validation Tests ─────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
describe("registry compose.yml validation", () => {
|
|
202
|
-
const componentIds = listComponentDirs();
|
|
203
|
-
const fullAddonIds = listFullAddonIds(componentIds);
|
|
204
|
-
|
|
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) {
|
|
208
|
-
describe(id, () => {
|
|
209
|
-
const compose = readComponentFile(id, "compose.yml");
|
|
210
|
-
|
|
211
|
-
it("has openpalm.name label", () => {
|
|
212
|
-
expect(compose).toMatch(/openpalm\.name:/);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("has openpalm.description label", () => {
|
|
216
|
-
expect(compose).toMatch(/openpalm\.description:/);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("joins a valid stack network", () => {
|
|
220
|
-
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
221
|
-
expect(hasValidNetwork).toBe(true);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("has restart policy", () => {
|
|
225
|
-
expect(compose).toMatch(/restart:\s/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("has healthcheck", () => {
|
|
229
|
-
expect(compose).toMatch(/healthcheck:/);
|
|
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
|
-
});
|
|
250
|
-
|
|
251
|
-
it("does not mount vault directory (single-file mounts allowed)", () => {
|
|
252
|
-
// Directory-level vault mounts are a security violation — no container may mount the full vault.
|
|
253
|
-
// Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
|
|
254
|
-
const lines = compose.split("\n");
|
|
255
|
-
for (const line of lines) {
|
|
256
|
-
if (line.match(/^\s*-\s+.*vault.*:/)) {
|
|
257
|
-
// Extract the source portion (before first colon that follows a path)
|
|
258
|
-
const match = line.match(/^\s*-\s+(.+?):/);
|
|
259
|
-
if (match) {
|
|
260
|
-
const source = match[1];
|
|
261
|
-
// Allow single-file vault mounts (path ends with a file, i.e. has an extension or
|
|
262
|
-
// a non-directory final segment). Block bare vault/ or vault/<dir>/ mounts.
|
|
263
|
-
if (/vault\b/i.test(source) && !/vault\/.*\.[a-z]+$/i.test(source)) {
|
|
264
|
-
throw new Error(`Vault directory mount detected: ${line.trim()}`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("does not mount docker socket", () => {
|
|
272
|
-
expect(compose).not.toContain("/var/run/docker.sock");
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("has a comment header describing the component", () => {
|
|
276
|
-
expect(compose.startsWith("#")).toBe(true);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
// ── .env.schema Validation Tests ─────────────────────────────────────────
|
|
283
|
-
|
|
284
|
-
describe("registry .env.schema validation", () => {
|
|
285
|
-
const componentIds = listComponentDirs();
|
|
286
|
-
const fullAddonIds = listFullAddonIds(componentIds);
|
|
287
|
-
|
|
288
|
-
for (const id of fullAddonIds) {
|
|
289
|
-
describe(id, () => {
|
|
290
|
-
const schema = readComponentFile(id, ".env.schema");
|
|
291
|
-
const entries = parseEnvSchema(schema);
|
|
292
|
-
|
|
293
|
-
it("is non-empty", () => {
|
|
294
|
-
expect(schema.length).toBeGreaterThan(0);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("has at least one variable definition", () => {
|
|
298
|
-
expect(entries.length).toBeGreaterThan(0);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("does not include INSTANCE_ID (removed)", () => {
|
|
302
|
-
const names = entries.map((e) => e.variable);
|
|
303
|
-
expect(names).not.toContain("INSTANCE_ID");
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("does not include INSTANCE_DIR (removed)", () => {
|
|
307
|
-
const names = entries.map((e) => e.variable);
|
|
308
|
-
expect(names).not.toContain("INSTANCE_DIR");
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("has at least one @required variable", () => {
|
|
312
|
-
const requiredEntries = entries.filter((e) =>
|
|
313
|
-
e.annotations.includes("@required")
|
|
314
|
-
);
|
|
315
|
-
expect(requiredEntries.length).toBeGreaterThan(0);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it("variable names are valid (uppercase with underscores)", () => {
|
|
319
|
-
const validVarRe = /^[A-Z_][A-Z0-9_]*$/;
|
|
320
|
-
for (const entry of entries) {
|
|
321
|
-
expect(validVarRe.test(entry.variable)).toBe(true);
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it("every variable has at least one comment line above it", () => {
|
|
326
|
-
for (const entry of entries) {
|
|
327
|
-
expect(entry.comments.length).toBeGreaterThan(0);
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("does not contain vault references", () => {
|
|
332
|
-
expect(schema.toLowerCase()).not.toContain("vault/");
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
// ── Sensitive Fields Tests ───────────────────────────────────────────────
|
|
339
|
-
|
|
340
|
-
describe("registry component sensitive fields", () => {
|
|
341
|
-
const componentIds = listComponentDirs();
|
|
342
|
-
const fullAddonIds = listFullAddonIds(componentIds);
|
|
343
|
-
|
|
344
|
-
for (const id of fullAddonIds) {
|
|
345
|
-
it(`${id}: has at least one @sensitive field (channel secret)`, () => {
|
|
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;
|
|
349
|
-
const schema = readComponentFile(id, ".env.schema");
|
|
350
|
-
const entries = parseEnvSchema(schema);
|
|
351
|
-
const sensitiveEntries = entries.filter((e) =>
|
|
352
|
-
e.annotations.includes("@sensitive")
|
|
353
|
-
);
|
|
354
|
-
expect(sensitiveEntries.length).toBeGreaterThan(0);
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// ── Cross-Component Consistency Tests ────────────────────────────────────
|
|
360
|
-
|
|
361
|
-
describe("cross-component consistency", () => {
|
|
362
|
-
const componentIds = listComponentDirs();
|
|
363
|
-
const fullAddonIds = listFullAddonIds(componentIds);
|
|
364
|
-
|
|
365
|
-
it("no duplicate openpalm.name labels across full addons", () => {
|
|
366
|
-
const names = new Set<string>();
|
|
367
|
-
for (const id of fullAddonIds) {
|
|
368
|
-
const compose = readComponentFile(id, "compose.yml");
|
|
369
|
-
const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
|
|
370
|
-
expect(nameMatch).not.toBeNull();
|
|
371
|
-
const name = nameMatch![1].trim();
|
|
372
|
-
expect(names.has(name)).toBe(false);
|
|
373
|
-
names.add(name);
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it("all full addons join a valid stack network", () => {
|
|
378
|
-
for (const id of fullAddonIds) {
|
|
379
|
-
const compose = readComponentFile(id, "compose.yml");
|
|
380
|
-
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
381
|
-
expect(hasValidNetwork).toBe(true);
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("no compose file uses INSTANCE_ID anywhere", () => {
|
|
386
|
-
for (const id of componentIds) {
|
|
387
|
-
const compose = readComponentFile(id, "compose.yml");
|
|
388
|
-
expect(compose).not.toContain("INSTANCE_ID");
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
});
|