@celilo/cli 0.8.2 → 0.9.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.
Files changed (46) hide show
  1. package/AGENTS.md +10 -18
  2. package/CELILO_CORE_MODULES.md +61 -0
  3. package/CELILO_SUBSYSTEMS.md +83 -0
  4. package/README.md +1539 -48
  5. package/drizzle/0012_module_systems_sizing.sql +3 -0
  6. package/drizzle/0013_dns_view_overrides.sql +1 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +5 -10
  9. package/src/capabilities/well-known.test.ts +12 -66
  10. package/src/capabilities/well-known.ts +11 -12
  11. package/src/cli/command-registry.ts +65 -1
  12. package/src/cli/commands/module-upgrade.test.ts +29 -0
  13. package/src/cli/commands/module-upgrade.ts +57 -24
  14. package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
  15. package/src/cli/commands/proxmox-instance-list.ts +140 -0
  16. package/src/cli/commands/proxmox-instance-resize.ts +235 -0
  17. package/src/cli/commands/proxmox-node-list.ts +1 -34
  18. package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
  19. package/src/cli/commands/proxmox-resize-guards.ts +102 -0
  20. package/src/cli/commands/proxmox-service.ts +38 -0
  21. package/src/cli/completion.ts +11 -37
  22. package/src/cli/index.ts +15 -0
  23. package/src/cli/validators.test.ts +1 -206
  24. package/src/cli/validators.ts +0 -168
  25. package/src/db/schema.ts +21 -1
  26. package/src/hooks/capability-loader.ts +22 -0
  27. package/src/manifest/template-validator.test.ts +31 -1
  28. package/src/manifest/template-validator.ts +9 -0
  29. package/src/services/aspect-approvals.test.ts +52 -0
  30. package/src/services/aspect-approvals.ts +41 -8
  31. package/src/services/deployed-systems.test.ts +73 -1
  32. package/src/services/deployed-systems.ts +72 -0
  33. package/src/services/dns-internal-records.test.ts +76 -3
  34. package/src/services/dns-internal-records.ts +52 -3
  35. package/src/services/dns-provider-backfill.ts +15 -3
  36. package/src/services/fleet-checks.test.ts +18 -16
  37. package/src/services/machine-detector.ts +34 -12
  38. package/src/services/programmatic-responder.aspect.test.ts +157 -0
  39. package/src/services/programmatic-responder.ts +51 -0
  40. package/src/templates/generator.ts +49 -1
  41. package/src/utils/shell.test.ts +1 -163
  42. package/src/utils/shell.ts +0 -100
  43. package/src/validation/schemas.ts +0 -5
  44. package/src/variables/context.ts +36 -7
  45. package/CLI_USAGE.md +0 -433
  46. package/src/config/env.ts +0 -41
