@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,369 @@
1
+ /**
2
+ * IPAM (IP Address Management) Allocator
3
+ * Automatically allocates VMID and container IP addresses from zone subnets
4
+ * Prevents conflicts and tracks allocations
5
+ */
6
+
7
+ import { and, eq } from 'drizzle-orm';
8
+ import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
9
+ import type { DbClient } from '../db/client';
10
+ import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
11
+ import type { NewIpAllocation, NewIpReservation, NewVmidReservation } from '../db/schema';
12
+ import type * as schema from '../db/schema';
13
+ import { generateIPsInSubnet, isIPInRange, isInSubnet, stripCIDR } from './subnet-parser';
14
+
15
+ // Type that accepts both database client and transaction
16
+ type DbOrTransaction = BunSQLiteDatabase<typeof schema> | DbClient;
17
+
18
+ /** Zones that support IPAM auto-allocation of VMID and container IP */
19
+ export type IpamZone = 'dmz' | 'app' | 'secure' | 'internal';
20
+
21
+ const IPAM_ZONES: IpamZone[] = ['internal', 'dmz', 'app', 'secure'];
22
+
23
+ /**
24
+ * Infer which zone an IP address belongs to by checking configured zone subnets.
25
+ * Returns null if the IP doesn't match any known zone.
26
+ */
27
+ export async function inferZoneFromIP(ip: string, db: DbOrTransaction): Promise<IpamZone | null> {
28
+ const bareIp = stripCIDR(ip);
29
+ for (const zone of IPAM_ZONES) {
30
+ const subnetKey = `network.${zone}.subnet`;
31
+ const records = await db
32
+ .select()
33
+ .from(systemConfig)
34
+ .where(eq(systemConfig.key, subnetKey))
35
+ .all();
36
+ if (records.length > 0 && isInSubnet(bareIp, records[0].value)) {
37
+ return zone;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export interface IPAMAllocation {
44
+ vmid: number;
45
+ containerIp: string; // CIDR format (e.g., "10.0.10.10/24")
46
+ }
47
+
48
+ /**
49
+ * Allocate VMID and container IP for a module
50
+ * Automatically selects next available from zone subnet
51
+ */
52
+ export async function allocateResources(
53
+ moduleId: string,
54
+ zone: IpamZone,
55
+ db: DbOrTransaction,
56
+ ): Promise<IPAMAllocation> {
57
+ // Allocate VMID (sequential from 2100)
58
+ const vmid = await allocateVMID(db);
59
+
60
+ // Get zone subnet from system config
61
+ const subnetKey = `network.${zone}.subnet`;
62
+ const subnetRecords = await db
63
+ .select()
64
+ .from(systemConfig)
65
+ .where(eq(systemConfig.key, subnetKey))
66
+ .all();
67
+
68
+ if (subnetRecords.length === 0) {
69
+ throw new Error(
70
+ `Network zone '${zone}' not configured. Run: celilo system config set ${subnetKey} "10.0.X.0/24"`,
71
+ );
72
+ }
73
+
74
+ const subnet = subnetRecords[0].value;
75
+
76
+ // Allocate IP from zone subnet
77
+ const containerIp = await allocateIPFromSubnet(subnet, zone, db);
78
+
79
+ // Record allocation
80
+ const newAllocation: NewIpAllocation = {
81
+ moduleId,
82
+ vmid,
83
+ containerIp,
84
+ zone,
85
+ };
86
+
87
+ await db.insert(ipAllocations).values(newAllocation).run();
88
+
89
+ return { vmid, containerIp };
90
+ }
91
+
92
+ /**
93
+ * Allocate next available VMID
94
+ * Starts from 2100 and increments sequentially
95
+ * Skips reserved VMIDs
96
+ */
97
+ export async function allocateVMID(db: DbOrTransaction): Promise<number> {
98
+ // Get all allocated VMIDs
99
+ const allocations = await db.select().from(ipAllocations).all();
100
+ const allocatedVMIDs = new Set(allocations.map((a: typeof ipAllocations.$inferSelect) => a.vmid));
101
+
102
+ // Get all reserved VMIDs
103
+ const reservations = await db.select().from(vmidReservations).all();
104
+ const reservedVMIDs = new Set(
105
+ reservations.map((r: typeof vmidReservations.$inferSelect) => r.vmid),
106
+ );
107
+
108
+ // Find next available VMID starting from 2100
109
+ let candidateVMID =
110
+ allocations.length === 0
111
+ ? 2100
112
+ : Math.max(...allocations.map((a: typeof ipAllocations.$inferSelect) => a.vmid)) + 1;
113
+
114
+ // Keep incrementing until we find an unreserved VMID
115
+ while (allocatedVMIDs.has(candidateVMID) || reservedVMIDs.has(candidateVMID)) {
116
+ candidateVMID++;
117
+ }
118
+
119
+ return candidateVMID;
120
+ }
121
+
122
+ /**
123
+ * Allocate next available IP from subnet
124
+ * Skips allocated IPs and reserved IPs
125
+ * Reserves .1-.9 for infrastructure
126
+ */
127
+ export async function allocateIPFromSubnet(
128
+ subnet: string,
129
+ zone: IpamZone,
130
+ db: DbOrTransaction,
131
+ ): Promise<string> {
132
+ // Get all allocated IPs in this subnet
133
+ const allocations = await db
134
+ .select()
135
+ .from(ipAllocations)
136
+ .where(eq(ipAllocations.zone, zone))
137
+ .all();
138
+ const allocatedIPs = new Set(
139
+ allocations.map((a: typeof ipAllocations.$inferSelect) => a.containerIp),
140
+ );
141
+
142
+ // Get all reservations in this zone
143
+ const reservations = await db
144
+ .select()
145
+ .from(ipReservations)
146
+ .where(eq(ipReservations.zone, zone))
147
+ .all();
148
+
149
+ // Find first available IP
150
+ for (const candidateIP of generateIPsInSubnet(subnet)) {
151
+ // Skip if already allocated
152
+ if (allocatedIPs.has(candidateIP)) {
153
+ continue;
154
+ }
155
+
156
+ // Skip if reserved
157
+ const isReserved = reservations.some((r: typeof ipReservations.$inferSelect) =>
158
+ isIPInRange(stripCIDR(candidateIP), r.ipStart, r.ipEnd),
159
+ );
160
+
161
+ if (isReserved) {
162
+ continue;
163
+ }
164
+
165
+ // Found available IP
166
+ return candidateIP;
167
+ }
168
+
169
+ throw new Error(`No available IPs in subnet ${subnet} (zone: ${zone})`);
170
+ }
171
+
172
+ /**
173
+ * Deallocate resources for a module
174
+ * Removes VMID and IP allocation
175
+ */
176
+ export async function deallocateResources(moduleId: string, db: DbOrTransaction): Promise<void> {
177
+ await db.delete(ipAllocations).where(eq(ipAllocations.moduleId, moduleId)).run();
178
+ }
179
+
180
+ /**
181
+ * Get allocation for a module
182
+ */
183
+ export async function getAllocation(
184
+ moduleId: string,
185
+ db: DbOrTransaction,
186
+ ): Promise<IPAMAllocation | null> {
187
+ const allocations = await db
188
+ .select()
189
+ .from(ipAllocations)
190
+ .where(eq(ipAllocations.moduleId, moduleId))
191
+ .all();
192
+
193
+ if (allocations.length === 0) {
194
+ return null;
195
+ }
196
+
197
+ const allocation = allocations[0];
198
+ return {
199
+ vmid: allocation.vmid,
200
+ containerIp: allocation.containerIp,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Reserve an IP or IP range
206
+ * Prevents IPAM from allocating reserved IPs
207
+ */
208
+ export async function reserveIP(
209
+ ipStart: string,
210
+ zone: IpamZone,
211
+ reason: string,
212
+ ipEnd: string | null,
213
+ db: DbOrTransaction,
214
+ ): Promise<void> {
215
+ const newReservation: NewIpReservation = {
216
+ ipStart: stripCIDR(ipStart),
217
+ ipEnd: ipEnd ? stripCIDR(ipEnd) : null,
218
+ zone,
219
+ reason,
220
+ };
221
+
222
+ await db.insert(ipReservations).values(newReservation).run();
223
+ }
224
+
225
+ /**
226
+ * Remove IP reservation
227
+ */
228
+ export async function unreserveIP(
229
+ ipStart: string,
230
+ zone: IpamZone,
231
+ db: DbOrTransaction,
232
+ ): Promise<void> {
233
+ const ip = stripCIDR(ipStart);
234
+ await db
235
+ .delete(ipReservations)
236
+ .where(and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone)));
237
+ }
238
+
239
+ /**
240
+ * List all IP reservations
241
+ */
242
+ export async function listReservations(db: DbOrTransaction) {
243
+ return await db.select().from(ipReservations).all();
244
+ }
245
+
246
+ /**
247
+ * Get all allocated IPs in a subnet (for debugging/status)
248
+ */
249
+ export async function getAllocatedIPsInSubnet(
250
+ subnet: string,
251
+ db: DbOrTransaction,
252
+ ): Promise<string[]> {
253
+ const allocations = await db.select().from(ipAllocations).all();
254
+
255
+ return allocations
256
+ .filter((a: typeof ipAllocations.$inferSelect) => isInSubnet(a.containerIp, subnet))
257
+ .map((a: typeof ipAllocations.$inferSelect) => a.containerIp);
258
+ }
259
+
260
+ /**
261
+ * Check if a specific IP is available
262
+ */
263
+ export async function isIPAvailable(
264
+ ip: string,
265
+ zone: IpamZone,
266
+ db: DbOrTransaction,
267
+ ): Promise<boolean> {
268
+ // Check if allocated
269
+ const allocations = await db
270
+ .select()
271
+ .from(ipAllocations)
272
+ .where(eq(ipAllocations.containerIp, ip))
273
+ .all();
274
+
275
+ if (allocations.length > 0) {
276
+ return false;
277
+ }
278
+
279
+ // Check if reserved
280
+ const reservations = await db
281
+ .select()
282
+ .from(ipReservations)
283
+ .where(eq(ipReservations.zone, zone))
284
+ .all();
285
+
286
+ const isReserved = reservations.some((r: typeof ipReservations.$inferSelect) =>
287
+ isIPInRange(stripCIDR(ip), r.ipStart, r.ipEnd),
288
+ );
289
+
290
+ return !isReserved;
291
+ }
292
+
293
+ /**
294
+ * Reserve a VMID
295
+ * Prevents IPAM from allocating reserved VMIDs
296
+ */
297
+ export async function reserveVMID(
298
+ vmid: number,
299
+ reason: string,
300
+ db: DbOrTransaction,
301
+ ): Promise<void> {
302
+ // Check if VMID is already allocated
303
+ const allocations = await db
304
+ .select()
305
+ .from(ipAllocations)
306
+ .where(eq(ipAllocations.vmid, vmid))
307
+ .all();
308
+
309
+ if (allocations.length > 0) {
310
+ throw new Error(`VMID ${vmid} is already allocated to module ${allocations[0].moduleId}`);
311
+ }
312
+
313
+ // Check if VMID is already reserved
314
+ const reservations = await db
315
+ .select()
316
+ .from(vmidReservations)
317
+ .where(eq(vmidReservations.vmid, vmid))
318
+ .all();
319
+
320
+ if (reservations.length > 0) {
321
+ throw new Error(`VMID ${vmid} is already reserved: ${reservations[0].reason}`);
322
+ }
323
+
324
+ const newReservation: NewVmidReservation = {
325
+ vmid,
326
+ reason,
327
+ };
328
+
329
+ await db.insert(vmidReservations).values(newReservation).run();
330
+ }
331
+
332
+ /**
333
+ * Remove VMID reservation
334
+ */
335
+ export async function unreserveVMID(vmid: number, db: DbOrTransaction): Promise<void> {
336
+ await db.delete(vmidReservations).where(eq(vmidReservations.vmid, vmid)).run();
337
+ }
338
+
339
+ /**
340
+ * List all VMID reservations
341
+ */
342
+ export async function listVMIDReservations(db: DbOrTransaction) {
343
+ return await db.select().from(vmidReservations).all();
344
+ }
345
+
346
+ /**
347
+ * Check if a specific VMID is available
348
+ */
349
+ export async function isVMIDAvailable(vmid: number, db: DbOrTransaction): Promise<boolean> {
350
+ // Check if allocated
351
+ const allocations = await db
352
+ .select()
353
+ .from(ipAllocations)
354
+ .where(eq(ipAllocations.vmid, vmid))
355
+ .all();
356
+
357
+ if (allocations.length > 0) {
358
+ return false;
359
+ }
360
+
361
+ // Check if reserved
362
+ const reservations = await db
363
+ .select()
364
+ .from(vmidReservations)
365
+ .where(eq(vmidReservations.vmid, vmid))
366
+ .all();
367
+
368
+ return reservations.length === 0;
369
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * IPAM Auto-Allocator Tests
3
+ */
4
+
5
+ import { Database } from 'bun:sqlite';
6
+ import { beforeEach, describe, expect, test } from 'bun:test';
7
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
8
+ import type { DbClient } from '../db/client';
9
+ import * as schema from '../db/schema';
10
+ import { allocateForModule, deallocateForModule, getAllocation } from './auto-allocator';
11
+
12
+ describe('IPAM Auto-Allocator', () => {
13
+ let db: Database;
14
+ let drizzleDb: DbClient;
15
+
16
+ beforeEach(() => {
17
+ // Create in-memory database for testing
18
+ db = new Database(':memory:');
19
+ drizzleDb = drizzle(db, { schema });
20
+
21
+ // Create required tables
22
+ db.exec(`
23
+ CREATE TABLE ip_allocations (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ module_id TEXT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
26
+ vmid INTEGER NOT NULL UNIQUE,
27
+ container_ip TEXT NOT NULL UNIQUE,
28
+ zone TEXT NOT NULL,
29
+ allocated_at INTEGER NOT NULL
30
+ );
31
+
32
+ CREATE TABLE vmid_reservations (
33
+ vmid INTEGER PRIMARY KEY NOT NULL,
34
+ reason TEXT,
35
+ created_at TEXT NOT NULL
36
+ );
37
+
38
+ CREATE TABLE ip_reservations (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ zone TEXT NOT NULL,
41
+ ip_start TEXT NOT NULL,
42
+ ip_end TEXT,
43
+ reason TEXT,
44
+ created_at TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE system_config (
48
+ key TEXT PRIMARY KEY NOT NULL,
49
+ value TEXT NOT NULL,
50
+ created_at TEXT NOT NULL,
51
+ updated_at TEXT NOT NULL
52
+ );
53
+ `);
54
+
55
+ // Insert test zone subnet configuration
56
+ db.prepare(
57
+ `INSERT INTO system_config (key, value, created_at, updated_at)
58
+ VALUES (?, ?, datetime('now'), datetime('now'))`,
59
+ ).run('network.dmz.subnet', '10.0.10.0/24');
60
+
61
+ db.prepare(
62
+ `INSERT INTO system_config (key, value, created_at, updated_at)
63
+ VALUES (?, ?, datetime('now'), datetime('now'))`,
64
+ ).run('network.app.subnet', '10.0.20.0/24');
65
+ });
66
+
67
+ describe('allocateForModule', () => {
68
+ test('allocates first VMID starting at 200', async () => {
69
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
70
+
71
+ expect(allocation.vmid).toBe(200);
72
+ expect(allocation.moduleId).toBe('test-module');
73
+ expect(allocation.zone).toBe('dmz');
74
+ });
75
+
76
+ test('allocates first IP starting at .10 in CIDR format', async () => {
77
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
78
+
79
+ expect(allocation.containerIp).toBe('10.0.10.10/24');
80
+ });
81
+
82
+ test('allocates sequential VMIDs for multiple modules', async () => {
83
+ const alloc1 = await allocateForModule('module-1', 'dmz', drizzleDb, db);
84
+ const alloc2 = await allocateForModule('module-2', 'dmz', drizzleDb, db);
85
+ const alloc3 = await allocateForModule('module-3', 'app', drizzleDb, db);
86
+
87
+ expect(alloc1.vmid).toBe(200);
88
+ expect(alloc2.vmid).toBe(201);
89
+ expect(alloc3.vmid).toBe(202);
90
+ });
91
+
92
+ test('allocates sequential IPs within same zone', async () => {
93
+ const alloc1 = await allocateForModule('module-1', 'dmz', drizzleDb, db);
94
+ const alloc2 = await allocateForModule('module-2', 'dmz', drizzleDb, db);
95
+
96
+ expect(alloc1.containerIp).toBe('10.0.10.10/24');
97
+ expect(alloc2.containerIp).toBe('10.0.10.11/24');
98
+ });
99
+
100
+ test('allocates IPs from different zone subnets', async () => {
101
+ const dmzAlloc = await allocateForModule('module-dmz', 'dmz', drizzleDb, db);
102
+ const appAlloc = await allocateForModule('module-app', 'app', drizzleDb, db);
103
+
104
+ expect(dmzAlloc.containerIp).toBe('10.0.10.10/24');
105
+ expect(appAlloc.containerIp).toBe('10.0.20.10/24');
106
+ });
107
+
108
+ test('skips reserved VMIDs', async () => {
109
+ // Reserve VMID 200
110
+ db.prepare(
111
+ `INSERT INTO vmid_reservations (vmid, reason, created_at)
112
+ VALUES (?, ?, datetime('now'))`,
113
+ ).run(200, 'Test reservation');
114
+
115
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
116
+
117
+ expect(allocation.vmid).toBe(201);
118
+ });
119
+
120
+ test('skips reserved IP addresses', async () => {
121
+ // Reserve IP 10.0.10.10
122
+ db.prepare(
123
+ `INSERT INTO ip_reservations (zone, ip_start, ip_end, reason, created_at)
124
+ VALUES (?, ?, ?, ?, datetime('now'))`,
125
+ ).run('dmz', '10.0.10.10', null, 'Test reservation');
126
+
127
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
128
+
129
+ expect(allocation.containerIp).toBe('10.0.10.11/24');
130
+ });
131
+
132
+ test('skips reserved IP ranges', async () => {
133
+ // Reserve range 10.0.10.10-10.0.10.12
134
+ db.prepare(
135
+ `INSERT INTO ip_reservations (zone, ip_start, ip_end, reason, created_at)
136
+ VALUES (?, ?, ?, ?, datetime('now'))`,
137
+ ).run('dmz', '10.0.10.10', '10.0.10.12', 'Test range reservation');
138
+
139
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
140
+
141
+ expect(allocation.containerIp).toBe('10.0.10.13/24');
142
+ });
143
+
144
+ test('reuses existing allocation for same module', async () => {
145
+ const alloc1 = await allocateForModule('test-module', 'dmz', drizzleDb, db);
146
+ const alloc2 = await allocateForModule('test-module', 'dmz', drizzleDb, db);
147
+
148
+ expect(alloc2.vmid).toBe(alloc1.vmid);
149
+ expect(alloc2.containerIp).toBe(alloc1.containerIp);
150
+ });
151
+
152
+ test('throws error when zone subnet not configured', async () => {
153
+ await expect(allocateForModule('test-module', 'invalid-zone', drizzleDb, db)).rejects.toThrow(
154
+ 'Zone subnet not configured',
155
+ );
156
+ });
157
+ });
158
+
159
+ describe('getAllocation', () => {
160
+ test('returns null when no allocation exists', () => {
161
+ const allocation = getAllocation('nonexistent-module', drizzleDb);
162
+
163
+ expect(allocation).toBeNull();
164
+ });
165
+
166
+ test('returns existing allocation', async () => {
167
+ await allocateForModule('test-module', 'dmz', drizzleDb, db);
168
+
169
+ const allocation = getAllocation('test-module', drizzleDb);
170
+
171
+ expect(allocation).not.toBeNull();
172
+ expect(allocation?.moduleId).toBe('test-module');
173
+ expect(allocation?.vmid).toBe(200);
174
+ expect(allocation?.containerIp).toBe('10.0.10.10/24');
175
+ expect(allocation?.zone).toBe('dmz');
176
+ });
177
+ });
178
+
179
+ describe('deallocateForModule', () => {
180
+ test('removes allocation for module', async () => {
181
+ await allocateForModule('test-module', 'dmz', drizzleDb, db);
182
+
183
+ const removed = await deallocateForModule('test-module', drizzleDb);
184
+
185
+ expect(removed).toBe(true);
186
+
187
+ const allocation = getAllocation('test-module', drizzleDb);
188
+ expect(allocation).toBeNull();
189
+ });
190
+
191
+ test('returns false when no allocation exists', async () => {
192
+ const removed = await deallocateForModule('nonexistent-module', drizzleDb);
193
+
194
+ expect(removed).toBe(false);
195
+ });
196
+
197
+ test('makes VMID and IP available for reallocation', async () => {
198
+ await allocateForModule('module-1', 'dmz', drizzleDb, db);
199
+ await allocateForModule('module-2', 'dmz', drizzleDb, db);
200
+
201
+ // Remove first module
202
+ await deallocateForModule('module-1', drizzleDb);
203
+
204
+ // Allocate new module - should get VMID 200 (first available)
205
+ const newAlloc = await allocateForModule('module-3', 'dmz', drizzleDb, db);
206
+
207
+ expect(newAlloc.vmid).toBe(200);
208
+ expect(newAlloc.containerIp).toBe('10.0.10.10/24');
209
+ });
210
+ });
211
+
212
+ describe('VMID allocation edge cases', () => {
213
+ test('handles large VMID gaps with reservations', async () => {
214
+ // Reserve range 200-299
215
+ for (let i = 200; i < 300; i++) {
216
+ db.prepare(
217
+ `INSERT INTO vmid_reservations (vmid, reason, created_at)
218
+ VALUES (?, ?, datetime('now'))`,
219
+ ).run(i, 'Reserved');
220
+ }
221
+
222
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
223
+
224
+ expect(allocation.vmid).toBe(300);
225
+ });
226
+ });
227
+
228
+ describe('IP allocation edge cases', () => {
229
+ test('handles subnet with many allocations', async () => {
230
+ // Allocate first 10 IPs
231
+ for (let i = 0; i < 10; i++) {
232
+ await allocateForModule(`module-${i}`, 'dmz', drizzleDb, db);
233
+ }
234
+
235
+ const allocation = await allocateForModule('module-10', 'dmz', drizzleDb, db);
236
+
237
+ expect(allocation.containerIp).toBe('10.0.10.20/24'); // .10-.19 taken, .20 is next
238
+ });
239
+
240
+ test('skips infrastructure range (.1-.9)', async () => {
241
+ const allocation = await allocateForModule('test-module', 'dmz', drizzleDb, db);
242
+
243
+ // Should start at .10, not .1 or .2
244
+ expect(allocation.containerIp).toBe('10.0.10.10/24');
245
+ });
246
+ });
247
+ });