@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,298 @@
1
+ /**
2
+ * Unit tests for container service operations
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { eq } from 'drizzle-orm';
10
+ import { closeDb, createDbClient } from '../db/client';
11
+ import { runMigrations } from '../db/migrate';
12
+ import { containerServices } from '../db/schema';
13
+ import {
14
+ addContainerService,
15
+ getContainerService,
16
+ getContainerServiceByName,
17
+ getServiceCredentials,
18
+ listContainerServices,
19
+ removeContainerService,
20
+ } from './container-service';
21
+
22
+ describe('container-service', () => {
23
+ let testDbPath: string;
24
+ let testDir: string;
25
+
26
+ beforeEach(async () => {
27
+ // Create temp directory for test database
28
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
29
+ testDbPath = join(testDir, 'test.db');
30
+
31
+ // Set environment variable for database path
32
+ process.env.CELILO_DB_PATH = testDbPath;
33
+
34
+ // Initialize database and run migrations
35
+ await runMigrations(testDbPath);
36
+
37
+ // Create a dummy master key for encryption
38
+ const masterKeyPath = join(testDir, 'master.key');
39
+ process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
40
+ const fs = await import('node:fs/promises');
41
+ await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8');
42
+ });
43
+
44
+ afterEach(() => {
45
+ // Close database connection
46
+ closeDb();
47
+
48
+ // Clean up test directory
49
+ if (testDir) {
50
+ rmSync(testDir, { recursive: true, force: true });
51
+ }
52
+
53
+ // Clear environment variables
54
+ process.env.CELILO_DB_PATH = undefined;
55
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
56
+ });
57
+
58
+ describe('addContainerService', () => {
59
+ it('creates a Proxmox service', async () => {
60
+ const service = await addContainerService({
61
+ name: 'Test Proxmox',
62
+ providerName: 'proxmox',
63
+ zones: ['dmz', 'app', 'secure'],
64
+ providerConfig: {
65
+ default_target_node: 'pve',
66
+ lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
67
+ storage: 'local-lvm',
68
+ },
69
+ apiCredentials: {
70
+ api_url: 'https://proxmox.local:8006/api2/json',
71
+ api_token_id: 'root@pam!celilo',
72
+ api_token_secret: 'test-token-secret',
73
+ },
74
+ });
75
+
76
+ expect(service.id).toBeDefined();
77
+ expect(service.name).toBe('Test Proxmox');
78
+ expect(service.providerName).toBe('proxmox');
79
+ expect(service.zones).toEqual(['dmz', 'app', 'secure']);
80
+ expect(service.apiCredentialsEncrypted).toBeDefined();
81
+ expect(service.createdAt).toBeInstanceOf(Date);
82
+ });
83
+
84
+ it('creates a Digital Ocean service', async () => {
85
+ const service = await addContainerService({
86
+ name: 'Digital Ocean NYC3',
87
+ providerName: 'digitalocean',
88
+ zones: ['external'],
89
+ providerConfig: {
90
+ region: 'nyc3',
91
+ default_size: 's-1vcpu-1gb',
92
+ default_image: 'ubuntu-22-04-x64',
93
+ },
94
+ apiCredentials: {
95
+ api_token: 'dop_test_token_12345',
96
+ },
97
+ });
98
+
99
+ expect(service.id).toBeDefined();
100
+ expect(service.name).toBe('Digital Ocean NYC3');
101
+ expect(service.providerName).toBe('digitalocean');
102
+ expect(service.zones).toEqual(['external']);
103
+ });
104
+
105
+ it('encrypts API credentials', async () => {
106
+ const service = await addContainerService({
107
+ name: 'Test Service',
108
+ providerName: 'proxmox',
109
+ zones: ['dmz'],
110
+ providerConfig: {},
111
+ apiCredentials: {
112
+ api_url: 'https://test.local',
113
+ api_token_id: 'root@pam!celilo',
114
+ api_token_secret: 'secret-token-123',
115
+ },
116
+ });
117
+
118
+ // Verify credentials are encrypted in database
119
+ const db = createDbClient({ path: testDbPath });
120
+ const dbRecord = await db
121
+ .select()
122
+ .from(containerServices)
123
+ .where(eq(containerServices.id, service.id))
124
+ .get();
125
+
126
+ expect(dbRecord?.apiCredentialsEncrypted).toBeDefined();
127
+ expect(dbRecord?.apiCredentialsEncrypted).not.toContain('secret-token-123');
128
+ });
129
+ });
130
+
131
+ describe('getContainerService', () => {
132
+ it('retrieves service by ID', async () => {
133
+ const created = await addContainerService({
134
+ name: 'Test Service',
135
+ providerName: 'proxmox',
136
+ zones: ['dmz'],
137
+ providerConfig: {},
138
+ apiCredentials: { api_url: 'https://test' },
139
+ });
140
+
141
+ const retrieved = await getContainerService(created.id);
142
+
143
+ expect(retrieved).toBeDefined();
144
+ expect(retrieved?.id).toBe(created.id);
145
+ expect(retrieved?.name).toBe('Test Service');
146
+ });
147
+
148
+ it('returns null for non-existent service', async () => {
149
+ const result = await getContainerService('non-existent-id');
150
+ expect(result).toBeNull();
151
+ });
152
+ });
153
+
154
+ describe('getContainerServiceByName', () => {
155
+ it('retrieves service by name', async () => {
156
+ await addContainerService({
157
+ name: 'Unique Service Name',
158
+ providerName: 'proxmox',
159
+ zones: ['dmz'],
160
+ providerConfig: {},
161
+ apiCredentials: { api_url: 'https://test' },
162
+ });
163
+
164
+ const retrieved = await getContainerServiceByName('Unique Service Name');
165
+
166
+ expect(retrieved).toBeDefined();
167
+ expect(retrieved?.name).toBe('Unique Service Name');
168
+ });
169
+
170
+ it('returns null for non-existent service name', async () => {
171
+ const result = await getContainerServiceByName('Does Not Exist');
172
+ expect(result).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe('listContainerServices', () => {
177
+ it('returns empty array when no services', async () => {
178
+ const services = await listContainerServices();
179
+ expect(services).toEqual([]);
180
+ });
181
+
182
+ it('lists all services without filters', async () => {
183
+ await addContainerService({
184
+ name: 'Service 1',
185
+ providerName: 'proxmox',
186
+ zones: ['dmz'],
187
+ providerConfig: {},
188
+ apiCredentials: { api_url: 'https://test1' },
189
+ });
190
+
191
+ await addContainerService({
192
+ name: 'Service 2',
193
+ providerName: 'digitalocean',
194
+ zones: ['external'],
195
+ providerConfig: {},
196
+ apiCredentials: { api_token: 'test2' },
197
+ });
198
+
199
+ const services = await listContainerServices();
200
+ expect(services).toHaveLength(2);
201
+ });
202
+
203
+ it('filters services by zone', async () => {
204
+ await addContainerService({
205
+ name: 'Proxmox Internal',
206
+ providerName: 'proxmox',
207
+ zones: ['dmz', 'app', 'secure'],
208
+ providerConfig: {},
209
+ apiCredentials: { api_url: 'https://test1' },
210
+ });
211
+
212
+ await addContainerService({
213
+ name: 'Digital Ocean External',
214
+ providerName: 'digitalocean',
215
+ zones: ['external'],
216
+ providerConfig: {},
217
+ apiCredentials: { api_token: 'test2' },
218
+ });
219
+
220
+ const externalServices = await listContainerServices({
221
+ zones: ['external'],
222
+ });
223
+
224
+ expect(externalServices).toHaveLength(1);
225
+ expect(externalServices[0].name).toBe('Digital Ocean External');
226
+ });
227
+
228
+ it('returns services matching any zone in filter', async () => {
229
+ await addContainerService({
230
+ name: 'Multi-Zone Service',
231
+ providerName: 'proxmox',
232
+ zones: ['dmz', 'app'],
233
+ providerConfig: {},
234
+ apiCredentials: { api_url: 'https://test' },
235
+ });
236
+
237
+ const dmzServices = await listContainerServices({ zones: ['dmz'] });
238
+ const appServices = await listContainerServices({ zones: ['app'] });
239
+
240
+ expect(dmzServices).toHaveLength(1);
241
+ expect(appServices).toHaveLength(1);
242
+ });
243
+ });
244
+
245
+ describe('getServiceCredentials', () => {
246
+ it('decrypts and returns service credentials', async () => {
247
+ const service = await addContainerService({
248
+ name: 'Test Service',
249
+ providerName: 'proxmox',
250
+ zones: ['dmz'],
251
+ providerConfig: {},
252
+ apiCredentials: {
253
+ api_url: 'https://proxmox.local:8006',
254
+ api_token_id: 'root@pam!celilo',
255
+ api_token_secret: 'secret-token-123',
256
+ },
257
+ });
258
+
259
+ const credentials = await getServiceCredentials(service.id);
260
+
261
+ expect((credentials as { api_url: string }).api_url).toBe('https://proxmox.local:8006');
262
+ expect((credentials as { api_token_id: string }).api_token_id).toBe('root@pam!celilo');
263
+ expect((credentials as { api_token_secret: string }).api_token_secret).toBe(
264
+ 'secret-token-123',
265
+ );
266
+ });
267
+
268
+ it('throws error for non-existent service', async () => {
269
+ await expect(getServiceCredentials('non-existent-id')).rejects.toThrow(
270
+ /Container service not found/,
271
+ );
272
+ });
273
+ });
274
+
275
+ describe('removeContainerService', () => {
276
+ it('deletes service from database', async () => {
277
+ const service = await addContainerService({
278
+ name: 'Service To Delete',
279
+ providerName: 'proxmox',
280
+ zones: ['dmz'],
281
+ providerConfig: {},
282
+ apiCredentials: { api_url: 'https://test' },
283
+ });
284
+
285
+ await removeContainerService(service.id);
286
+
287
+ const retrieved = await getContainerService(service.id);
288
+ expect(retrieved).toBeNull();
289
+ });
290
+
291
+ it('does not throw when removing non-existent service', async () => {
292
+ // Should complete without error
293
+ await removeContainerService('non-existent-id');
294
+ // If we get here, no error was thrown
295
+ expect(true).toBe(true);
296
+ });
297
+ });
298
+ });
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { eq } from 'drizzle-orm';
3
+ import { z } from 'zod';
4
+ import { getDb } from '../db/client';
5
+ import { type NetworkZone, containerServices } from '../db/schema';
6
+ import { decryptSecret, encryptSecret } from '../secrets/encryption';
7
+ import { getOrCreateMasterKey } from '../secrets/master-key';
8
+ import type { ContainerService, TestResult } from '../types/infrastructure';
9
+ import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas';
10
+
11
+ /**
12
+ * Zod schemas for provider-specific credentials
13
+ * Validates external data from database storage (Rule 3.7)
14
+ */
15
+ const ProxmoxCredentialsSchema = z.object({
16
+ api_url: z.string().url(),
17
+ api_token_id: z.string().min(1),
18
+ api_token_secret: z.string().min(1),
19
+ });
20
+
21
+ const DigitalOceanCredentialsSchema = z.object({
22
+ api_token: z.string().min(1),
23
+ });
24
+
25
+ export type ProxmoxCredentials = z.infer<typeof ProxmoxCredentialsSchema>;
26
+ export type DigitalOceanCredentials = z.infer<typeof DigitalOceanCredentialsSchema>;
27
+ export type ServiceCredentials = ProxmoxCredentials | DigitalOceanCredentials;
28
+
29
+ /**
30
+ * Container service filters
31
+ */
32
+ export interface ContainerServiceFilters {
33
+ zones?: NetworkZone[];
34
+ }
35
+
36
+ /**
37
+ * Generate a service ID from a human-readable name
38
+ * Converts to kebab-case, removes special characters
39
+ */
40
+ function generateServiceId(name: string): string {
41
+ return name
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
44
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
45
+ }
46
+
47
+ /**
48
+ * Add a new container service
49
+ */
50
+ export async function addContainerService(
51
+ service: Omit<
52
+ ContainerService,
53
+ | 'id'
54
+ | 'serviceId'
55
+ | 'createdAt'
56
+ | 'updatedAt'
57
+ | 'apiCredentialsEncrypted'
58
+ | 'verified'
59
+ | 'verifiedAt'
60
+ | 'verificationError'
61
+ > & {
62
+ apiCredentials: Record<string, unknown>;
63
+ },
64
+ ): Promise<ContainerService> {
65
+ const db = getDb();
66
+ const id = randomUUID();
67
+ const serviceId = generateServiceId(service.name);
68
+ const now = new Date();
69
+
70
+ // Check if service ID already exists
71
+ const existing = await db
72
+ .select()
73
+ .from(containerServices)
74
+ .where(eq(containerServices.serviceId, serviceId))
75
+ .limit(1);
76
+
77
+ if (existing.length > 0) {
78
+ throw new Error(`Service ID '${serviceId}' already exists. Please choose a different name.`);
79
+ }
80
+
81
+ // Encrypt API credentials
82
+ const masterKey = await getOrCreateMasterKey();
83
+ const encrypted = encryptSecret(JSON.stringify(service.apiCredentials), masterKey);
84
+
85
+ const values = {
86
+ id,
87
+ serviceId,
88
+ name: service.name,
89
+ providerName: service.providerName,
90
+ zones: service.zones, // Drizzle auto-stringifies with mode: 'json'
91
+ apiCredentialsEncrypted: JSON.stringify(encrypted),
92
+ providerConfig: service.providerConfig, // Drizzle auto-stringifies with mode: 'json'
93
+ verified: false,
94
+ verifiedAt: null,
95
+ verificationError: null,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ };
99
+
100
+ await db.insert(containerServices).values(values);
101
+
102
+ return {
103
+ id,
104
+ serviceId,
105
+ name: service.name,
106
+ providerName: service.providerName,
107
+ zones: service.zones,
108
+ apiCredentialsEncrypted: JSON.stringify(encrypted),
109
+ providerConfig: service.providerConfig,
110
+ verified: false,
111
+ verifiedAt: null,
112
+ verificationError: null,
113
+ createdAt: now,
114
+ updatedAt: now,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Get container service by ID
120
+ */
121
+ export async function getContainerService(id: string): Promise<ContainerService | null> {
122
+ const db = getDb();
123
+
124
+ const result = await db
125
+ .select()
126
+ .from(containerServices)
127
+ .where(eq(containerServices.id, id))
128
+ .limit(1);
129
+
130
+ if (result.length === 0) {
131
+ return null;
132
+ }
133
+
134
+ const row = result[0];
135
+
136
+ return {
137
+ id: row.id,
138
+ serviceId: row.serviceId,
139
+ name: row.name,
140
+ providerName: row.providerName as ContainerService['providerName'],
141
+ zones: row.zones, // Drizzle auto-parses with mode: 'json'
142
+ apiCredentialsEncrypted: row.apiCredentialsEncrypted,
143
+ providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json'
144
+ verified: Boolean(row.verified),
145
+ verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null,
146
+ verificationError: row.verificationError,
147
+ createdAt: new Date(row.createdAt),
148
+ updatedAt: new Date(row.updatedAt),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Get container service by service ID (user-facing identifier)
154
+ */
155
+ export async function getContainerServiceByServiceId(
156
+ serviceId: string,
157
+ ): Promise<ContainerService | null> {
158
+ const db = getDb();
159
+
160
+ const result = await db
161
+ .select()
162
+ .from(containerServices)
163
+ .where(eq(containerServices.serviceId, serviceId))
164
+ .limit(1);
165
+
166
+ if (result.length === 0) {
167
+ return null;
168
+ }
169
+
170
+ const row = result[0];
171
+
172
+ return {
173
+ id: row.id,
174
+ serviceId: row.serviceId,
175
+ name: row.name,
176
+ providerName: row.providerName as ContainerService['providerName'],
177
+ zones: row.zones, // Drizzle auto-parses with mode: 'json'
178
+ apiCredentialsEncrypted: row.apiCredentialsEncrypted,
179
+ providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json'
180
+ verified: Boolean(row.verified),
181
+ verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null,
182
+ verificationError: row.verificationError,
183
+ createdAt: new Date(row.createdAt),
184
+ updatedAt: new Date(row.updatedAt),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Get container service by name
190
+ */
191
+ export async function getContainerServiceByName(name: string): Promise<ContainerService | null> {
192
+ const db = getDb();
193
+
194
+ const result = await db
195
+ .select()
196
+ .from(containerServices)
197
+ .where(eq(containerServices.name, name))
198
+ .limit(1);
199
+
200
+ if (result.length === 0) {
201
+ return null;
202
+ }
203
+
204
+ const row = result[0];
205
+
206
+ return {
207
+ id: row.id,
208
+ serviceId: row.serviceId,
209
+ name: row.name,
210
+ providerName: row.providerName as ContainerService['providerName'],
211
+ zones: row.zones, // Drizzle auto-parses with mode: 'json'
212
+ apiCredentialsEncrypted: row.apiCredentialsEncrypted,
213
+ providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json'
214
+ verified: Boolean(row.verified),
215
+ verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null,
216
+ verificationError: row.verificationError,
217
+ createdAt: new Date(row.createdAt),
218
+ updatedAt: new Date(row.updatedAt),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Get decrypted API credentials for a service
224
+ * Returns validated, typed credentials based on provider (Rule 3.7)
225
+ */
226
+ export async function getServiceCredentials(serviceId: string): Promise<ServiceCredentials> {
227
+ const service = await getContainerService(serviceId);
228
+ if (!service) {
229
+ throw new Error(`Container service not found: ${serviceId}`);
230
+ }
231
+
232
+ const masterKey = await getOrCreateMasterKey();
233
+ const encrypted = parseJsonWithValidation(
234
+ service.apiCredentialsEncrypted,
235
+ EncryptionEnvelopeSchema,
236
+ 'service credentials encryption envelope',
237
+ );
238
+ const decrypted = decryptSecret(encrypted, masterKey);
239
+ const parsed = JSON.parse(decrypted);
240
+
241
+ // Validate based on provider type
242
+ if (service.providerName === 'proxmox') {
243
+ return ProxmoxCredentialsSchema.parse(parsed);
244
+ }
245
+ if (service.providerName === 'digitalocean') {
246
+ return DigitalOceanCredentialsSchema.parse(parsed);
247
+ }
248
+
249
+ throw new Error(
250
+ `Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`,
251
+ );
252
+ }
253
+
254
+ /**
255
+ * List container services with optional filters
256
+ */
257
+ export async function listContainerServices(
258
+ filters?: ContainerServiceFilters,
259
+ ): Promise<ContainerService[]> {
260
+ const db = getDb();
261
+
262
+ const results = await db.select().from(containerServices);
263
+
264
+ let services = results.map((row) => ({
265
+ id: row.id,
266
+ serviceId: row.serviceId,
267
+ name: row.name,
268
+ providerName: row.providerName as ContainerService['providerName'],
269
+ zones: row.zones, // Drizzle auto-parses with mode: 'json'
270
+ apiCredentialsEncrypted: row.apiCredentialsEncrypted,
271
+ providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json'
272
+ verified: Boolean(row.verified),
273
+ verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null,
274
+ verificationError: row.verificationError,
275
+ createdAt: new Date(row.createdAt),
276
+ updatedAt: new Date(row.updatedAt),
277
+ }));
278
+
279
+ // Apply zone filter
280
+ if (filters?.zones && filters.zones.length > 0) {
281
+ services = services.filter((service) =>
282
+ filters.zones?.some((zone) => service.zones.includes(zone)),
283
+ );
284
+ }
285
+
286
+ return services;
287
+ }
288
+
289
+ /**
290
+ * Remove a container service
291
+ */
292
+ /**
293
+ * Update provider configuration for a container service
294
+ */
295
+ export async function updateServiceProviderConfig(
296
+ id: string,
297
+ providerConfig: Record<string, unknown>,
298
+ ): Promise<void> {
299
+ const db = getDb();
300
+ await db
301
+ .update(containerServices)
302
+ .set({
303
+ providerConfig,
304
+ updatedAt: new Date(),
305
+ })
306
+ .where(eq(containerServices.id, id));
307
+ }
308
+
309
+ export async function removeContainerService(id: string): Promise<void> {
310
+ const db = getDb();
311
+
312
+ await db.delete(containerServices).where(eq(containerServices.id, id));
313
+ }
314
+
315
+ /**
316
+ * Test connection to a container service
317
+ * Validates credentials and connectivity for the provider
318
+ */
319
+ export async function testConnection(service: ContainerService): Promise<TestResult> {
320
+ const credentials = await getServiceCredentials(service.id);
321
+
322
+ if (service.providerName === 'proxmox') {
323
+ const { testProxmoxConnection } = await import('../api-clients/proxmox');
324
+ const proxmoxCreds = credentials as ProxmoxCredentials;
325
+
326
+ // Cast providerConfig to expected shape
327
+ const providerConfig = service.providerConfig as {
328
+ default_target_node: string;
329
+ lxc_template: string;
330
+ storage: string;
331
+ };
332
+
333
+ return testProxmoxConnection(
334
+ {
335
+ api_url: proxmoxCreds.api_url,
336
+ api_token_id: proxmoxCreds.api_token_id,
337
+ api_token_secret: proxmoxCreds.api_token_secret,
338
+ },
339
+ providerConfig,
340
+ );
341
+ }
342
+
343
+ if (service.providerName === 'digitalocean') {
344
+ const { testDigitalOceanConnection } = await import('../api-clients/digitalocean');
345
+ const doCreds = credentials as DigitalOceanCredentials;
346
+ return testDigitalOceanConnection({
347
+ api_token: doCreds.api_token,
348
+ });
349
+ }
350
+
351
+ return {
352
+ success: false,
353
+ message: `Connection test not implemented for provider: ${service.providerName}`,
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Update verification status for a container service
359
+ */
360
+ export async function updateVerificationStatus(
361
+ serviceId: string,
362
+ testResult: TestResult,
363
+ ): Promise<void> {
364
+ const db = getDb();
365
+ const now = new Date();
366
+
367
+ await db
368
+ .update(containerServices)
369
+ .set({
370
+ verified: testResult.success,
371
+ verifiedAt: testResult.success ? now : null,
372
+ verificationError: testResult.success ? null : testResult.message || 'Connection test failed',
373
+ updatedAt: now,
374
+ })
375
+ .where(eq(containerServices.id, serviceId));
376
+ }
377
+
378
+ /**
379
+ * Verify a container service and update its status
380
+ * Returns the updated service
381
+ */
382
+ export async function verifyContainerService(serviceId: string): Promise<{
383
+ service: ContainerService;
384
+ testResult: TestResult;
385
+ }> {
386
+ const service = await getContainerService(serviceId);
387
+ if (!service) {
388
+ throw new Error(`Container service not found: ${serviceId}`);
389
+ }
390
+
391
+ const testResult = await testConnection(service);
392
+ await updateVerificationStatus(serviceId, testResult);
393
+
394
+ // Fetch updated service
395
+ const updatedService = await getContainerService(serviceId);
396
+ if (!updatedService) {
397
+ throw new Error(`Failed to fetch updated service: ${serviceId}`);
398
+ }
399
+
400
+ return { service: updatedService, testResult };
401
+ }