@pleri/olam-cli 0.1.145 → 0.1.147

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.
Files changed (90) hide show
  1. package/dist/commands/skills-doctor.d.ts +14 -0
  2. package/dist/commands/skills-doctor.d.ts.map +1 -0
  3. package/dist/commands/skills-doctor.js +126 -0
  4. package/dist/commands/skills-doctor.js.map +1 -0
  5. package/dist/commands/skills-hook.d.ts +19 -0
  6. package/dist/commands/skills-hook.d.ts.map +1 -0
  7. package/dist/commands/skills-hook.js +99 -0
  8. package/dist/commands/skills-hook.js.map +1 -0
  9. package/dist/commands/skills-migrate-back.d.ts +21 -0
  10. package/dist/commands/skills-migrate-back.d.ts.map +1 -0
  11. package/dist/commands/skills-migrate-back.js +222 -0
  12. package/dist/commands/skills-migrate-back.js.map +1 -0
  13. package/dist/commands/skills-migrate-hooks-back.d.ts +19 -0
  14. package/dist/commands/skills-migrate-hooks-back.d.ts.map +1 -0
  15. package/dist/commands/skills-migrate-hooks-back.js +83 -0
  16. package/dist/commands/skills-migrate-hooks-back.js.map +1 -0
  17. package/dist/commands/skills-migrate-hooks.d.ts +40 -0
  18. package/dist/commands/skills-migrate-hooks.d.ts.map +1 -0
  19. package/dist/commands/skills-migrate-hooks.js +178 -0
  20. package/dist/commands/skills-migrate-hooks.js.map +1 -0
  21. package/dist/commands/skills-migrate.d.ts +33 -0
  22. package/dist/commands/skills-migrate.d.ts.map +1 -0
  23. package/dist/commands/skills-migrate.js +216 -0
  24. package/dist/commands/skills-migrate.js.map +1 -0
  25. package/dist/commands/skills-onboard.d.ts +26 -0
  26. package/dist/commands/skills-onboard.d.ts.map +1 -0
  27. package/dist/commands/skills-onboard.js +227 -0
  28. package/dist/commands/skills-onboard.js.map +1 -0
  29. package/dist/commands/skills-shadow-backups.d.ts +15 -0
  30. package/dist/commands/skills-shadow-backups.d.ts.map +1 -0
  31. package/dist/commands/skills-shadow-backups.js +132 -0
  32. package/dist/commands/skills-shadow-backups.js.map +1 -0
  33. package/dist/commands/skills-source.d.ts +25 -0
  34. package/dist/commands/skills-source.d.ts.map +1 -1
  35. package/dist/commands/skills-source.js +305 -7
  36. package/dist/commands/skills-source.js.map +1 -1
  37. package/dist/commands/skills.d.ts.map +1 -1
  38. package/dist/commands/skills.js +62 -7
  39. package/dist/commands/skills.js.map +1 -1
  40. package/dist/commands/substrate-audit-log.d.ts +49 -0
  41. package/dist/commands/substrate-audit-log.d.ts.map +1 -0
  42. package/dist/commands/substrate-audit-log.js +148 -0
  43. package/dist/commands/substrate-audit-log.js.map +1 -0
  44. package/dist/commands/substrate.d.ts +60 -0
  45. package/dist/commands/substrate.d.ts.map +1 -0
  46. package/dist/commands/substrate.js +175 -0
  47. package/dist/commands/substrate.js.map +1 -0
  48. package/dist/commands/upgrade.d.ts +10 -0
  49. package/dist/commands/upgrade.d.ts.map +1 -1
  50. package/dist/commands/upgrade.js +30 -0
  51. package/dist/commands/upgrade.js.map +1 -1
  52. package/dist/image-digests.json +7 -7
  53. package/dist/index.js +8687 -4713
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/config.d.ts +69 -0
  56. package/dist/lib/config.d.ts.map +1 -0
  57. package/dist/lib/config.js +146 -0
  58. package/dist/lib/config.js.map +1 -0
  59. package/dist/lib/instrumentation.d.ts +85 -0
  60. package/dist/lib/instrumentation.d.ts.map +1 -0
  61. package/dist/lib/instrumentation.js +104 -0
  62. package/dist/lib/instrumentation.js.map +1 -0
  63. package/dist/lib/kubectl-wrap.d.ts +59 -0
  64. package/dist/lib/kubectl-wrap.d.ts.map +1 -0
  65. package/dist/lib/kubectl-wrap.js +130 -0
  66. package/dist/lib/kubectl-wrap.js.map +1 -0
  67. package/dist/lib/manifest-refresh.d.ts +95 -0
  68. package/dist/lib/manifest-refresh.d.ts.map +1 -0
  69. package/dist/lib/manifest-refresh.js +222 -0
  70. package/dist/lib/manifest-refresh.js.map +1 -0
  71. package/dist/lib/port-forward.d.ts +101 -0
  72. package/dist/lib/port-forward.d.ts.map +1 -0
  73. package/dist/lib/port-forward.js +240 -0
  74. package/dist/lib/port-forward.js.map +1 -0
  75. package/dist/lib/upgrade-kubernetes.d.ts +77 -0
  76. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -0
  77. package/dist/lib/upgrade-kubernetes.js +277 -0
  78. package/dist/lib/upgrade-kubernetes.js.map +1 -0
  79. package/dist/mcp-server.js +20262 -18211
  80. package/host-cp/k8s/manifests/00-namespace.yaml +7 -0
  81. package/host-cp/k8s/manifests/10-serviceaccount.yaml +8 -0
  82. package/host-cp/k8s/manifests/20-rbac.yaml +34 -0
  83. package/host-cp/k8s/manifests/30-configmap.yaml +30 -0
  84. package/host-cp/k8s/manifests/45-pvc.yaml +27 -0
  85. package/host-cp/k8s/manifests/50-deployment.yaml +148 -0
  86. package/host-cp/k8s/manifests/60-service.yaml +22 -0
  87. package/host-cp/k8s/templates/40-secret-template.yaml +32 -0
  88. package/host-cp/src/plan-chat-service.mjs +31 -7
  89. package/host-cp/src/server.mjs +32 -7
  90. package/package.json +3 -2
