@celilo/cli 0.1.0

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 (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * IPAM Auto-Allocator
3
+ * Automatic VMID and IP allocation for container-based modules
4
+ *
5
+ * - Modules declare zone, Celilo auto-allocates vmid/IP
6
+ * - Sequential allocation with reservation support
7
+ * - Persistent storage in ip_allocations table
8
+ */
9
+
10
+ import { eq } from 'drizzle-orm';
11
+ import type { DbClient } from '../db/client';
12
+ import { ipAllocations } from '../db/schema';
13
+
14
+ export interface IpamAllocation {
15
+ moduleId: string;
16
+ vmid: number;
17
+ containerIp: string;
18
+ zone: string;
19
+ }
20
+
21
+ /**
22
+ * Get existing allocation for module
23
+ *
24
+ * @param moduleId - Module identifier
25
+ * @param db - Drizzle database instance
26
+ * @returns Existing allocation or null
27
+ */
28
+ export function getAllocation(moduleId: string, db: DbClient): IpamAllocation | null {
29
+ const allocation = db
30
+ .select()
31
+ .from(ipAllocations)
32
+ .where(eq(ipAllocations.moduleId, moduleId))
33
+ .get();
34
+
35
+ if (!allocation) {
36
+ return null;
37
+ }
38
+
39
+ return {
40
+ moduleId: allocation.moduleId,
41
+ vmid: allocation.vmid,
42
+ containerIp: allocation.containerIp,
43
+ zone: allocation.zone,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Allocate VMID from available pool
49
+ *
50
+ * Algorithm:
51
+ * - Start from 200 (100-199 reserved for infrastructure)
52
+ * - Skip already allocated VMIDs
53
+ * - Skip reserved VMID ranges
54
+ * - Return first available VMID
55
+ *
56
+ * @param db - SQLite database instance (for raw queries)
57
+ * @returns Next available VMID
58
+ * @throws Error if no VMIDs available
59
+ */
60
+ function allocateVMID(db: DbClient['$client']): number {
61
+ // Get all allocated VMIDs
62
+ const allocatedRows = db.prepare('SELECT vmid FROM ip_allocations').all() as Array<{
63
+ vmid: number;
64
+ }>;
65
+
66
+ // Get all reserved VMIDs (including ranges)
67
+ const reservedRows = db.prepare('SELECT vmid FROM vmid_reservations').all() as Array<{
68
+ vmid: number;
69
+ }>;
70
+
71
+ const used = new Set<number>();
72
+
73
+ // Add allocated VMIDs
74
+ for (const row of allocatedRows) {
75
+ used.add(row.vmid);
76
+ }
77
+
78
+ // Add reserved VMIDs
79
+ for (const row of reservedRows) {
80
+ used.add(row.vmid);
81
+ }
82
+
83
+ // Find first available VMID starting from 200
84
+ const START_VMID = 200;
85
+ const MAX_VMID = 999999999;
86
+
87
+ for (let candidate = START_VMID; candidate <= MAX_VMID; candidate++) {
88
+ if (!used.has(candidate)) {
89
+ return candidate;
90
+ }
91
+ }
92
+
93
+ throw new Error('No available VMIDs in pool (200-999999999)');
94
+ }
95
+
96
+ /**
97
+ * Parse CIDR subnet to get network address and prefix
98
+ *
99
+ * @param subnet - CIDR subnet (e.g., "10.0.10.0/24")
100
+ * @returns Parsed subnet components
101
+ */
102
+ function parseSubnet(subnet: string): {
103
+ octets: number[];
104
+ prefix: number;
105
+ } {
106
+ const [network, prefixStr] = subnet.split('/');
107
+ const octets = network.split('.').map(Number);
108
+ const prefix = Number.parseInt(prefixStr, 10);
109
+
110
+ return { octets, prefix };
111
+ }
112
+
113
+ /**
114
+ * Allocate IP address from zone subnet
115
+ *
116
+ * Algorithm:
117
+ * - Get zone subnet from system config
118
+ * - Parse CIDR to get network range
119
+ * - Skip .0 (network), .1 (gateway), .255 (broadcast)
120
+ * - Start allocation from .10 (reserve .2-.9 for infrastructure)
121
+ * - Skip already allocated IPs
122
+ * - Skip reserved IP ranges
123
+ * - Return first available IP
124
+ *
125
+ * @param zone - Network zone (dmz, app, secure)
126
+ * @param db - SQLite database instance
127
+ * @returns Next available IP address
128
+ * @throws Error if no IPs available or subnet not configured
129
+ */
130
+ function allocateIP(zone: string, db: DbClient['$client']): string {
131
+ // Get zone subnet from system config
132
+ const subnetRow = db
133
+ .prepare('SELECT value FROM system_config WHERE key = ?')
134
+ .get(`network.${zone}.subnet`) as { value: string } | undefined;
135
+
136
+ if (!subnetRow) {
137
+ throw new Error(
138
+ `Zone subnet not configured: network.${zone}.subnet\n` +
139
+ `Run: celilo system config set network.${zone}.subnet <cidr>`,
140
+ );
141
+ }
142
+
143
+ const subnet = subnetRow.value;
144
+ const { octets, prefix } = parseSubnet(subnet);
145
+ const [a, b, c] = octets;
146
+
147
+ // IP allocation range: .10 to .254
148
+ // .0 = network address
149
+ // .1 = gateway (reserved)
150
+ // .2-.9 = infrastructure (reserved for manual use)
151
+ // .10-.254 = auto-allocation pool
152
+ // .255 = broadcast address
153
+ const START_OCTET = 10;
154
+ const END_OCTET = 254;
155
+
156
+ // Get all allocated IPs in this zone
157
+ const allocatedRows = db
158
+ .prepare('SELECT container_ip FROM ip_allocations WHERE zone = ?')
159
+ .all(zone) as Array<{ container_ip: string }>;
160
+
161
+ // Get all reserved IPs in this zone
162
+ const reservedRows = db
163
+ .prepare('SELECT ip_start, ip_end FROM ip_reservations WHERE zone = ?')
164
+ .all(zone) as Array<{ ip_start: string; ip_end: string | null }>;
165
+
166
+ const used = new Set<string>();
167
+
168
+ // Add allocated IPs (strip CIDR suffix for comparison)
169
+ for (const row of allocatedRows) {
170
+ const ipOnly = row.container_ip.split('/')[0];
171
+ used.add(ipOnly);
172
+ }
173
+
174
+ // Add reserved IPs (including ranges)
175
+ for (const row of reservedRows) {
176
+ if (row.ip_end) {
177
+ // Handle IP range: "10.0.10.1-10.0.10.9"
178
+ const startOctet = Number.parseInt(row.ip_start.split('.')[3], 10);
179
+ const endOctet = Number.parseInt(row.ip_end.split('.')[3], 10);
180
+
181
+ for (let i = startOctet; i <= endOctet; i++) {
182
+ used.add(`${a}.${b}.${c}.${i}`);
183
+ }
184
+ } else {
185
+ // Single IP reservation
186
+ used.add(row.ip_start);
187
+ }
188
+ }
189
+
190
+ // Find first available IP
191
+ for (let octet = START_OCTET; octet <= END_OCTET; octet++) {
192
+ const candidate = `${a}.${b}.${c}.${octet}`;
193
+ if (!used.has(candidate)) {
194
+ return `${candidate}/${prefix}`;
195
+ }
196
+ }
197
+
198
+ throw new Error(
199
+ `No available IPs in zone ${zone} subnet ${subnet}\n` +
200
+ `Allocated: ${allocatedRows.length}, Reserved: ${reservedRows.length}`,
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Allocate VMID and IP for module
206
+ *
207
+ * Main allocation function - coordinates VMID and IP allocation
208
+ * and stores result in database.
209
+ *
210
+ * @param moduleId - Module identifier
211
+ * @param zone - Network zone (dmz, app, secure)
212
+ * @param drizzleDb - Drizzle database instance (for inserts)
213
+ * @param sqliteDb - SQLite database instance (for raw queries)
214
+ * @returns Allocated VMID and IP
215
+ */
216
+ export async function allocateForModule(
217
+ moduleId: string,
218
+ zone: string,
219
+ drizzleDb: DbClient,
220
+ sqliteDb: DbClient['$client'],
221
+ ): Promise<IpamAllocation> {
222
+ // Check if already allocated
223
+ const existing = getAllocation(moduleId, drizzleDb);
224
+ if (existing) {
225
+ return existing;
226
+ }
227
+
228
+ // Allocate VMID
229
+ const vmid = allocateVMID(sqliteDb);
230
+
231
+ // Allocate IP from zone subnet
232
+ const containerIp = allocateIP(zone, sqliteDb);
233
+
234
+ // Store allocation
235
+ await drizzleDb.insert(ipAllocations).values({
236
+ moduleId,
237
+ vmid,
238
+ containerIp,
239
+ zone: zone as 'dmz' | 'app' | 'secure' | 'internal',
240
+ });
241
+
242
+ return {
243
+ moduleId,
244
+ vmid,
245
+ containerIp,
246
+ zone,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Deallocate VMID and IP for module
252
+ *
253
+ * Removes allocation from database, making VMID and IP available
254
+ * for future allocations.
255
+ *
256
+ * @param moduleId - Module identifier
257
+ * @param db - Drizzle database instance
258
+ * @returns True if allocation was removed, false if none existed
259
+ */
260
+ export async function deallocateForModule(moduleId: string, db: DbClient): Promise<boolean> {
261
+ // Check if allocation exists before deleting
262
+ const existing = getAllocation(moduleId, db);
263
+ if (!existing) {
264
+ return false;
265
+ }
266
+
267
+ await db.delete(ipAllocations).where(eq(ipAllocations.moduleId, moduleId));
268
+
269
+ return true;
270
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ addCIDR,
4
+ generateIPsInSubnet,
5
+ isIPInRange,
6
+ isInSubnet,
7
+ parseSubnet,
8
+ stripCIDR,
9
+ } from './subnet-parser';
10
+
11
+ describe('Subnet Parser', () => {
12
+ describe('parseSubnet', () => {
13
+ test('should parse valid /24 subnet', () => {
14
+ const result = parseSubnet('10.0.10.0/24');
15
+
16
+ expect(result.network).toBe('10.0.10.0');
17
+ expect(result.maskBits).toBe(24);
18
+ expect(result.octets).toEqual([10, 0, 10, 0]);
19
+ expect(result.firstUsableIp).toBe('10.0.10.10');
20
+ expect(result.lastUsableIp).toBe('10.0.10.254');
21
+ });
22
+
23
+ test('should reject invalid subnet format', () => {
24
+ expect(() => parseSubnet('invalid')).toThrow('Invalid subnet format');
25
+ expect(() => parseSubnet('10.0.10.0')).toThrow('Invalid subnet format');
26
+ expect(() => parseSubnet('10.0.10.0/abc')).toThrow('Invalid subnet format');
27
+ });
28
+
29
+ test('should reject invalid IP address', () => {
30
+ expect(() => parseSubnet('256.0.10.0/24')).toThrow('Invalid IP address');
31
+ expect(() => parseSubnet('10.0.10/24')).toThrow('Invalid IP address');
32
+ });
33
+
34
+ test('should reject subnets smaller than /24', () => {
35
+ expect(() => parseSubnet('10.0.10.0/25')).toThrow('Subnet too small');
36
+ expect(() => parseSubnet('10.0.10.0/30')).toThrow('Subnet too small');
37
+ });
38
+ });
39
+
40
+ describe('stripCIDR', () => {
41
+ test('should strip CIDR mask', () => {
42
+ expect(stripCIDR('10.0.10.10/24')).toBe('10.0.10.10');
43
+ expect(stripCIDR('192.168.1.1/16')).toBe('192.168.1.1');
44
+ });
45
+
46
+ test('should return IP unchanged if no mask', () => {
47
+ expect(stripCIDR('10.0.10.10')).toBe('10.0.10.10');
48
+ });
49
+ });
50
+
51
+ describe('addCIDR', () => {
52
+ test('should add CIDR mask', () => {
53
+ expect(addCIDR('10.0.10.10', 24)).toBe('10.0.10.10/24');
54
+ expect(addCIDR('192.168.1.1', 16)).toBe('192.168.1.1/16');
55
+ });
56
+ });
57
+
58
+ describe('isInSubnet', () => {
59
+ test('should return true for IP in subnet', () => {
60
+ expect(isInSubnet('10.0.10.50/24', '10.0.10.0/24')).toBe(true);
61
+ expect(isInSubnet('10.0.10.10/24', '10.0.10.0/24')).toBe(true);
62
+ expect(isInSubnet('10.0.10.254/24', '10.0.10.0/24')).toBe(true);
63
+ });
64
+
65
+ test('should return false for IP outside subnet', () => {
66
+ expect(isInSubnet('10.0.11.50/24', '10.0.10.0/24')).toBe(false);
67
+ expect(isInSubnet('10.0.20.10/24', '10.0.10.0/24')).toBe(false);
68
+ expect(isInSubnet('192.168.1.1/24', '10.0.10.0/24')).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe('generateIPsInSubnet', () => {
73
+ test('should generate IPs from .10 to .254', () => {
74
+ const ips = Array.from(generateIPsInSubnet('10.0.10.0/24'));
75
+
76
+ expect(ips[0]).toBe('10.0.10.10/24'); // First usable (reserve .1-.9)
77
+ expect(ips[ips.length - 1]).toBe('10.0.10.254/24'); // Last usable
78
+ expect(ips.length).toBe(245); // 254 - 10 + 1 = 245 IPs
79
+ });
80
+
81
+ test('should generate correct IPs for different subnet', () => {
82
+ const ips = Array.from(generateIPsInSubnet('192.168.1.0/24'));
83
+
84
+ expect(ips[0]).toBe('192.168.1.10/24');
85
+ expect(ips[ips.length - 1]).toBe('192.168.1.254/24');
86
+ });
87
+ });
88
+
89
+ describe('isIPInRange', () => {
90
+ test('should match single IP reservation', () => {
91
+ expect(isIPInRange('10.0.10.50', '10.0.10.50', null)).toBe(true);
92
+ expect(isIPInRange('10.0.10.51', '10.0.10.50', null)).toBe(false);
93
+ });
94
+
95
+ test('should match IP range reservation', () => {
96
+ expect(isIPInRange('10.0.10.50', '10.0.10.50', '10.0.10.60')).toBe(true);
97
+ expect(isIPInRange('10.0.10.55', '10.0.10.50', '10.0.10.60')).toBe(true);
98
+ expect(isIPInRange('10.0.10.60', '10.0.10.50', '10.0.10.60')).toBe(true);
99
+ expect(isIPInRange('10.0.10.49', '10.0.10.50', '10.0.10.60')).toBe(false);
100
+ expect(isIPInRange('10.0.10.61', '10.0.10.50', '10.0.10.60')).toBe(false);
101
+ });
102
+
103
+ test('should handle IPs with CIDR notation', () => {
104
+ expect(isIPInRange('10.0.10.50/24', '10.0.10.50', '10.0.10.60')).toBe(true);
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Subnet parsing utilities for IPAM system
3
+ * Handles CIDR notation and IP address operations
4
+ */
5
+
6
+ export interface SubnetInfo {
7
+ network: string;
8
+ maskBits: number;
9
+ octets: [number, number, number, number];
10
+ firstUsableIp: string;
11
+ lastUsableIp: string;
12
+ totalHosts: number;
13
+ }
14
+
15
+ /**
16
+ * Parse subnet CIDR notation (e.g., "10.0.10.0/24")
17
+ * Returns network information needed for IP allocation
18
+ */
19
+ export function parseSubnet(subnet: string): SubnetInfo {
20
+ const [network, maskBitsStr] = subnet.split('/');
21
+ const maskBits = Number.parseInt(maskBitsStr, 10);
22
+
23
+ if (!network || Number.isNaN(maskBits) || maskBits < 0 || maskBits > 32) {
24
+ throw new Error(`Invalid subnet format: ${subnet}`);
25
+ }
26
+
27
+ const octets = network.split('.').map((o) => {
28
+ const octet = Number.parseInt(o, 10);
29
+ if (Number.isNaN(octet) || octet < 0 || octet > 255) {
30
+ throw new Error(`Invalid IP address: ${network}`);
31
+ }
32
+ return octet;
33
+ }) as [number, number, number, number];
34
+
35
+ if (octets.length !== 4) {
36
+ throw new Error(`Invalid IP address: ${network}`);
37
+ }
38
+
39
+ // Calculate total hosts (2^(32-maskBits) - 2 for network and broadcast)
40
+ const totalHosts = 2 ** (32 - maskBits) - 2;
41
+
42
+ // For simplicity, we only support /24 and larger subnets
43
+ if (maskBits > 24) {
44
+ throw new Error(`Subnet too small: ${subnet}. Celilo requires /24 or larger subnets.`);
45
+ }
46
+
47
+ // Calculate first and last usable IPs
48
+ // We reserve .1-.9 for infrastructure, so first usable is .10
49
+ const firstUsableIp = `${octets[0]}.${octets[1]}.${octets[2]}.10`;
50
+ const lastUsableIp = `${octets[0]}.${octets[1]}.${octets[2]}.254`;
51
+
52
+ return {
53
+ network,
54
+ maskBits,
55
+ octets,
56
+ firstUsableIp,
57
+ lastUsableIp,
58
+ totalHosts,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Strip CIDR mask from IP address
64
+ * Example: "10.0.10.10/24" → "10.0.10.10"
65
+ */
66
+ export function stripCIDR(ipWithMask: string): string {
67
+ return ipWithMask.split('/')[0];
68
+ }
69
+
70
+ /**
71
+ * Add CIDR mask to IP address
72
+ * Example: "10.0.10.10", 24 → "10.0.10.10/24"
73
+ */
74
+ export function addCIDR(ip: string, maskBits: number): string {
75
+ return `${ip}/${maskBits}`;
76
+ }
77
+
78
+ /**
79
+ * Check if an IP address belongs to a subnet
80
+ * Example: isInSubnet("10.0.10.50/24", "10.0.10.0/24") → true
81
+ */
82
+ export function isInSubnet(ipWithMask: string, subnet: string): boolean {
83
+ const ip = stripCIDR(ipWithMask);
84
+ const subnetInfo = parseSubnet(subnet);
85
+
86
+ const ipOctets = ip.split('.').map((o) => Number.parseInt(o, 10));
87
+
88
+ // For /24 subnet, just check first 3 octets
89
+ return (
90
+ ipOctets[0] === subnetInfo.octets[0] &&
91
+ ipOctets[1] === subnetInfo.octets[1] &&
92
+ ipOctets[2] === subnetInfo.octets[2]
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Generate all possible IPs in a subnet range
98
+ * For /24 subnet, returns IPs from .10 to .254 (reserved .1-.9 for infrastructure)
99
+ */
100
+ export function* generateIPsInSubnet(subnet: string): Generator<string> {
101
+ const subnetInfo = parseSubnet(subnet);
102
+ const [a, b, c] = subnetInfo.octets;
103
+
104
+ // Start from .10 (reserve .1-.9 for infrastructure)
105
+ // End at .254 (reserve .255 for broadcast)
106
+ for (let lastOctet = 10; lastOctet <= 254; lastOctet++) {
107
+ yield addCIDR(`${a}.${b}.${c}.${lastOctet}`, subnetInfo.maskBits);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check if IP is within a reserved range
113
+ */
114
+ export function isIPInRange(ip: string, rangeStart: string, rangeEnd: string | null): boolean {
115
+ const ipNum = ipToNumber(ip);
116
+ const startNum = ipToNumber(rangeStart);
117
+
118
+ if (!rangeEnd) {
119
+ // Single IP reservation
120
+ return ipNum === startNum;
121
+ }
122
+
123
+ const endNum = ipToNumber(rangeEnd);
124
+ return ipNum >= startNum && ipNum <= endNum;
125
+ }
126
+
127
+ /**
128
+ * Convert IP address to number for comparison
129
+ * Example: "10.0.10.50" → 167773234
130
+ */
131
+ function ipToNumber(ip: string): number {
132
+ const octets = stripCIDR(ip)
133
+ .split('.')
134
+ .map((o) => Number.parseInt(o, 10));
135
+ return (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3];
136
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Celilo Module Contract Registry
3
+ *
4
+ * Maps contract version strings to their canonical hook signatures. The
5
+ * manifest's `celilo_contract` field selects which contract applies; the
6
+ * registry is the single source of truth for what each version promises.
7
+ *
8
+ * To mint a new contract version: add a new file (e.g. `./v2.ts`), import its
9
+ * contract object here, and register it in `CONTRACTS`. Old contracts stay
10
+ * registered indefinitely so legacy modules continue to validate.
11
+ */
12
+
13
+ import { V1_CONTRACT } from './v1';
14
+ import type { ContractHookSignature, ContractHooks } from './v1';
15
+
16
+ export type { ContractHooks, ContractHookSignature };
17
+
18
+ /**
19
+ * The shape of a registered contract.
20
+ */
21
+ export interface Contract {
22
+ /** Semver-style version string (e.g. "1.0"). */
23
+ version: string;
24
+ /** Canonical hook signatures for this version. */
25
+ hooks: ContractHooks;
26
+ }
27
+
28
+ /**
29
+ * Registry of every contract version Celilo knows about.
30
+ * The keys are the strings users write in `celilo_contract`.
31
+ *
32
+ * When minting a new version, also add its string literal to
33
+ * `SUPPORTED_CONTRACT_VERSIONS` below so Zod's enum picks it up.
34
+ */
35
+ export const CONTRACTS: Record<string, Contract> = {
36
+ '1.0': V1_CONTRACT,
37
+ };
38
+
39
+ /**
40
+ * Const tuple of supported contract version strings, used to drive the Zod
41
+ * enum that validates the manifest's `celilo_contract` field.
42
+ */
43
+ export const SUPPORTED_CONTRACT_VERSIONS = ['1.0'] as const;
44
+ export type SupportedContractVersion = (typeof SUPPORTED_CONTRACT_VERSIONS)[number];
45
+
46
+ /**
47
+ * Look up a contract by version string.
48
+ *
49
+ * @returns The contract, or `undefined` if the version is not registered.
50
+ */
51
+ export function resolveContract(version: string): Contract | undefined {
52
+ return CONTRACTS[version];
53
+ }
54
+
55
+ /**
56
+ * List of all registered contract version strings, used for error messages
57
+ * and for validating the `celilo_contract` field's enum.
58
+ */
59
+ export function supportedContractVersions(): string[] {
60
+ return Object.keys(CONTRACTS);
61
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Celilo Module Contract — v1.0
3
+ *
4
+ * Defines the canonical signature of every lifecycle hook in contract version
5
+ * "1.0". A module that declares `celilo_contract: "1.0"` in its manifest
6
+ * promises to implement these hooks with these exact inputs and outputs.
7
+ *
8
+ * Why this exists:
9
+ * Before contract versioning, every module redeclared inputs/outputs arrays
10
+ * for every hook in its manifest. That meant if Celilo changed the
11
+ * canonical signature of a hook (say, added a required input to on_backup),
12
+ * we had to hand-edit every manifest to match. This file is now the single
13
+ * source of truth.
14
+ *
15
+ * The contract is enforced by `validateHookInputs` and `validateHookOutputs`
16
+ * in `apps/celilo/src/hooks/executor.ts` — at execution time the executor
17
+ * looks up the canonical inputs/outputs from the contract registered for the
18
+ * manifest's declared version.
19
+ *
20
+ * Adding a new optional input or output is a v1.x additive change.
21
+ * Adding a new required input or removing a required output is a breaking
22
+ * change and requires a v2.0 contract.
23
+ */
24
+
25
+ /**
26
+ * Per-input/output metadata.
27
+ *
28
+ * `required: true` — the executor enforces presence at runtime.
29
+ * `required: false` — the value is allowed but not mandatory; modules may
30
+ * produce or accept it without violating the contract.
31
+ */
32
+ export interface ContractField {
33
+ required: boolean;
34
+ }
35
+
36
+ /**
37
+ * Signature for a single lifecycle hook within a contract version.
38
+ */
39
+ export interface ContractHookSignature {
40
+ /** Inputs the executor must supply when calling the hook. */
41
+ inputs: Record<string, ContractField>;
42
+ /** Outputs the executor enforces against the hook's return value. */
43
+ outputs: Record<string, ContractField>;
44
+ }
45
+
46
+ /**
47
+ * Full contract for a version: a map of canonical hook name → signature.
48
+ * Only hook names listed here are valid; declaring a hook not in this map is
49
+ * a manifest validation error.
50
+ */
51
+ export type ContractHooks = Record<string, ContractHookSignature>;
52
+
53
+ /**
54
+ * Contract v1.0 — current canonical hook signatures.
55
+ *
56
+ * These mirror the inputs/outputs that pre-Phase-2 manifests declared
57
+ * inline. The values were derived from a survey of every active manifest in
58
+ * the repo at the time the contract was minted.
59
+ */
60
+ export const V1_HOOKS: ContractHooks = {
61
+ container_created: {
62
+ inputs: {},
63
+ outputs: {},
64
+ },
65
+ on_install: {
66
+ inputs: {},
67
+ outputs: {},
68
+ },
69
+ on_uninstall: {
70
+ inputs: {},
71
+ outputs: {},
72
+ },
73
+ health_check: {
74
+ inputs: {},
75
+ outputs: {},
76
+ },
77
+ validate_config: {
78
+ inputs: {},
79
+ outputs: {},
80
+ },
81
+ on_backup: {
82
+ inputs: {
83
+ backup_dir: { required: true },
84
+ },
85
+ outputs: {
86
+ artifact_count: { required: true },
87
+ size_bytes: { required: true },
88
+ schema_version: { required: true },
89
+ },
90
+ },
91
+ on_backup_analyze: {
92
+ inputs: {
93
+ artifact_path: { required: true },
94
+ },
95
+ outputs: {
96
+ artifact_count: { required: true },
97
+ size_bytes: { required: true },
98
+ schema_version: { required: true },
99
+ },
100
+ },
101
+ on_restore: {
102
+ inputs: {
103
+ restore_dir: { required: true },
104
+ schema_version: { required: true },
105
+ },
106
+ outputs: {
107
+ restored_items: { required: true },
108
+ },
109
+ },
110
+ };
111
+
112
+ /**
113
+ * Contract v1.0 metadata.
114
+ */
115
+ export const V1_CONTRACT = {
116
+ version: '1.0' as const,
117
+ hooks: V1_HOOKS,
118
+ };