@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.
- package/AGENTS.md +10 -18
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/README.md +1539 -48
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +5 -10
- package/src/capabilities/well-known.test.ts +12 -66
- package/src/capabilities/well-known.ts +11 -12
- package/src/cli/command-registry.ts +65 -1
- package/src/cli/commands/module-upgrade.test.ts +29 -0
- package/src/cli/commands/module-upgrade.ts +57 -24
- package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +140 -0
- package/src/cli/commands/proxmox-instance-resize.ts +235 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -34
- package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
- package/src/cli/commands/proxmox-resize-guards.ts +102 -0
- package/src/cli/commands/proxmox-service.ts +38 -0
- package/src/cli/completion.ts +11 -37
- package/src/cli/index.ts +15 -0
- package/src/cli/validators.test.ts +1 -206
- package/src/cli/validators.ts +0 -168
- package/src/db/schema.ts +21 -1
- package/src/hooks/capability-loader.ts +22 -0
- package/src/manifest/template-validator.test.ts +31 -1
- package/src/manifest/template-validator.ts +9 -0
- package/src/services/aspect-approvals.test.ts +52 -0
- package/src/services/aspect-approvals.ts +41 -8
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/dns-internal-records.test.ts +76 -3
- package/src/services/dns-internal-records.ts +52 -3
- package/src/services/dns-provider-backfill.ts +15 -3
- package/src/services/fleet-checks.test.ts +18 -16
- package/src/services/machine-detector.ts +34 -12
- package/src/services/programmatic-responder.aspect.test.ts +157 -0
- package/src/services/programmatic-responder.ts +51 -0
- package/src/templates/generator.ts +49 -1
- package/src/utils/shell.test.ts +1 -163
- package/src/utils/shell.ts +0 -100
- package/src/validation/schemas.ts +0 -5
- package/src/variables/context.ts +36 -7
- package/CLI_USAGE.md +0 -433
- 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
|
-
});
|
package/src/cli/validators.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
136
|
-
* (D7 — the prior decision was about a different scope; re-prompt).
|
|
137
|
-
* - 'no_approval': no row for this version
|
|
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
|
-
|
|
149
|
-
|
|
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
|
}
|