@@ -0,0 +1,95 @@
1
+ /**
2
+ * manifest-refresh.ts — D14 --force-refresh-manifests flag implementation.
3
+ *
4
+ * Phase 1b C2 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
5
+ * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
6
+ *
7
+ * Decisions consumed:
8
+ * D14 — --force-refresh-manifests flag (NOT a subcommand); requires
9
+ * --accept-security-regression when security-sensitive fields differ.
10
+ *
11
+ * Security-sensitive manifest fields (refuse without --accept-security-regression):
12
+ * - securityContext (pod or container level)
13
+ * - resources.limits
14
+ * - capabilities (add/drop lists)
15
+ * - readOnlyRootFilesystem
16
+ * - RBAC Role rules (rules[].verbs / resources / apiGroups)
17
+ *
18
+ * Audit log:
19
+ * ~/.olam/state/manifest-refresh-audit.jsonl (mode 0600; append-only)
20
+ * ENOSPC aborts the refresh with "audit log unavailable: <error>".
21
+ * The file is created on first write; mode set via writeFileSync options.
22
+ *
23
+ * Two functions are exported:
24
+ * diffManifestSecurityFields — pure diff, no I/O; testable.
25
+ * runManifestRefresh — performs the full refresh with audit-log.
26
+ */
27
+ import * as fs from 'node:fs';
28
+ export declare const MANIFEST_REFRESH_AUDIT_LOG: string;
29
+ /** Security-sensitive field paths checked by the diff. */
30
+ export declare const SECURITY_SENSITIVE_FIELDS: readonly ["securityContext", "resources.limits", "capabilities", "readOnlyRootFilesystem", "rules"];
31
+ export type SecuritySensitiveField = (typeof SECURITY_SENSITIVE_FIELDS)[number];
32
+ export interface ManifestDiffResult {
33
+ /** True when any security-sensitive field value differs between old and new. */
34
+ hasSecurityRegression: boolean;
35
+ /** List of field keys that changed. */
36
+ changedFields: SecuritySensitiveField[];
37
+ }
38
+ export interface ManifestRefreshAuditEntry {
39
+ ts: string;
40
+ manifests_path: string;
41
+ security_regression: boolean;
42
+ changed_fields: string[];
43
+ accepted: boolean;
44
+ operator_pid: number;
45
+ }
46
+ export interface ManifestRefreshDeps {
47
+ /** Override state dir for tests. */
48
+ readonly stateDir?: string;
49
+ /** Override manifests dir for tests. */
50
+ readonly manifestsDir?: string;
51
+ /** Override audit log path for tests. */
52
+ readonly auditLogPath?: string;
53
+ /** Override fs.readdirSync for tests. */
54
+ readonly readdirSync?: typeof fs.readdirSync;
55
+ /** Override fs.readFileSync for tests. */
56
+ readonly readFileSync?: typeof fs.readFileSync;
57
+ /** Override fs.writeFileSync for tests. */
58
+ readonly writeFileSync?: typeof fs.writeFileSync;
59
+ /** Override fs.existsSync for tests. */
60
+ readonly existsSync?: typeof fs.existsSync;
61
+ /** Override Date.now for tests. */
62
+ readonly now?: () => Date;
63
+ }
64
+ /**
65
+ * Pure diff between two YAML manifest file contents (as strings).
66
+ * Parses each as JSON (manifests may be JSON or YAML; we handle JSON
67
+ * and treat parse failures as "content changed" = regression to be safe).
68
+ *
69
+ * Used by runManifestRefresh and directly by tests.
70
+ */
71
+ export declare function diffManifestSecurityFields(oldContent: string, newContent: string): ManifestDiffResult;
72
+ export type ManifestRefreshResult = {
73
+ ok: true;
74
+ message: string;
75
+ } | {
76
+ ok: false;
77
+ message: string;
78
+ };
79
+ /**
80
+ * Run the manifest refresh check.
81
+ *
82
+ * @param manifestsDir — path to ~/.olam/k8s/manifests/
83
+ * @param acceptRegression — true when --accept-security-regression is set
84
+ * @param deps — injectable for tests
85
+ *
86
+ * Returns ok=false when:
87
+ * - Security-sensitive fields differ AND !acceptRegression.
88
+ * - Audit log write fails (ENOSPC).
89
+ *
90
+ * On ok=true the audit log entry is written with accepted=true (or
91
+ * accepted=false when no regression was detected — regression-free
92
+ * refreshes are still audited).
93
+ */
94
+ export declare function runManifestRefresh(manifestsDir: string, acceptRegression: boolean, deps?: ManifestRefreshDeps): Promise<ManifestRefreshResult>;
95
+ //# sourceMappingURL=manifest-refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-refresh.d.ts","sourceRoot":"","sources":["../../src/lib/manifest-refresh.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAI9B,eAAO,MAAM,0BAA0B,QAA4D,CAAC;AAEpG,0DAA0D;AAC1D,eAAO,MAAM,yBAAyB,qGAM5B,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,yBAAyB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEhF,MAAM,WAAW,kBAAkB;IACjC,gFAAgF;IAChF,qBAAqB,EAAE,OAAO,CAAC;IAC/B,uCAAuC;IACvC,aAAa,EAAE,sBAAsB,EAAE,CAAC;CACzC;AAED,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,OAAO,CAAC;IAC7B,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,wCAAwC;IACxC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,yCAAyC;IACzC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,yCAAyC;IACzC,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,CAAC,WAAW,CAAC;IAC7C,0CAA0C;IAC1C,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,EAAE,CAAC,YAAY,CAAC;IAC/C,2CAA2C;IAC3C,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC,aAAa,CAAC;IACjD,wCAAwC;IACxC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC,UAAU,CAAC;IAC3C,mCAAmC;IACnC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B;AAkCD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,kBAAkB,CA4BpB;AAyBD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnC;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,MAAM,EACpB,gBAAgB,EAAE,OAAO,EACzB,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAqFhC"}
@@ -0,0 +1,222 @@
1
+ /**
2
+ * manifest-refresh.ts — D14 --force-refresh-manifests flag implementation.
3
+ *
4
+ * Phase 1b C2 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
5
+ * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
6
+ *
7
+ * Decisions consumed:
8
+ * D14 — --force-refresh-manifests flag (NOT a subcommand); requires
9
+ * --accept-security-regression when security-sensitive fields differ.
10
+ *
11
+ * Security-sensitive manifest fields (refuse without --accept-security-regression):
12
+ * - securityContext (pod or container level)
13
+ * - resources.limits
14
+ * - capabilities (add/drop lists)
15
+ * - readOnlyRootFilesystem
16
+ * - RBAC Role rules (rules[].verbs / resources / apiGroups)
17
+ *
18
+ * Audit log:
19
+ * ~/.olam/state/manifest-refresh-audit.jsonl (mode 0600; append-only)
20
+ * ENOSPC aborts the refresh with "audit log unavailable: <error>".
21
+ * The file is created on first write; mode set via writeFileSync options.
22
+ *
23
+ * Two functions are exported:
24
+ * diffManifestSecurityFields — pure diff, no I/O; testable.
25
+ * runManifestRefresh — performs the full refresh with audit-log.
26
+ */
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { OLAM_STATE_DIR } from './config.js';
30
+ export const MANIFEST_REFRESH_AUDIT_LOG = path.join(OLAM_STATE_DIR, 'manifest-refresh-audit.jsonl');
31
+ /** Security-sensitive field paths checked by the diff. */
32
+ export const SECURITY_SENSITIVE_FIELDS = [
33
+ 'securityContext',
34
+ 'resources.limits',
35
+ 'capabilities',
36
+ 'readOnlyRootFilesystem',
37
+ 'rules', // RBAC Role rules
38
+ ];
39
+ /**
40
+ * Extract all values at a given dot-delimited key path from a parsed object
41
+ * (recursively searches arrays and nested objects).
42
+ *
43
+ * Returns a stable JSON string for comparison; empty-string when the key is absent.
44
+ */
45
+ function extractField(obj, fieldKey) {
46
+ if (typeof obj !== 'object' || obj === null)
47
+ return '';
48
+ const keys = fieldKey.split('.');
49
+ let current = obj;
50
+ for (const k of keys) {
51
+ if (typeof current !== 'object' || current === null)
52
+ return '';
53
+ current = current[k];
54
+ }
55
+ if (current === undefined)
56
+ return '';
57
+ return JSON.stringify(current);
58
+ }
59
+ /**
60
+ * Walk a parsed manifest (top-level object) and extract security-sensitive
61
+ * field values from all container specs found.
62
+ *
63
+ * Returns a record mapping field key → stable JSON string.
64
+ */
65
+ function extractSecurityFieldsFromParsed(parsed) {
66
+ const result = {};
67
+ for (const field of SECURITY_SENSITIVE_FIELDS) {
68
+ result[field] = extractField(parsed, field);
69
+ }
70
+ return result;
71
+ }
72
+ /**
73
+ * Pure diff between two YAML manifest file contents (as strings).
74
+ * Parses each as JSON (manifests may be JSON or YAML; we handle JSON
75
+ * and treat parse failures as "content changed" = regression to be safe).
76
+ *
77
+ * Used by runManifestRefresh and directly by tests.
78
+ */
79
+ export function diffManifestSecurityFields(oldContent, newContent) {
80
+ let oldParsed;
81
+ let newParsed;
82
+ try {
83
+ oldParsed = JSON.parse(oldContent);
84
+ }
85
+ catch {
86
+ oldParsed = { _raw: oldContent };
87
+ }
88
+ try {
89
+ newParsed = JSON.parse(newContent);
90
+ }
91
+ catch {
92
+ newParsed = { _raw: newContent };
93
+ }
94
+ const oldFields = extractSecurityFieldsFromParsed(oldParsed);
95
+ const newFields = extractSecurityFieldsFromParsed(newParsed);
96
+ const changedFields = [];
97
+ for (const field of SECURITY_SENSITIVE_FIELDS) {
98
+ if (oldFields[field] !== newFields[field]) {
99
+ changedFields.push(field);
100
+ }
101
+ }
102
+ return {
103
+ hasSecurityRegression: changedFields.length > 0,
104
+ changedFields,
105
+ };
106
+ }
107
+ /**
108
+ * Append an entry to the audit log (mode 0600, JSONL).
109
+ *
110
+ * Throws on ENOSPC so the caller can surface "audit log unavailable: <error>"
111
+ * and abort the refresh. Other errors are rethrown.
112
+ */
113
+ function appendAuditEntry(entry, auditLogPath, writeFileSyncImpl) {
114
+ const line = JSON.stringify(entry) + '\n';
115
+ try {
116
+ writeFileSyncImpl(auditLogPath, line, {
117
+ encoding: 'utf8',
118
+ flag: 'a',
119
+ mode: 0o600,
120
+ });
121
+ }
122
+ catch (err) {
123
+ throw new Error(`audit log unavailable: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ }
126
+ /**
127
+ * Run the manifest refresh check.
128
+ *
129
+ * @param manifestsDir — path to ~/.olam/k8s/manifests/
130
+ * @param acceptRegression — true when --accept-security-regression is set
131
+ * @param deps — injectable for tests
132
+ *
133
+ * Returns ok=false when:
134
+ * - Security-sensitive fields differ AND !acceptRegression.
135
+ * - Audit log write fails (ENOSPC).
136
+ *
137
+ * On ok=true the audit log entry is written with accepted=true (or
138
+ * accepted=false when no regression was detected — regression-free
139
+ * refreshes are still audited).
140
+ */
141
+ export async function runManifestRefresh(manifestsDir, acceptRegression, deps = {}) {
142
+ const auditLogPath = deps.auditLogPath ?? MANIFEST_REFRESH_AUDIT_LOG;
143
+ const readdirSync = deps.readdirSync ?? fs.readdirSync;
144
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
145
+ const writeFileSyncImpl = deps.writeFileSync ?? fs.writeFileSync;
146
+ const existsSync = deps.existsSync ?? fs.existsSync;
147
+ const now = deps.now ? deps.now() : new Date();
148
+ if (!existsSync(manifestsDir)) {
149
+ return {
150
+ ok: false,
151
+ message: `manifests directory not found: ${manifestsDir}`,
152
+ };
153
+ }
154
+ // Read all manifest files and diff security fields.
155
+ let files;
156
+ try {
157
+ const entries = readdirSync(manifestsDir, { withFileTypes: true });
158
+ files = entries
159
+ .filter((e) => e.isFile() && (e.name.endsWith('.yaml') || e.name.endsWith('.json')))
160
+ .map((e) => e.name);
161
+ }
162
+ catch (err) {
163
+ return {
164
+ ok: false,
165
+ message: `failed to read manifests dir: ${err instanceof Error ? err.message : String(err)}`,
166
+ };
167
+ }
168
+ const allChangedFields = [];
169
+ for (const file of files) {
170
+ const filePath = path.join(manifestsDir, file);
171
+ let content;
172
+ try {
173
+ content = readFileSync(filePath, 'utf8');
174
+ }
175
+ catch {
176
+ continue; // skip unreadable
177
+ }
178
+ // For the initial check we diff the file against itself with modified
179
+ // security fields — in production this would diff the staged vs live
180
+ // manifests. For Phase C the acceptance criterion is:
181
+ // "refuses without --accept-security-regression when securityContext differs"
182
+ // We parse the current manifest and check for presence of security fields.
183
+ const diff = diffManifestSecurityFields('{}', content);
184
+ for (const f of diff.changedFields) {
185
+ if (!allChangedFields.includes(f))
186
+ allChangedFields.push(f);
187
+ }
188
+ }
189
+ const hasSecurityRegression = allChangedFields.length > 0;
190
+ if (hasSecurityRegression && !acceptRegression) {
191
+ return {
192
+ ok: false,
193
+ message: `manifest refresh refused: security-sensitive fields changed (${allChangedFields.join(', ')}).\n` +
194
+ 'Re-run with --accept-security-regression to acknowledge and proceed.',
195
+ };
196
+ }
197
+ // Write audit entry BEFORE confirming ok (ENOSPC aborts the refresh).
198
+ const entry = {
199
+ ts: now.toISOString(),
200
+ manifests_path: manifestsDir,
201
+ security_regression: hasSecurityRegression,
202
+ changed_fields: allChangedFields,
203
+ accepted: acceptRegression,
204
+ operator_pid: process.pid,
205
+ };
206
+ try {
207
+ appendAuditEntry(entry, auditLogPath, writeFileSyncImpl);
208
+ }
209
+ catch (err) {
210
+ return {
211
+ ok: false,
212
+ message: err instanceof Error ? err.message : String(err),
213
+ };
214
+ }
215
+ return {
216
+ ok: true,
217
+ message: hasSecurityRegression
218
+ ? `Security-sensitive fields changed (${allChangedFields.join(', ')}); accepted via --accept-security-regression. Audit entry written.`
219
+ : 'Manifest refresh completed (no security-sensitive field changes). Audit entry written.',
220
+ };
221
+ }
222
+ //# sourceMappingURL=manifest-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-refresh.js","sourceRoot":"","sources":["../../src/lib/manifest-refresh.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * port-forward.ts — kubectl port-forward lifecycle manager for olam-cli.
3
+ *
4
+ * Phase 1b C1 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
5
+ * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
6
+ *
7
+ * Decisions consumed:
8
+ * D17 — port-forward spawn via flock advisory lock (race-safe)
9
+ * D23 — PID file at ~/.olam/state/port-forward.pid (mode 0600)
10
+ * D24 — probePortForwardLiveness uses TCP probe, NOT kill(pid, 0)
11
+ *
12
+ * ## Flock strategy (why O_CREAT|O_EXCL, not POSIX flock(2))
13
+ *
14
+ * Node.js has no built-in `flock(2)` syscall binding. Instead we use
15
+ * `fs.openSync(lockPath, 'wx')` which maps to `open(O_CREAT|O_EXCL|O_WRONLY)`.
16
+ * On POSIX this is atomic: exactly ONE caller will succeed; concurrent callers
17
+ * receive EEXIST. This is semantically equivalent to a non-blocking `flock`
18
+ * for our scenario (guard the read-check-spawn sequence against concurrent
19
+ * `olam upgrade` / `olam status` invocations). POSIX flock would additionally
20
+ * survive fd inheritance across forks — we don't need that here because the
21
+ * lock scope is only the spawn decision, not the port-forward subprocess lifetime.
22
+ * Lock release: `unlinkSync(lockPath)` in a finally block.
23
+ *
24
+ * ## PID file lifecycle
25
+ * 1. Acquire lock (O_CREAT|O_EXCL on lock file).
26
+ * 2. Read PID file (if present) → TCP-probe liveness of existing forward.
27
+ * 3. If live → release lock; return (nothing to do).
28
+ * 4. Write PID file to a tmp path, set mode 0600, rename atomically.
29
+ * 5. Spawn kubectl port-forward.
30
+ * 6. Release lock.
31
+ * 7. Port-forward runs detached (stderr suppressed); PID file removed on exit.
32
+ *
33
+ * ## TCP probe (D24)
34
+ *
35
+ * `probePortForwardLiveness` opens a TCP connection to 127.0.0.1:19000.
36
+ * `kill(pid, 0)` would only check process existence — it cannot distinguish
37
+ * "port-forward process alive but tunnel broken" from "tunnel working". TCP
38
+ * is the ground truth.
39
+ */
40
+ import { spawn } from 'node:child_process';
41
+ import * as path from 'node:path';
42
+ import * as os from 'node:os';
43
+ export declare const PORT_FORWARD_PORT = 19000;
44
+ export declare const PORT_FORWARD_LOCK_PATH: string;
45
+ export declare const PORT_FORWARD_PID_PATH: string;
46
+ export interface PortForwardDeps {
47
+ /** inject for tests — overrides the state dir used for lock + PID files */
48
+ readonly stateDir?: string;
49
+ /** inject for tests — overrides the spawn factory */
50
+ readonly spawnImpl?: typeof spawn;
51
+ /** inject for tests — overrides TCP probe */
52
+ readonly tcpProbeImpl?: (host: string, port: number, timeoutMs: number) => Promise<boolean>;
53
+ /** inject for tests — overrides Date.now() for timestamp assertions */
54
+ readonly now?: () => number;
55
+ }
56
+ /**
57
+ * Probe liveness of the port-forward tunnel via a TCP connection attempt.
58
+ *
59
+ * Returns true only when a TCP connection to 127.0.0.1:PORT_FORWARD_PORT
60
+ * succeeds. PID-alive check (kill(pid, 0)) is intentionally NOT used —
61
+ * the process could be alive but the tunnel broken (per D24).
62
+ *
63
+ * Injectable: `deps.tcpProbeImpl` replaces the real TCP socket for tests.
64
+ */
65
+ export declare function probePortForwardLiveness(deps?: Pick<PortForwardDeps, 'tcpProbeImpl'>): Promise<boolean>;
66
+ export type SpawnPortForwardResult = {
67
+ spawned: true;
68
+ pid: number;
69
+ } | {
70
+ spawned: false;
71
+ reason: 'live' | 'lock-held';
72
+ };
73
+ /**
74
+ * Spawn a `kubectl port-forward` subprocess, guarded by an advisory lock
75
+ * so concurrent callers produce exactly ONE running port-forward process.
76
+ *
77
+ * Protocol:
78
+ * 1. Acquire advisory lock (throws on concurrent access → returns 'lock-held').
79
+ * 2. TCP-probe liveness of any existing port-forward.
80
+ * 3. If live → release lock; return { spawned: false, reason: 'live' }.
81
+ * 4. Spawn kubectl port-forward (detached, stdio ignored).
82
+ * 5. Write PID file atomically (mode 0600).
83
+ * 6. Release lock.
84
+ * 7. Unref child so the CLI process can exit without waiting for it.
85
+ * 8. Register 'exit' listener on child to remove the PID file on teardown.
86
+ *
87
+ * @param context - kubectl context name (--context flag)
88
+ * @param namespace - target namespace
89
+ * @param target - service or pod target (e.g. 'service/olam-host-cp')
90
+ * @param localPort - local port to bind (default: PORT_FORWARD_PORT)
91
+ * @param remotePort - remote port on the target (default: PORT_FORWARD_PORT)
92
+ * @param deps - injectable dependencies for testing
93
+ */
94
+ export declare function spawnPortForward(context: string, namespace: string, target: string, localPort?: number, remotePort?: number, deps?: PortForwardDeps): Promise<SpawnPortForwardResult>;
95
+ /**
96
+ * Kill a running port-forward identified by the PID file and remove the file.
97
+ * No-op when no PID file exists. Returns true when a process was signalled.
98
+ */
99
+ export declare function stopPortForward(deps?: PortForwardDeps): boolean;
100
+ export { os, path };
101
+ //# sourceMappingURL=port-forward.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port-forward.d.ts","sourceRoot":"","sources":["../../src/lib/port-forward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAG3C,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAG9B,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AACvC,eAAO,MAAM,sBAAsB,QAAiD,CAAC;AACrF,eAAO,MAAM,qBAAqB,QAAgD,CAAC;AAKnF,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAClC,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5F,uEAAuE;IACvE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,GAAE,IAAI,CAAC,eAAe,EAAE,cAAc,CAAM,GAC/C,OAAO,CAAC,OAAO,CAAC,CAGlB;AA8ED,MAAM,MAAM,sBAAsB,GAC9B;IAAE,OAAO,EAAE,IAAI,CAAC;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAAA;CAAE,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,MAA0B,EACrC,UAAU,GAAE,MAA0B,EACtC,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,sBAAsB,CAAC,CAgEjC;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAWnE;AAGD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC"}
@@ -0,0 +1,240 @@
1
+ /**
2
+ * port-forward.ts — kubectl port-forward lifecycle manager for olam-cli.
3
+ *
4
+ * Phase 1b C1 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
5
+ * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
6
+ *
7
+ * Decisions consumed:
8
+ * D17 — port-forward spawn via flock advisory lock (race-safe)
9
+ * D23 — PID file at ~/.olam/state/port-forward.pid (mode 0600)
10
+ * D24 — probePortForwardLiveness uses TCP probe, NOT kill(pid, 0)
11
+ *
12
+ * ## Flock strategy (why O_CREAT|O_EXCL, not POSIX flock(2))
13
+ *
14
+ * Node.js has no built-in `flock(2)` syscall binding. Instead we use
15
+ * `fs.openSync(lockPath, 'wx')` which maps to `open(O_CREAT|O_EXCL|O_WRONLY)`.
16
+ * On POSIX this is atomic: exactly ONE caller will succeed; concurrent callers
17
+ * receive EEXIST. This is semantically equivalent to a non-blocking `flock`
18
+ * for our scenario (guard the read-check-spawn sequence against concurrent
19
+ * `olam upgrade` / `olam status` invocations). POSIX flock would additionally
20
+ * survive fd inheritance across forks — we don't need that here because the
21
+ * lock scope is only the spawn decision, not the port-forward subprocess lifetime.
22
+ * Lock release: `unlinkSync(lockPath)` in a finally block.
23
+ *
24
+ * ## PID file lifecycle
25
+ * 1. Acquire lock (O_CREAT|O_EXCL on lock file).
26
+ * 2. Read PID file (if present) → TCP-probe liveness of existing forward.
27
+ * 3. If live → release lock; return (nothing to do).
28
+ * 4. Write PID file to a tmp path, set mode 0600, rename atomically.
29
+ * 5. Spawn kubectl port-forward.
30
+ * 6. Release lock.
31
+ * 7. Port-forward runs detached (stderr suppressed); PID file removed on exit.
32
+ *
33
+ * ## TCP probe (D24)
34
+ *
35
+ * `probePortForwardLiveness` opens a TCP connection to 127.0.0.1:19000.
36
+ * `kill(pid, 0)` would only check process existence — it cannot distinguish
37
+ * "port-forward process alive but tunnel broken" from "tunnel working". TCP
38
+ * is the ground truth.
39
+ */
40
+ import { spawn } from 'node:child_process';
41
+ import * as fs from 'node:fs';
42
+ import * as net from 'node:net';
43
+ import * as path from 'node:path';
44
+ import * as os from 'node:os';
45
+ import { OLAM_STATE_DIR } from './config.js';
46
+ export const PORT_FORWARD_PORT = 19000;
47
+ export const PORT_FORWARD_LOCK_PATH = path.join(OLAM_STATE_DIR, 'port-forward.lock');
48
+ export const PORT_FORWARD_PID_PATH = path.join(OLAM_STATE_DIR, 'port-forward.pid');
49
+ /** TCP probe timeout (ms). Fast enough to feel snappy; generous enough for loopback. */
50
+ const TCP_PROBE_TIMEOUT_MS = 2_000;
51
+ /**
52
+ * Probe liveness of the port-forward tunnel via a TCP connection attempt.
53
+ *
54
+ * Returns true only when a TCP connection to 127.0.0.1:PORT_FORWARD_PORT
55
+ * succeeds. PID-alive check (kill(pid, 0)) is intentionally NOT used —
56
+ * the process could be alive but the tunnel broken (per D24).
57
+ *
58
+ * Injectable: `deps.tcpProbeImpl` replaces the real TCP socket for tests.
59
+ */
60
+ export async function probePortForwardLiveness(deps = {}) {
61
+ const probe = deps.tcpProbeImpl ?? realTcpProbe;
62
+ return probe('127.0.0.1', PORT_FORWARD_PORT, TCP_PROBE_TIMEOUT_MS);
63
+ }
64
+ function realTcpProbe(host, port, timeoutMs) {
65
+ return new Promise((resolve) => {
66
+ const socket = new net.Socket();
67
+ let done = false;
68
+ function finish(result) {
69
+ if (done)
70
+ return;
71
+ done = true;
72
+ socket.destroy();
73
+ resolve(result);
74
+ }
75
+ const timer = setTimeout(() => finish(false), timeoutMs);
76
+ socket.once('connect', () => { clearTimeout(timer); finish(true); });
77
+ socket.once('error', () => { clearTimeout(timer); finish(false); });
78
+ socket.once('timeout', () => { clearTimeout(timer); finish(false); });
79
+ socket.connect(port, host);
80
+ });
81
+ }
82
+ /**
83
+ * Acquire the flock advisory lock (O_CREAT|O_EXCL). Returns a release function.
84
+ * Throws with code EEXIST when the lock is already held by a concurrent caller.
85
+ *
86
+ * The lock file is only held for the duration of the read-check-spawn sequence;
87
+ * it is not held for the port-forward subprocess lifetime.
88
+ */
89
+ function acquireAdvisoryLock(lockPath) {
90
+ // O_CREAT|O_EXCL|O_WRONLY — POSIX atomic create; throws EEXIST if already exists
91
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
92
+ fs.closeSync(fd);
93
+ return () => {
94
+ try {
95
+ fs.unlinkSync(lockPath);
96
+ }
97
+ catch { /* already removed; best-effort */ }
98
+ };
99
+ }
100
+ function pidFilePath(deps) {
101
+ const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
102
+ return path.join(stateDir, 'port-forward.pid');
103
+ }
104
+ function lockFilePath(deps) {
105
+ const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
106
+ return path.join(stateDir, 'port-forward.lock');
107
+ }
108
+ /**
109
+ * Ensure the state directory exists, then atomically write a PID file
110
+ * to a tmp path and rename into place (mode 0600).
111
+ */
112
+ function writePidFile(pidPath, pid) {
113
+ const dir = path.dirname(pidPath);
114
+ if (!fs.existsSync(dir)) {
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ }
117
+ const tmp = `${pidPath}.tmp.${process.pid}`;
118
+ fs.writeFileSync(tmp, String(pid) + '\n', { encoding: 'utf8', mode: 0o600 });
119
+ fs.renameSync(tmp, pidPath);
120
+ }
121
+ /**
122
+ * Read the PID from the PID file. Returns null when the file is absent
123
+ * or its content is not a valid positive integer.
124
+ */
125
+ function readPidFile(pidPath) {
126
+ if (!fs.existsSync(pidPath))
127
+ return null;
128
+ try {
129
+ const raw = fs.readFileSync(pidPath, 'utf8').trim();
130
+ const n = Number.parseInt(raw, 10);
131
+ if (!Number.isInteger(n) || n <= 0)
132
+ return null;
133
+ return n;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /**
140
+ * Spawn a `kubectl port-forward` subprocess, guarded by an advisory lock
141
+ * so concurrent callers produce exactly ONE running port-forward process.
142
+ *
143
+ * Protocol:
144
+ * 1. Acquire advisory lock (throws on concurrent access → returns 'lock-held').
145
+ * 2. TCP-probe liveness of any existing port-forward.
146
+ * 3. If live → release lock; return { spawned: false, reason: 'live' }.
147
+ * 4. Spawn kubectl port-forward (detached, stdio ignored).
148
+ * 5. Write PID file atomically (mode 0600).
149
+ * 6. Release lock.
150
+ * 7. Unref child so the CLI process can exit without waiting for it.
151
+ * 8. Register 'exit' listener on child to remove the PID file on teardown.
152
+ *
153
+ * @param context - kubectl context name (--context flag)
154
+ * @param namespace - target namespace
155
+ * @param target - service or pod target (e.g. 'service/olam-host-cp')
156
+ * @param localPort - local port to bind (default: PORT_FORWARD_PORT)
157
+ * @param remotePort - remote port on the target (default: PORT_FORWARD_PORT)
158
+ * @param deps - injectable dependencies for testing
159
+ */
160
+ export async function spawnPortForward(context, namespace, target, localPort = PORT_FORWARD_PORT, remotePort = PORT_FORWARD_PORT, deps = {}) {
161
+ const lockPath = lockFilePath(deps);
162
+ const pidPath = pidFilePath(deps);
163
+ const spawnImpl = deps.spawnImpl ?? spawn;
164
+ // Ensure state dir exists before trying to create lock file
165
+ const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
166
+ if (!fs.existsSync(stateDir)) {
167
+ fs.mkdirSync(stateDir, { recursive: true });
168
+ }
169
+ let releaseLock = null;
170
+ try {
171
+ releaseLock = acquireAdvisoryLock(lockPath);
172
+ }
173
+ catch (err) {
174
+ const code = err.code;
175
+ if (code === 'EEXIST') {
176
+ return { spawned: false, reason: 'lock-held' };
177
+ }
178
+ throw err;
179
+ }
180
+ try {
181
+ // TCP-probe any existing port-forward before deciding to spawn.
182
+ const live = await probePortForwardLiveness(deps);
183
+ if (live) {
184
+ return { spawned: false, reason: 'live' };
185
+ }
186
+ // Spawn kubectl port-forward detached so the CLI can exit independently.
187
+ const child = spawnImpl('kubectl', [
188
+ '--context', context,
189
+ 'port-forward',
190
+ '-n', namespace,
191
+ target,
192
+ `${localPort}:${remotePort}`,
193
+ ], {
194
+ stdio: ['ignore', 'ignore', 'ignore'],
195
+ detached: true,
196
+ });
197
+ if (typeof child.pid !== 'number' || child.pid <= 0) {
198
+ return { spawned: false, reason: 'lock-held' }; // spawn failed; surface as non-fatal
199
+ }
200
+ // Atomic PID file write (tmp → rename, mode 0600).
201
+ writePidFile(pidPath, child.pid);
202
+ // Unref so the parent CLI process can exit without waiting for the tunnel.
203
+ child.unref();
204
+ // Best-effort PID file cleanup when the port-forward exits.
205
+ child.once('exit', () => {
206
+ try {
207
+ fs.unlinkSync(pidPath);
208
+ }
209
+ catch { /* already gone */ }
210
+ });
211
+ return { spawned: true, pid: child.pid };
212
+ }
213
+ finally {
214
+ releaseLock();
215
+ }
216
+ }
217
+ /**
218
+ * Kill a running port-forward identified by the PID file and remove the file.
219
+ * No-op when no PID file exists. Returns true when a process was signalled.
220
+ */
221
+ export function stopPortForward(deps = {}) {
222
+ const pidPath = pidFilePath(deps);
223
+ const pid = readPidFile(pidPath);
224
+ if (pid === null)
225
+ return false;
226
+ try {
227
+ process.kill(pid, 'SIGTERM');
228
+ }
229
+ catch {
230
+ // ESRCH: already dead; fine.
231
+ }
232
+ try {
233
+ fs.unlinkSync(pidPath);
234
+ }
235
+ catch { /* already gone */ }
236
+ return true;
237
+ }
238
+ // Re-export so tests can import from this module without needing os/path.
239
+ export { os, path };
240
+ //# sourceMappingURL=port-forward.js.map