@@ -3,149 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from 'bun:test';
6
- import {
7
- validateDomain,
8
- validateEmail,
9
- validateIpAddress,
10
- validateIpAddressList,
11
- validateProxmoxUrl,
12
- validateRequired,
13
- validateRequiredNoDefault,
14
- validateSubnet,
15
- } from './validators';
16
-
17
- describe('validateProxmoxUrl', () => {
18
- test('accepts valid HTTPS URL with port', () => {
19
- expect(validateProxmoxUrl('https://proxmox.example.com:8006')).toBeUndefined();
20
- expect(validateProxmoxUrl('https://192.168.1.100:8006')).toBeUndefined();
21
- });
22
-
23
- test('rejects non-HTTPS URLs', () => {
24
- expect(validateProxmoxUrl('http://proxmox.example.com:8006')).toBe(
25
- 'URL must start with https://',
26
- );
27
- });
28
-
29
- test('rejects URLs without port', () => {
30
- expect(validateProxmoxUrl('https://proxmox.example.com')).toBe(
31
- 'URL must include port (e.g., :8006)',
32
- );
33
- });
34
-
35
- test('allows undefined (for default values)', () => {
36
- expect(validateProxmoxUrl(undefined)).toBeUndefined();
37
- });
38
-
39
- test('rejects empty input', () => {
40
- expect(validateProxmoxUrl('')).toBe('URL is required');
41
- expect(validateProxmoxUrl(' ')).toBe('URL is required');
42
- });
43
-
44
- test('rejects invalid URLs', () => {
45
- expect(validateProxmoxUrl('https://:8006')).toBe('Invalid URL format');
46
- expect(validateProxmoxUrl('not-a-url')).toBe('URL must start with https://');
47
- });
48
- });
49
-
50
- describe('validateSubnet', () => {
51
- test('accepts valid CIDR notation', () => {
52
- expect(validateSubnet('10.0.10.0/24')).toBeUndefined();
53
- expect(validateSubnet('192.168.1.0/16')).toBeUndefined();
54
- expect(validateSubnet('172.16.0.0/12')).toBeUndefined();
55
- });
56
-
57
- test('rejects invalid CIDR format', () => {
58
- expect(validateSubnet('10.0.10.0')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
59
- expect(validateSubnet('10.0.10/24')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
60
- });
61
-
62
- test('rejects invalid IP octets', () => {
63
- expect(validateSubnet('256.0.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
64
- expect(validateSubnet('10.300.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
65
- });
66
-
67
- test('rejects invalid prefix', () => {
68
- expect(validateSubnet('10.0.10.0/33')).toBe('Invalid prefix length (must be 0-32)');
69
- expect(validateSubnet('10.0.10.0/-1')).toBe('Invalid prefix length (must be 0-32)');
70
- });
71
-
72
- test('allows undefined (for default values)', () => {
73
- expect(validateSubnet(undefined)).toBeUndefined();
74
- });
75
-
76
- test('rejects empty input', () => {
77
- expect(validateSubnet('')).toBe('Subnet is required');
78
- });
79
- });
80
-
81
- describe('validateDomain', () => {
82
- test('accepts valid domains', () => {
83
- expect(validateDomain('homelab.local')).toBeUndefined();
84
- expect(validateDomain('example.com')).toBeUndefined();
85
- expect(validateDomain('sub.domain.co.uk')).toBeUndefined();
86
- });
87
-
88
- test('rejects invalid domain format', () => {
89
- expect(validateDomain('not-a-domain')).toBe('Invalid domain format (e.g., homelab.local)');
90
- expect(validateDomain('.example.com')).toBe('Invalid domain format (e.g., homelab.local)');
91
- expect(validateDomain('example..com')).toBe('Invalid domain format (e.g., homelab.local)');
92
- });
93
-
94
- test('allows undefined (for default values)', () => {
95
- expect(validateDomain(undefined)).toBeUndefined();
96
- });
97
-
98
- test('rejects empty input', () => {
99
- expect(validateDomain('')).toBe('Domain is required');
100
- });
101
- });
102
-
103
- describe('validateEmail', () => {
104
- test('accepts valid emails', () => {
105
- expect(validateEmail('admin@example.com')).toBeUndefined();
106
- expect(validateEmail('user.name@domain.co.uk')).toBeUndefined();
107
- });
108
-
109
- test('rejects invalid email format', () => {
110
- expect(validateEmail('not-an-email')).toBe('Invalid email format');
111
- expect(validateEmail('@example.com')).toBe('Invalid email format');
112
- expect(validateEmail('user@')).toBe('Invalid email format');
113
- });
114
-
115
- test('allows undefined (for default values)', () => {
116
- expect(validateEmail(undefined)).toBeUndefined();
117
- });
118
-
119
- test('rejects empty input', () => {
120
- expect(validateEmail('')).toBe('Email is required');
121
- });
122
- });
123
-
124
- describe('validateIpAddress', () => {
125
- test('accepts valid IP addresses', () => {
126
- expect(validateIpAddress('1.1.1.1')).toBeUndefined();
127
- expect(validateIpAddress('192.168.1.100')).toBeUndefined();
128
- expect(validateIpAddress('10.0.0.1')).toBeUndefined();
129
- });
130
-
131
- test('rejects invalid IP format', () => {
132
- expect(validateIpAddress('not-an-ip')).toBe('Invalid IP format (e.g., 1.1.1.1)');
133
- expect(validateIpAddress('192.168.1')).toBe('Invalid IP format (e.g., 1.1.1.1)');
134
- });
135
-
136
- test('rejects invalid octets', () => {
137
- expect(validateIpAddress('256.1.1.1')).toBe('Invalid IP address (octets must be 0-255)');
138
- expect(validateIpAddress('192.168.1.300')).toBe('Invalid IP address (octets must be 0-255)');
139
- });
140
-
141
- test('allows undefined (for default values)', () => {
142
- expect(validateIpAddress(undefined)).toBeUndefined();
143
- });
144
-
145
- test('rejects empty input', () => {
146
- expect(validateIpAddress('')).toBe('IP address is required');
147
- });
148
- });
6
+ import { validateRequired } from './validators';
149
7
 
150
8
  describe('validateRequired', () => {
151
9
  test('accepts non-empty strings', () => {
@@ -170,66 +28,3 @@ describe('validateRequired', () => {
170
28
  expect(validator('')).toBe('Custom field is required');
171
29
  });
172
30
  });
173
-
174
- describe('validateRequiredNoDefault', () => {
175
- test('accepts non-empty strings', () => {
176
- const validator = validateRequiredNoDefault('Password');
177
- expect(validator('value')).toBeUndefined();
178
- expect(validator(' text ')).toBeUndefined();
179
- });
180
-
181
- test('rejects undefined (no default values)', () => {
182
- const validator = validateRequiredNoDefault('Password');
183
- expect(validator(undefined)).toBe('Password is required');
184
- });
185
-
186
- test('rejects empty strings', () => {
187
- const validator = validateRequiredNoDefault('Password');
188
- expect(validator('')).toBe('Password is required');
189
- expect(validator(' ')).toBe('Password is required');
190
- });
191
-
192
- test('uses custom field name in error message', () => {
193
- const validator = validateRequiredNoDefault('Custom password');
194
- expect(validator('')).toBe('Custom password is required');
195
- });
196
- });
197
-
198
- describe('validateIpAddressList', () => {
199
- test('accepts single IP address', () => {
200
- expect(validateIpAddressList('1.1.1.1')).toBeUndefined();
201
- expect(validateIpAddressList('192.168.1.100')).toBeUndefined();
202
- });
203
-
204
- test('accepts space-separated IP addresses', () => {
205
- expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
206
- expect(validateIpAddressList('192.168.0.1 10.0.0.1 1.1.1.1')).toBeUndefined();
207
- });
208
-
209
- test('accepts multiple spaces between IPs', () => {
210
- expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
211
- expect(validateIpAddressList('1.1.1.1 8.8.8.8 1.0.0.1')).toBeUndefined();
212
- });
213
-
214
- test('rejects comma-separated IPs', () => {
215
- expect(validateIpAddressList('1.1.1.1,8.8.8.8')).toContain('Invalid IP address');
216
- });
217
-
218
- test('rejects invalid IP in list', () => {
219
- expect(validateIpAddressList('8.8.8.8 999.999.999.999')).toContain(
220
- "Invalid IP address '999.999.999.999'",
221
- );
222
- expect(validateIpAddressList('192.168.0.1 not-an-ip')).toContain(
223
- "Invalid IP address 'not-an-ip'",
224
- );
225
- });
226
-
227
- test('allows undefined (for default values)', () => {
228
- expect(validateIpAddressList(undefined)).toBeUndefined();
229
- });
230
-
231
- test('rejects empty input', () => {
232
- expect(validateIpAddressList('')).toBe('At least one IP address is required');
233
- expect(validateIpAddressList(' ')).toBe('At least one IP address is required');
234
- });
235
- });
@@ -3,137 +3,6 @@
3
3
  * Validation functions for interactive prompts
4
4
  */
5
5
 
6
- /**
7
- * Validate URL format (must start with https:// and include port)
8
- */
9
- export function validateProxmoxUrl(value: string | undefined): string | Error | undefined {
10
- // Allow undefined (default will be used)
11
- if (value === undefined) {
12
- return;
13
- }
14
-
15
- if (!value.trim()) {
16
- return 'URL is required';
17
- }
18
-
19
- if (!value.startsWith('https://')) {
20
- return 'URL must start with https://';
21
- }
22
-
23
- if (!value.includes(':') || !value.match(/:\d+/)) {
24
- return 'URL must include port (e.g., :8006)';
25
- }
26
-
27
- // Basic URL validation
28
- try {
29
- new URL(value);
30
- } catch {
31
- return 'Invalid URL format';
32
- }
33
- }
34
-
35
- /**
36
- * Validate subnet CIDR notation (e.g., 10.0.10.0/24)
37
- */
38
- export function validateSubnet(value: string | undefined): string | Error | undefined {
39
- // Allow undefined (default will be used)
40
- if (value === undefined) {
41
- return;
42
- }
43
-
44
- if (!value.trim()) {
45
- return 'Subnet is required';
46
- }
47
-
48
- // Check CIDR format: IP/prefix
49
- const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/(.+)$/;
50
- if (!cidrPattern.test(value)) {
51
- return 'Subnet must be in CIDR format (e.g., 10.0.10.0/24)';
52
- }
53
-
54
- // Validate IP octets (0-255)
55
- const [ip, prefix] = value.split('/');
56
- const octets = ip.split('.').map(Number);
57
-
58
- for (const octet of octets) {
59
- if (octet < 0 || octet > 255) {
60
- return 'Invalid IP address (octets must be 0-255)';
61
- }
62
- }
63
-
64
- // Validate prefix (0-32)
65
- const prefixNum = Number(prefix);
66
- if (prefixNum < 0 || prefixNum > 32) {
67
- return 'Invalid prefix length (must be 0-32)';
68
- }
69
- }
70
-
71
- /**
72
- * Validate domain name format
73
- */
74
- export function validateDomain(value: string | undefined): string | Error | undefined {
75
- // Allow undefined (default will be used)
76
- if (value === undefined) {
77
- return;
78
- }
79
-
80
- if (!value.trim()) {
81
- return 'Domain is required';
82
- }
83
-
84
- // Basic domain format: lowercase, alphanumeric, hyphens, dots
85
- const domainPattern = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
86
- if (!domainPattern.test(value)) {
87
- return 'Invalid domain format (e.g., homelab.local)';
88
- }
89
- }
90
-
91
- /**
92
- * Validate email format
93
- */
94
- export function validateEmail(value: string | undefined): string | Error | undefined {
95
- // Allow undefined (default will be used)
96
- if (value === undefined) {
97
- return;
98
- }
99
-
100
- if (!value.trim()) {
101
- return 'Email is required';
102
- }
103
-
104
- const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
105
- if (!emailPattern.test(value)) {
106
- return 'Invalid email format';
107
- }
108
- }
109
-
110
- /**
111
- * Validate IP address format
112
- */
113
- export function validateIpAddress(value: string | undefined): string | Error | undefined {
114
- // Allow undefined (default will be used)
115
- if (value === undefined) {
116
- return;
117
- }
118
-
119
- if (!value.trim()) {
120
- return 'IP address is required';
121
- }
122
-
123
- const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
124
- if (!ipPattern.test(value)) {
125
- return 'Invalid IP format (e.g., 1.1.1.1)';
126
- }
127
-
128
- // Validate octets (0-255)
129
- const octets = value.split('.').map(Number);
130
- for (const octet of octets) {
131
- if (octet < 0 || octet > 255) {
132
- return 'Invalid IP address (octets must be 0-255)';
133
- }
134
- }
135
- }
136
-
137
6
  /**
138
7
  * Validate non-empty string (allows undefined for prompts with default values)
139
8
  */
@@ -149,40 +18,3 @@ export function validateRequired(fieldName: string) {
149
18
  }
150
19
  };
151
20
  }
152
-
153
- /**
154
- * Validate required field with no defaults (for password fields)
155
- */
156
- export function validateRequiredNoDefault(fieldName: string) {
157
- return (value: string | undefined): string | Error | undefined => {
158
- // Reject undefined - no default value for this field
159
- if (value === undefined || !value.trim()) {
160
- return `${fieldName} is required`;
161
- }
162
- };
163
- }
164
-
165
- /**
166
- * Validate space-separated list of IP addresses
167
- */
168
- export function validateIpAddressList(value: string | undefined): string | Error | undefined {
169
- // Allow undefined (default will be used)
170
- if (value === undefined) {
171
- return;
172
- }
173
-
174
- if (!value.trim()) {
175
- return 'At least one IP address is required';
176
- }
177
-
178
- // Split by spaces (any amount of whitespace)
179
- const ips = value.trim().split(/\s+/);
180
-
181
- // Validate each IP
182
- for (const ip of ips) {
183
- const error = validateIpAddress(ip);
184
- if (error) {
185
- return `Invalid IP address '${ip}': ${error}`;
186
- }
187
- }
188
- }
package/src/db/schema.ts CHANGED
@@ -392,6 +392,17 @@ export const moduleSystems = sqliteTable(
392
392
  serviceId: text('service_id').references(() => containerServices.id),
393
393
  /** Proxmox VMID — set only for proxmox containers. */
394
394
  vmid: integer('vmid'),
395
+ // Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
396
+ // VM/LXC instances only (null for machine-pool systems celilo doesn't size).
397
+ // Seeded from the module's `requires.system` at first provision, then owned
398
+ // by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
399
+ // never the live size. See CLAUDE.md "requires.system is the MINIMUM".
400
+ /** vCPU cores. */
401
+ cpu: integer('cpu'),
402
+ /** RAM in MB. */
403
+ memory: integer('memory'),
404
+ /** Root disk in GB. */
405
+ disk: integer('disk'),
395
406
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
396
407
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
397
408
  },
@@ -492,8 +503,17 @@ export const dnsInternalRecords = sqliteTable(
492
503
  .references(() => modules.id, { onDelete: 'cascade' }),
493
504
  /** The registered hostname (e.g. "git-ssh.git.celilo.computer"). */
494
505
  host: text('host').notNull(),
495
- /** The A-record value celilo asked the resolver to serve. */
506
+ /** The A-record value celilo asked the resolver to serve — the LAN/default
507
+ * answer (firewall natIp for a caddy-fronted host). */
496
508
  ip: text('ip').notNull(),
509
+ /**
510
+ * In-zone split-horizon answer (caddy's zone-routable IP), when this is a
511
+ * caddy-fronted hostname that needs source-based views (ISS-0156,
512
+ * v2/INTERNAL_DNS_ZONE_VIEWS.md). NULL for records with no zone override
513
+ * (per-system identity, plain A records). This column is the durable
514
+ * desired-state the resolver's view config is reconciled from.
515
+ */
516
+ zoneRoutableIp: text('zone_routable_ip'),
497
517
  registeredAt: integer('registered_at', { mode: 'timestamp' })
498
518
  .notNull()
499
519
  .default(sql`(unixepoch())`),
@@ -124,6 +124,28 @@ export async function resolveFirewallNatIp(db: DbClient): Promise<string | undef
124
124
  return undefined;
125
125
  }
126
126
 
127
+ /**
128
+ * Caddy's zone-routable IP — its own DMZ ingress address (`target_ip`, the same
129
+ * value public_web exposes as `dmz_ip`). This is the in-zone split-horizon
130
+ * answer (ISS-0156): clients INSIDE the segmented zones reach caddy here, since
131
+ * they can't route to the firewall natIp. Returns undefined when no public_web
132
+ * provider advertises a `target_ip`. Shared by the live public_web registration
133
+ * and the deploy-time backfill so both write the same `zoneRoutableValue`.
134
+ */
135
+ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefined> {
136
+ const webProviders = db
137
+ .select()
138
+ .from(capabilities)
139
+ .where(eq(capabilities.capabilityName, 'public_web'))
140
+ .all();
141
+ for (const wp of webProviders) {
142
+ const cfg = await loadModuleConfig(wp.moduleId, db);
143
+ const ip = String(cfg.target_ip ?? '').split('/')[0];
144
+ if (ip) return ip;
145
+ }
146
+ return undefined;
147
+ }
148
+
127
149
  export async function loadCapabilityFunctions(
128
150
  consumingModuleId: string,
129
151
  db: DbClient,
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import type { ModuleManifest } from './schema';
3
- import { type TemplateValidationError, formatTemplateValidationErrors } from './template-validator';
6
+ import {
7
+ type TemplateValidationError,
8
+ formatTemplateValidationErrors,
9
+ validateModuleTemplates,
10
+ } from './template-validator';
4
11
 
5
12
  /**
6
13
  * Create a minimal valid manifest for testing
@@ -66,6 +73,29 @@ describe('template-validator', () => {
66
73
  expect(system?.storage).toBe('local-lvm');
67
74
  });
68
75
 
76
+ test('accepts $self:{cores,memory,disk,storage} as auto-allocated sizing (ISS-0150)', async () => {
77
+ // ISS-0150 repointed instance Terraform to read $self:cores/memory/disk/
78
+ // storage, which are injected at generate time from module_systems (see
79
+ // variables/context.ts) — not declared in the manifest. They must validate
80
+ // at `module import` time like vmid/target_ip, or every VM/CT module fails
81
+ // to import. Regression guard: this previously errored "Self variable
82
+ // 'cores' not found in module configuration".
83
+ const manifest = createTestManifest();
84
+ const dir = await mkdtemp(join(tmpdir(), 'celilo-tpl-'));
85
+ try {
86
+ await mkdir(join(dir, 'terraform'), { recursive: true });
87
+ await writeFile(
88
+ join(dir, 'terraform/main.tf.tpl'),
89
+ 'cores = $self:cores\nmemory = $self:memory\ndisk = $self:disk\nstorage = "$self:storage"\n',
90
+ );
91
+ const result = await validateModuleTemplates(dir, manifest);
92
+ expect(result.errors).toEqual([]);
93
+ expect(result.success).toBe(true);
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+
69
99
  test('validates capability references', async () => {
70
100
  const manifest = createTestManifest({
71
101
  requires: {
@@ -73,6 +73,15 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
73
73
  'gateway', // Auto-derived from zone configuration
74
74
  'target_node', // Can be auto-derived from system config
75
75
  'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
76
+ // Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
77
+ // disk,storage}, which are injected during resolution from the module_systems
78
+ // table (falling back to requires.system.*) — see variables/context.ts. Like
79
+ // vmid/target_ip they are populated at generate time, not declared in the
80
+ // manifest, so they are auto-allocated rather than import-time validation errors.
81
+ 'cores', // requires.system.cpu / module_systems.cpu
82
+ 'memory', // requires.system.memory / module_systems.memory
83
+ 'disk', // requires.system.disk / module_systems.disk
84
+ 'storage', // storage pool name (requires.system.storage)
76
85
  ]);
77
86
 
78
87
  /**
@@ -247,5 +247,57 @@ describe('aspect-approvals', () => {
247
247
  const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db);
248
248
  expect(status).toBe('scope_changed');
249
249
  });
250
+
251
+ // #262 — scope-keyed carry-forward across versions. The ISS-0156 cutover
252
+ // hung because consent was recorded at the imported version, then a
253
+ // `module update` bumped the version and the deploy re-prompted (no row for
254
+ // the new version) — and nothing answered the prompt. Per D7, only a SCOPE
255
+ // change requires re-consent; a pure version bump must not.
256
+ it('carries approval forward to a bumped version with the SAME scope (#262 — no re-prompt)', () => {
257
+ insertModule('technitium', '1.0.2+4');
258
+ const db = getDb();
259
+ recordAspectApproval({
260
+ moduleId: 'technitium',
261
+ version: '1.0.2+4',
262
+ scopeHash: computeAspectScopeHash(baseAspect),
263
+ approver: null,
264
+ db,
265
+ });
266
+ // `module update` bumped +4 → +6; aspect scope unchanged.
267
+ expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('approved');
268
+ });
269
+
270
+ it('carries a DENIAL forward to a bumped version with the same scope', () => {
271
+ insertModule('technitium', '1.0.2+4');
272
+ const db = getDb();
273
+ recordAspectConsent({
274
+ moduleId: 'technitium',
275
+ version: '1.0.2+4',
276
+ scopeHash: computeAspectScopeHash(baseAspect),
277
+ approver: null,
278
+ consented: false,
279
+ db,
280
+ });
281
+ expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('denied');
282
+ });
283
+
284
+ it('does NOT carry forward to a bumped version when the scope changed', () => {
285
+ insertModule('technitium', '1.0.2+4');
286
+ const db = getDb();
287
+ recordAspectApproval({
288
+ moduleId: 'technitium',
289
+ version: '1.0.2+4',
290
+ scopeHash: computeAspectScopeHash(baseAspect),
291
+ approver: null,
292
+ db,
293
+ });
294
+ const narrowed: BaseModuleAspect = {
295
+ ansible_role: 'dns-client-config',
296
+ applicable_zones: ['dmz'],
297
+ triggers: ['on_install'],
298
+ };
299
+ // Bumped version AND narrowed scope → no exact row, no scope match → re-prompt.
300
+ expect(checkAspectApproval('technitium', '1.0.2+6', narrowed, db)).toBe('no_approval');
301
+ });
250
302
  });
251
303
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { createHash, randomUUID } from 'node:crypto';
24
- import { and, eq, sql } from 'drizzle-orm';
24
+ import { and, desc, eq, sql } from 'drizzle-orm';
25
25
  import type { getDb } from '../db/client';
26
26
  import { aspectApprovals } from '../db/schema';
27
27
  import type { BaseModuleAspect } from '../manifest/schema';
@@ -65,6 +65,26 @@ export function findAspectApproval(
65
65
  .get();
66
66
  }
67
67
 
68
+ /**
69
+ * Find the most recent consent row for a (moduleId, scopeHash) pair across ALL
70
+ * versions. Used to carry an operator's decision forward when a module version
71
+ * bump leaves the aspect scope (applicable_zones + triggers) unchanged — so a
72
+ * `module update` auto-revision doesn't force re-consent (#262). The latest
73
+ * decision (by approvedAt) for that scope wins.
74
+ */
75
+ export function findAspectApprovalByScope(
76
+ moduleId: string,
77
+ scopeHash: string,
78
+ db: DbClient,
79
+ ): typeof aspectApprovals.$inferSelect | undefined {
80
+ return db
81
+ .select()
82
+ .from(aspectApprovals)
83
+ .where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.scopeHash, scopeHash)))
84
+ .orderBy(desc(aspectApprovals.approvedAt))
85
+ .get();
86
+ }
87
+
68
88
  /**
69
89
  * Record the operator's consent DECISION for a module version's
70
90
  * base-module aspect — `consented: true` (approve) or `false`
@@ -132,9 +152,17 @@ export function recordAspectApproval(args: {
132
152
  * - 'approved': a matching-scope row with `consented: true` — run.
133
153
  * - 'denied': a matching-scope row with `consented: false` — the
134
154
  * operator explicitly refused (ISS-0027). Skip; do NOT re-prompt.
135
- * - 'scope_changed': a row exists but the manifest's scope differs
136
- * (D7 — the prior decision was about a different scope; re-prompt).
137
- * - 'no_approval': no row for this version undecided; prompt.
155
+ * - 'scope_changed': a row exists for THIS version but the manifest's scope
156
+ * differs (D7 — the prior decision was about a different scope; re-prompt).
157
+ * - 'no_approval': no row for this version AND no prior decision for this
158
+ * scope at any version — undecided; prompt.
159
+ *
160
+ * #262: when there's no row for the exact version, the operator's decision is
161
+ * carried forward by SCOPE. A pure version bump (e.g. a `module update`
162
+ * auto-revision) that leaves applicable_zones + triggers unchanged must NOT
163
+ * re-prompt — that re-prompt, unanswerable headlessly, is what hung the
164
+ * ISS-0156 cutover. A genuine scope change has a different scopeHash, so it
165
+ * finds no match and correctly falls through to 'no_approval'.
138
166
  */
139
167
  export function checkAspectApproval(
140
168
  moduleId: string,
@@ -142,9 +170,14 @@ export function checkAspectApproval(
142
170
  aspect: BaseModuleAspect,
143
171
  db: DbClient,
144
172
  ): 'approved' | 'denied' | 'scope_changed' | 'no_approval' {
145
- const existing = findAspectApproval(moduleId, version, db);
146
- if (!existing) return 'no_approval';
147
173
  const currentHash = computeAspectScopeHash(aspect);
148
- if (existing.scopeHash !== currentHash) return 'scope_changed';
149
- return existing.consented ? 'approved' : 'denied';
174
+ const exact = findAspectApproval(moduleId, version, db);
175
+ if (exact) {
176
+ if (exact.scopeHash !== currentHash) return 'scope_changed';
177
+ return exact.consented ? 'approved' : 'denied';
178
+ }
179
+ // No row for this exact version — carry the decision forward by scope (#262).
180
+ const byScope = findAspectApprovalByScope(moduleId, currentHash, db);
181
+ if (byScope) return byScope.consented ? 'approved' : 'denied';
182
+ return 'no_approval';
150
183
  }