iec-builder 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 (337) hide show
  1. package/.claude/settings.local.json +111 -0
  2. package/.iec.yaml +5 -0
  3. package/CLAUDE.md +174 -0
  4. package/Dockerfile +34 -0
  5. package/README.md +84 -0
  6. package/catalog-info.yaml +11 -0
  7. package/dist/config/env.d.ts +219 -0
  8. package/dist/config/env.d.ts.map +1 -0
  9. package/dist/config/env.js +89 -0
  10. package/dist/config/env.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +148 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/middleware/auth.d.ts +43 -0
  16. package/dist/middleware/auth.d.ts.map +1 -0
  17. package/dist/middleware/auth.js +217 -0
  18. package/dist/middleware/auth.js.map +1 -0
  19. package/dist/middleware/org-access.d.ts +28 -0
  20. package/dist/middleware/org-access.d.ts.map +1 -0
  21. package/dist/middleware/org-access.js +102 -0
  22. package/dist/middleware/org-access.js.map +1 -0
  23. package/dist/models/types.d.ts +254 -0
  24. package/dist/models/types.d.ts.map +1 -0
  25. package/dist/models/types.js +2 -0
  26. package/dist/models/types.js.map +1 -0
  27. package/dist/routes/ai.d.ts +2 -0
  28. package/dist/routes/ai.d.ts.map +1 -0
  29. package/dist/routes/ai.js +77 -0
  30. package/dist/routes/ai.js.map +1 -0
  31. package/dist/routes/audit.d.ts +2 -0
  32. package/dist/routes/audit.d.ts.map +1 -0
  33. package/dist/routes/audit.js +102 -0
  34. package/dist/routes/audit.js.map +1 -0
  35. package/dist/routes/builds.d.ts +2 -0
  36. package/dist/routes/builds.d.ts.map +1 -0
  37. package/dist/routes/builds.js +262 -0
  38. package/dist/routes/builds.js.map +1 -0
  39. package/dist/routes/cluster.d.ts +2 -0
  40. package/dist/routes/cluster.d.ts.map +1 -0
  41. package/dist/routes/cluster.js +181 -0
  42. package/dist/routes/cluster.js.map +1 -0
  43. package/dist/routes/config.d.ts +2 -0
  44. package/dist/routes/config.d.ts.map +1 -0
  45. package/dist/routes/config.js +291 -0
  46. package/dist/routes/config.js.map +1 -0
  47. package/dist/routes/databases.d.ts +2 -0
  48. package/dist/routes/databases.d.ts.map +1 -0
  49. package/dist/routes/databases.js +161 -0
  50. package/dist/routes/databases.js.map +1 -0
  51. package/dist/routes/db-whitelist.d.ts +2 -0
  52. package/dist/routes/db-whitelist.d.ts.map +1 -0
  53. package/dist/routes/db-whitelist.js +148 -0
  54. package/dist/routes/db-whitelist.js.map +1 -0
  55. package/dist/routes/domains.d.ts +2 -0
  56. package/dist/routes/domains.d.ts.map +1 -0
  57. package/dist/routes/domains.js +449 -0
  58. package/dist/routes/domains.js.map +1 -0
  59. package/dist/routes/oauth.d.ts +2 -0
  60. package/dist/routes/oauth.d.ts.map +1 -0
  61. package/dist/routes/oauth.js +180 -0
  62. package/dist/routes/oauth.js.map +1 -0
  63. package/dist/routes/observability.d.ts +2 -0
  64. package/dist/routes/observability.d.ts.map +1 -0
  65. package/dist/routes/observability.js +167 -0
  66. package/dist/routes/observability.js.map +1 -0
  67. package/dist/routes/orgs.d.ts +2 -0
  68. package/dist/routes/orgs.d.ts.map +1 -0
  69. package/dist/routes/orgs.js +270 -0
  70. package/dist/routes/orgs.js.map +1 -0
  71. package/dist/routes/platform.d.ts +2 -0
  72. package/dist/routes/platform.d.ts.map +1 -0
  73. package/dist/routes/platform.js +107 -0
  74. package/dist/routes/platform.js.map +1 -0
  75. package/dist/routes/push.d.ts +2 -0
  76. package/dist/routes/push.d.ts.map +1 -0
  77. package/dist/routes/push.js +233 -0
  78. package/dist/routes/push.js.map +1 -0
  79. package/dist/routes/rotation.d.ts +3 -0
  80. package/dist/routes/rotation.d.ts.map +1 -0
  81. package/dist/routes/rotation.js +154 -0
  82. package/dist/routes/rotation.js.map +1 -0
  83. package/dist/routes/services.d.ts +2 -0
  84. package/dist/routes/services.d.ts.map +1 -0
  85. package/dist/routes/services.js +246 -0
  86. package/dist/routes/services.js.map +1 -0
  87. package/dist/routes/storage.d.ts +2 -0
  88. package/dist/routes/storage.d.ts.map +1 -0
  89. package/dist/routes/storage.js +118 -0
  90. package/dist/routes/storage.js.map +1 -0
  91. package/dist/routes/users.d.ts +2 -0
  92. package/dist/routes/users.d.ts.map +1 -0
  93. package/dist/routes/users.js +183 -0
  94. package/dist/routes/users.js.map +1 -0
  95. package/dist/routes/versions.d.ts +2 -0
  96. package/dist/routes/versions.d.ts.map +1 -0
  97. package/dist/routes/versions.js +195 -0
  98. package/dist/routes/versions.js.map +1 -0
  99. package/dist/routes/webhooks.d.ts +2 -0
  100. package/dist/routes/webhooks.d.ts.map +1 -0
  101. package/dist/routes/webhooks.js +334 -0
  102. package/dist/routes/webhooks.js.map +1 -0
  103. package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts +2 -0
  104. package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts.map +1 -0
  105. package/dist/services/__tests__/deploy-pipeline.integration.test.js +482 -0
  106. package/dist/services/__tests__/deploy-pipeline.integration.test.js.map +1 -0
  107. package/dist/services/bio-client.d.ts +68 -0
  108. package/dist/services/bio-client.d.ts.map +1 -0
  109. package/dist/services/bio-client.js +110 -0
  110. package/dist/services/bio-client.js.map +1 -0
  111. package/dist/services/build-queue.d.ts +7 -0
  112. package/dist/services/build-queue.d.ts.map +1 -0
  113. package/dist/services/build-queue.js +114 -0
  114. package/dist/services/build-queue.js.map +1 -0
  115. package/dist/services/builder.d.ts +7 -0
  116. package/dist/services/builder.d.ts.map +1 -0
  117. package/dist/services/builder.js +1384 -0
  118. package/dist/services/builder.js.map +1 -0
  119. package/dist/services/catalog.d.ts +177 -0
  120. package/dist/services/catalog.d.ts.map +1 -0
  121. package/dist/services/catalog.js +805 -0
  122. package/dist/services/catalog.js.map +1 -0
  123. package/dist/services/catalog.test.d.ts +2 -0
  124. package/dist/services/catalog.test.d.ts.map +1 -0
  125. package/dist/services/catalog.test.js +467 -0
  126. package/dist/services/catalog.test.js.map +1 -0
  127. package/dist/services/cloudflare.d.ts +43 -0
  128. package/dist/services/cloudflare.d.ts.map +1 -0
  129. package/dist/services/cloudflare.js +182 -0
  130. package/dist/services/cloudflare.js.map +1 -0
  131. package/dist/services/config-validator.d.ts +28 -0
  132. package/dist/services/config-validator.d.ts.map +1 -0
  133. package/dist/services/config-validator.js +68 -0
  134. package/dist/services/config-validator.js.map +1 -0
  135. package/dist/services/config-validator.test.d.ts +2 -0
  136. package/dist/services/config-validator.test.d.ts.map +1 -0
  137. package/dist/services/config-validator.test.js +151 -0
  138. package/dist/services/config-validator.test.js.map +1 -0
  139. package/dist/services/crypto.d.ts +19 -0
  140. package/dist/services/crypto.d.ts.map +1 -0
  141. package/dist/services/crypto.js +63 -0
  142. package/dist/services/crypto.js.map +1 -0
  143. package/dist/services/database.d.ts +26 -0
  144. package/dist/services/database.d.ts.map +1 -0
  145. package/dist/services/database.js +100 -0
  146. package/dist/services/database.js.map +1 -0
  147. package/dist/services/db-credential-manager.d.ts +73 -0
  148. package/dist/services/db-credential-manager.d.ts.map +1 -0
  149. package/dist/services/db-credential-manager.js +342 -0
  150. package/dist/services/db-credential-manager.js.map +1 -0
  151. package/dist/services/db-provisioner.d.ts +57 -0
  152. package/dist/services/db-provisioner.d.ts.map +1 -0
  153. package/dist/services/db-provisioner.js +400 -0
  154. package/dist/services/db-provisioner.js.map +1 -0
  155. package/dist/services/db-provisioner.test.d.ts +2 -0
  156. package/dist/services/db-provisioner.test.d.ts.map +1 -0
  157. package/dist/services/db-provisioner.test.js +141 -0
  158. package/dist/services/db-provisioner.test.js.map +1 -0
  159. package/dist/services/db-whitelist.d.ts +58 -0
  160. package/dist/services/db-whitelist.d.ts.map +1 -0
  161. package/dist/services/db-whitelist.js +379 -0
  162. package/dist/services/db-whitelist.js.map +1 -0
  163. package/dist/services/dependency-resolver.d.ts +58 -0
  164. package/dist/services/dependency-resolver.d.ts.map +1 -0
  165. package/dist/services/dependency-resolver.js +180 -0
  166. package/dist/services/dependency-resolver.js.map +1 -0
  167. package/dist/services/dependency-resolver.test.d.ts +2 -0
  168. package/dist/services/dependency-resolver.test.d.ts.map +1 -0
  169. package/dist/services/dependency-resolver.test.js +195 -0
  170. package/dist/services/dependency-resolver.test.js.map +1 -0
  171. package/dist/services/deploy-gate.d.ts +19 -0
  172. package/dist/services/deploy-gate.d.ts.map +1 -0
  173. package/dist/services/deploy-gate.js +56 -0
  174. package/dist/services/deploy-gate.js.map +1 -0
  175. package/dist/services/deploy-gate.test.d.ts +2 -0
  176. package/dist/services/deploy-gate.test.d.ts.map +1 -0
  177. package/dist/services/deploy-gate.test.js +199 -0
  178. package/dist/services/deploy-gate.test.js.map +1 -0
  179. package/dist/services/dockerfile-generator.d.ts +31 -0
  180. package/dist/services/dockerfile-generator.d.ts.map +1 -0
  181. package/dist/services/dockerfile-generator.js +544 -0
  182. package/dist/services/dockerfile-generator.js.map +1 -0
  183. package/dist/services/dockerfile-generator.test.d.ts +2 -0
  184. package/dist/services/dockerfile-generator.test.d.ts.map +1 -0
  185. package/dist/services/dockerfile-generator.test.js +144 -0
  186. package/dist/services/dockerfile-generator.test.js.map +1 -0
  187. package/dist/services/forgejo.d.ts +58 -0
  188. package/dist/services/forgejo.d.ts.map +1 -0
  189. package/dist/services/forgejo.js +131 -0
  190. package/dist/services/forgejo.js.map +1 -0
  191. package/dist/services/koko.d.ts +153 -0
  192. package/dist/services/koko.d.ts.map +1 -0
  193. package/dist/services/koko.js +260 -0
  194. package/dist/services/koko.js.map +1 -0
  195. package/dist/services/kubernetes.d.ts +16 -0
  196. package/dist/services/kubernetes.d.ts.map +1 -0
  197. package/dist/services/kubernetes.js +102 -0
  198. package/dist/services/kubernetes.js.map +1 -0
  199. package/dist/services/oauth-provisioner.d.ts +30 -0
  200. package/dist/services/oauth-provisioner.d.ts.map +1 -0
  201. package/dist/services/oauth-provisioner.js +182 -0
  202. package/dist/services/oauth-provisioner.js.map +1 -0
  203. package/dist/services/oauth-provisioner.test.d.ts +2 -0
  204. package/dist/services/oauth-provisioner.test.d.ts.map +1 -0
  205. package/dist/services/oauth-provisioner.test.js +349 -0
  206. package/dist/services/oauth-provisioner.test.js.map +1 -0
  207. package/dist/services/pod-diagnostics.d.ts +11 -0
  208. package/dist/services/pod-diagnostics.d.ts.map +1 -0
  209. package/dist/services/pod-diagnostics.js +201 -0
  210. package/dist/services/pod-diagnostics.js.map +1 -0
  211. package/dist/services/rotation-scheduler.d.ts +2 -0
  212. package/dist/services/rotation-scheduler.d.ts.map +1 -0
  213. package/dist/services/rotation-scheduler.js +215 -0
  214. package/dist/services/rotation-scheduler.js.map +1 -0
  215. package/dist/services/storage-credential-manager.d.ts +43 -0
  216. package/dist/services/storage-credential-manager.d.ts.map +1 -0
  217. package/dist/services/storage-credential-manager.js +159 -0
  218. package/dist/services/storage-credential-manager.js.map +1 -0
  219. package/dist/services/storage-provisioner.d.ts +32 -0
  220. package/dist/services/storage-provisioner.d.ts.map +1 -0
  221. package/dist/services/storage-provisioner.js +136 -0
  222. package/dist/services/storage-provisioner.js.map +1 -0
  223. package/dist/services/storage.d.ts +65 -0
  224. package/dist/services/storage.d.ts.map +1 -0
  225. package/dist/services/storage.js +204 -0
  226. package/dist/services/storage.js.map +1 -0
  227. package/dist/services/troubleshooter.d.ts +22 -0
  228. package/dist/services/troubleshooter.d.ts.map +1 -0
  229. package/dist/services/troubleshooter.js +168 -0
  230. package/dist/services/troubleshooter.js.map +1 -0
  231. package/dist/services/vault-client.d.ts +114 -0
  232. package/dist/services/vault-client.d.ts.map +1 -0
  233. package/dist/services/vault-client.js +411 -0
  234. package/dist/services/vault-client.js.map +1 -0
  235. package/dist/utils/logger.d.ts +2 -0
  236. package/dist/utils/logger.d.ts.map +1 -0
  237. package/dist/utils/logger.js +6 -0
  238. package/dist/utils/logger.js.map +1 -0
  239. package/dist/utils/response.d.ts +13 -0
  240. package/dist/utils/response.d.ts.map +1 -0
  241. package/dist/utils/response.js +12 -0
  242. package/dist/utils/response.js.map +1 -0
  243. package/docs/registry-migration.md +301 -0
  244. package/docs/registry-quickstart.md +169 -0
  245. package/ecosystem.config.cjs +14 -0
  246. package/findings.md +168 -0
  247. package/helm/default-service/Chart.yaml +6 -0
  248. package/helm/default-service/templates/deployment.yaml +97 -0
  249. package/helm/default-service/templates/ingress.yaml +43 -0
  250. package/helm/default-service/templates/service.yaml +17 -0
  251. package/helm/default-service/values.yaml +82 -0
  252. package/helm/services/iec-builder/Chart.yaml +6 -0
  253. package/helm/services/iec-builder/templates/_helpers.tpl +61 -0
  254. package/helm/services/iec-builder/templates/deployment.yaml +73 -0
  255. package/helm/services/iec-builder/templates/service.yaml +15 -0
  256. package/helm/services/iec-builder/templates/serviceaccount.yaml +12 -0
  257. package/helm/services/iec-builder/values.yaml +56 -0
  258. package/helm/vault-values.yaml +127 -0
  259. package/package.json +45 -0
  260. package/progress.md +156 -0
  261. package/scripts/.vault-init-keys.json +23 -0
  262. package/scripts/backfill-ownership.ts +113 -0
  263. package/scripts/finalize-mongo-auth.sh +212 -0
  264. package/scripts/setup-ipset.sh +107 -0
  265. package/scripts/setup-mongo-auth.sh +163 -0
  266. package/scripts/setup-neo4j-auth.sh +62 -0
  267. package/scripts/setup-redis-auth.sh +55 -0
  268. package/scripts/setup-registry-secret.sh +71 -0
  269. package/scripts/setup-vault.sh +308 -0
  270. package/src/config/env.ts +117 -0
  271. package/src/index.ts +153 -0
  272. package/src/middleware/auth.ts +294 -0
  273. package/src/middleware/org-access.ts +126 -0
  274. package/src/models/types.ts +288 -0
  275. package/src/routes/ai.ts +115 -0
  276. package/src/routes/audit.ts +121 -0
  277. package/src/routes/builds.ts +320 -0
  278. package/src/routes/cluster.ts +235 -0
  279. package/src/routes/config.ts +369 -0
  280. package/src/routes/databases.ts +201 -0
  281. package/src/routes/db-whitelist.ts +204 -0
  282. package/src/routes/domains.ts +547 -0
  283. package/src/routes/oauth.ts +195 -0
  284. package/src/routes/observability.ts +205 -0
  285. package/src/routes/orgs.ts +330 -0
  286. package/src/routes/platform.ts +134 -0
  287. package/src/routes/rotation.ts +191 -0
  288. package/src/routes/services.ts +290 -0
  289. package/src/routes/storage.ts +153 -0
  290. package/src/routes/users.ts +235 -0
  291. package/src/routes/webhooks.ts +384 -0
  292. package/src/services/__tests__/catalog-storage.test.ts +186 -0
  293. package/src/services/__tests__/deploy-pipeline.integration.test.ts +624 -0
  294. package/src/services/__tests__/pod-diagnostics.test.ts +332 -0
  295. package/src/services/__tests__/storage-credential-manager.test.ts +129 -0
  296. package/src/services/__tests__/storage-provisioner.test.ts +166 -0
  297. package/src/services/__tests__/troubleshooter.test.ts +329 -0
  298. package/src/services/bio-client.ts +189 -0
  299. package/src/services/build-queue.ts +137 -0
  300. package/src/services/builder.ts +1800 -0
  301. package/src/services/catalog.test.ts +1389 -0
  302. package/src/services/catalog.ts +1187 -0
  303. package/src/services/cloudflare.ts +259 -0
  304. package/src/services/config-validator.test.ts +190 -0
  305. package/src/services/config-validator.ts +108 -0
  306. package/src/services/crypto.ts +78 -0
  307. package/src/services/database.ts +122 -0
  308. package/src/services/db-credential-manager.test.ts +101 -0
  309. package/src/services/db-credential-manager.ts +447 -0
  310. package/src/services/db-provisioner.test.ts +602 -0
  311. package/src/services/db-provisioner.ts +589 -0
  312. package/src/services/db-whitelist.test.ts +671 -0
  313. package/src/services/db-whitelist.ts +496 -0
  314. package/src/services/dependency-resolver.test.ts +677 -0
  315. package/src/services/dependency-resolver.ts +319 -0
  316. package/src/services/deploy-gate.test.ts +247 -0
  317. package/src/services/deploy-gate.ts +75 -0
  318. package/src/services/dockerfile-generator.test.ts +401 -0
  319. package/src/services/dockerfile-generator.ts +606 -0
  320. package/src/services/forgejo.ts +212 -0
  321. package/src/services/koko.ts +492 -0
  322. package/src/services/kubernetes.ts +141 -0
  323. package/src/services/oauth-provisioner.test.ts +477 -0
  324. package/src/services/oauth-provisioner.ts +286 -0
  325. package/src/services/pod-diagnostics.ts +261 -0
  326. package/src/services/rotation-scheduler.ts +293 -0
  327. package/src/services/storage-credential-manager.ts +223 -0
  328. package/src/services/storage-provisioner.ts +216 -0
  329. package/src/services/storage.ts +274 -0
  330. package/src/services/troubleshooter.ts +208 -0
  331. package/src/services/vault-client.test.ts +272 -0
  332. package/src/services/vault-client.ts +587 -0
  333. package/src/utils/logger.ts +6 -0
  334. package/src/utils/response.ts +23 -0
  335. package/task_plan.md +171 -0
  336. package/tsconfig.json +20 -0
  337. package/vitest.config.ts +19 -0
@@ -0,0 +1,329 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ const { mockGetPods } = vi.hoisted(() => ({
4
+ mockGetPods: vi.fn(),
5
+ }))
6
+
7
+ vi.mock('../pod-diagnostics.js', () => ({
8
+ getPods: (...args: unknown[]) => mockGetPods(...args),
9
+ }))
10
+
11
+ vi.mock('../../utils/logger.js', () => ({
12
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
13
+ }))
14
+
15
+ import { troubleshoot, type TroubleshootResult } from '../troubleshooter.js'
16
+ import type { PodDiagnostic } from '../../models/types.js'
17
+
18
+ function makePod(overrides: Partial<PodDiagnostic> = {}): PodDiagnostic {
19
+ return {
20
+ name: 'my-api-abc123',
21
+ phase: 'Running',
22
+ restartCount: 0,
23
+ containerStatuses: [{ name: 'my-api', ready: true, state: 'running' }],
24
+ events: [],
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('troubleshooter', () => {
30
+ beforeEach(() => {
31
+ vi.clearAllMocks()
32
+ })
33
+
34
+ describe('healthy service', () => {
35
+ it('returns healthy=true with no issues for running pods', async () => {
36
+ mockGetPods.mockResolvedValue([makePod()])
37
+
38
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
39
+ expect(result.healthy).toBe(true)
40
+ expect(result.issues).toHaveLength(0)
41
+ expect(result.service).toBe('my-api')
42
+ expect(result.namespace).toBe('my-api-prod')
43
+ expect(result.environment).toBe('prod')
44
+ expect(result.pods).toHaveLength(1)
45
+ })
46
+ })
47
+
48
+ describe('no pods', () => {
49
+ it('returns critical issue when namespace has no pods', async () => {
50
+ mockGetPods.mockResolvedValue([])
51
+
52
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
53
+ expect(result.healthy).toBe(false)
54
+ expect(result.issues).toHaveLength(1)
55
+ expect(result.issues[0].severity).toBe('critical')
56
+ expect(result.issues[0].category).toBe('deployment')
57
+ expect(result.issues[0].title).toBe('No Pods Found')
58
+ })
59
+ })
60
+
61
+ describe('crash-loop detection', () => {
62
+ it('detects CrashLoopBackOff in container', async () => {
63
+ mockGetPods.mockResolvedValue([
64
+ makePod({
65
+ restartCount: 5,
66
+ containerStatuses: [{
67
+ name: 'my-api',
68
+ ready: false,
69
+ state: 'waiting',
70
+ reason: 'CrashLoopBackOff',
71
+ exitCode: 1,
72
+ }],
73
+ }),
74
+ ])
75
+
76
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
77
+ expect(result.healthy).toBe(false)
78
+ const issue = result.issues.find((i) => i.title === 'CrashLoopBackOff')
79
+ expect(issue).toBeDefined()
80
+ expect(issue!.severity).toBe('critical')
81
+ expect(issue!.category).toBe('crash')
82
+ expect(issue!.detail).toContain('5 restarts')
83
+ expect(issue!.containerName).toBe('my-api')
84
+ expect(issue!.suggestion).toContain('tawa logs')
85
+ })
86
+
87
+ it('detects CrashLoopBackOff in init container', async () => {
88
+ mockGetPods.mockResolvedValue([
89
+ makePod({
90
+ phase: 'Init:Error',
91
+ containerStatuses: [{ name: 'my-api', ready: false, state: 'waiting' }],
92
+ initContainerStatuses: [{
93
+ name: 'vault-agent-init',
94
+ ready: false,
95
+ state: 'waiting',
96
+ reason: 'CrashLoopBackOff',
97
+ }],
98
+ }),
99
+ ])
100
+
101
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
102
+ expect(result.healthy).toBe(false)
103
+ const crashIssue = result.issues.find((i) => i.title === 'CrashLoopBackOff')
104
+ expect(crashIssue).toBeDefined()
105
+ expect(crashIssue!.containerName).toBe('vault-agent-init')
106
+ })
107
+ })
108
+
109
+ describe('image-pull detection', () => {
110
+ it('detects ImagePullBackOff', async () => {
111
+ mockGetPods.mockResolvedValue([
112
+ makePod({
113
+ phase: 'Pending',
114
+ containerStatuses: [{
115
+ name: 'my-api',
116
+ ready: false,
117
+ state: 'waiting',
118
+ reason: 'ImagePullBackOff',
119
+ message: 'Back-off pulling image',
120
+ }],
121
+ }),
122
+ ])
123
+
124
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
125
+ expect(result.healthy).toBe(false)
126
+ const issue = result.issues.find((i) => i.title === 'ImagePullBackOff')
127
+ expect(issue).toBeDefined()
128
+ expect(issue!.severity).toBe('critical')
129
+ expect(issue!.suggestion).toContain('registry')
130
+ })
131
+
132
+ it('detects ErrImagePull', async () => {
133
+ mockGetPods.mockResolvedValue([
134
+ makePod({
135
+ phase: 'Pending',
136
+ containerStatuses: [{
137
+ name: 'my-api',
138
+ ready: false,
139
+ state: 'waiting',
140
+ reason: 'ErrImagePull',
141
+ }],
142
+ }),
143
+ ])
144
+
145
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
146
+ const issue = result.issues.find((i) => i.title === 'ImagePullBackOff')
147
+ expect(issue).toBeDefined()
148
+ })
149
+ })
150
+
151
+ describe('OOMKilled detection', () => {
152
+ it('detects OOMKilled container', async () => {
153
+ mockGetPods.mockResolvedValue([
154
+ makePod({
155
+ restartCount: 1,
156
+ containerStatuses: [{
157
+ name: 'my-api',
158
+ ready: false,
159
+ state: 'terminated',
160
+ reason: 'OOMKilled',
161
+ }],
162
+ }),
163
+ ])
164
+
165
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
166
+ expect(result.healthy).toBe(false)
167
+ const issue = result.issues.find((i) => i.title === 'OOMKilled')
168
+ expect(issue).toBeDefined()
169
+ expect(issue!.severity).toBe('critical')
170
+ expect(issue!.suggestion).toContain('pod-tier')
171
+ })
172
+ })
173
+
174
+ describe('vault-init-failure detection', () => {
175
+ it('detects failed Vault init container', async () => {
176
+ mockGetPods.mockResolvedValue([
177
+ makePod({
178
+ phase: 'Init:Error',
179
+ containerStatuses: [{ name: 'my-api', ready: false, state: 'waiting' }],
180
+ initContainerStatuses: [{
181
+ name: 'vault-agent-init',
182
+ ready: false,
183
+ state: 'waiting',
184
+ message: 'vault auth failed',
185
+ }],
186
+ }),
187
+ ])
188
+
189
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
190
+ expect(result.healthy).toBe(false)
191
+ const issue = result.issues.find((i) => i.title === 'Vault Init Container Failed')
192
+ expect(issue).toBeDefined()
193
+ expect(issue!.severity).toBe('critical')
194
+ expect(issue!.category).toBe('vault')
195
+ expect(issue!.suggestion).toContain('Vault')
196
+ })
197
+ })
198
+
199
+ describe('config-error detection', () => {
200
+ it('detects CreateContainerConfigError', async () => {
201
+ mockGetPods.mockResolvedValue([
202
+ makePod({
203
+ containerStatuses: [{
204
+ name: 'my-api',
205
+ ready: false,
206
+ state: 'waiting',
207
+ reason: 'CreateContainerConfigError',
208
+ message: 'secret "my-api-managed-secrets" not found',
209
+ }],
210
+ }),
211
+ ])
212
+
213
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
214
+ expect(result.healthy).toBe(false)
215
+ const issue = result.issues.find((i) => i.title === 'Missing ConfigMap or Secret')
216
+ expect(issue).toBeDefined()
217
+ expect(issue!.detail).toContain('my-api-managed-secrets')
218
+ expect(issue!.suggestion).toContain('tawa config set')
219
+ })
220
+ })
221
+
222
+ describe('pending-scheduling detection', () => {
223
+ it('detects FailedScheduling event on Pending pod', async () => {
224
+ mockGetPods.mockResolvedValue([
225
+ makePod({
226
+ phase: 'Pending',
227
+ containerStatuses: [],
228
+ events: [{
229
+ type: 'Warning',
230
+ reason: 'FailedScheduling',
231
+ message: '0/3 nodes are available: insufficient cpu.',
232
+ age: '2m',
233
+ }],
234
+ }),
235
+ ])
236
+
237
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
238
+ const issue = result.issues.find((i) => i.title === 'Pod Pending (FailedScheduling)')
239
+ expect(issue).toBeDefined()
240
+ expect(issue!.severity).toBe('warning')
241
+ expect(issue!.detail).toContain('insufficient cpu')
242
+ })
243
+ })
244
+
245
+ describe('high-restarts detection', () => {
246
+ it('flags pods with 3+ restarts not in CrashLoopBackOff', async () => {
247
+ mockGetPods.mockResolvedValue([
248
+ makePod({
249
+ restartCount: 4,
250
+ containerStatuses: [{
251
+ name: 'my-api',
252
+ ready: true,
253
+ state: 'running',
254
+ }],
255
+ }),
256
+ ])
257
+
258
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
259
+ const issue = result.issues.find((i) => i.title === 'High Restart Count')
260
+ expect(issue).toBeDefined()
261
+ expect(issue!.severity).toBe('warning')
262
+ expect(issue!.detail).toContain('4 times')
263
+ })
264
+
265
+ it('does not flag high restarts when CrashLoopBackOff is already detected', async () => {
266
+ mockGetPods.mockResolvedValue([
267
+ makePod({
268
+ restartCount: 10,
269
+ containerStatuses: [{
270
+ name: 'my-api',
271
+ ready: false,
272
+ state: 'waiting',
273
+ reason: 'CrashLoopBackOff',
274
+ }],
275
+ }),
276
+ ])
277
+
278
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
279
+ const highRestartsIssue = result.issues.find((i) => i.title === 'High Restart Count')
280
+ expect(highRestartsIssue).toBeUndefined()
281
+ const crashIssue = result.issues.find((i) => i.title === 'CrashLoopBackOff')
282
+ expect(crashIssue).toBeDefined()
283
+ })
284
+ })
285
+
286
+ describe('mixed status', () => {
287
+ it('reports multiple issues from different pods sorted by severity', async () => {
288
+ mockGetPods.mockResolvedValue([
289
+ makePod({ name: 'healthy-pod' }),
290
+ makePod({
291
+ name: 'crash-pod',
292
+ restartCount: 5,
293
+ containerStatuses: [{
294
+ name: 'app',
295
+ ready: false,
296
+ state: 'waiting',
297
+ reason: 'CrashLoopBackOff',
298
+ }],
299
+ }),
300
+ makePod({
301
+ name: 'restart-pod',
302
+ restartCount: 4,
303
+ containerStatuses: [{
304
+ name: 'app',
305
+ ready: true,
306
+ state: 'running',
307
+ }],
308
+ }),
309
+ ])
310
+
311
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
312
+ expect(result.healthy).toBe(false)
313
+ expect(result.issues.length).toBeGreaterThanOrEqual(2)
314
+ // Critical issues should come first
315
+ expect(result.issues[0].severity).toBe('critical')
316
+ expect(result.pods).toHaveLength(3)
317
+ })
318
+ })
319
+
320
+ describe('result structure', () => {
321
+ it('includes analyzedAt timestamp', async () => {
322
+ mockGetPods.mockResolvedValue([makePod()])
323
+
324
+ const result = await troubleshoot('my-api', 'my-api-prod', 'prod')
325
+ expect(result.analyzedAt).toBeTruthy()
326
+ expect(new Date(result.analyzedAt).getTime()).not.toBeNaN()
327
+ })
328
+ })
329
+ })
@@ -0,0 +1,189 @@
1
+ import { env } from '../config/env.js'
2
+ import { logger } from '../utils/logger.js'
3
+
4
+ interface ApiResponse<T> {
5
+ success: boolean
6
+ data?: T
7
+ error?: {
8
+ code: string
9
+ message: string
10
+ details?: unknown
11
+ }
12
+ }
13
+
14
+ interface RequestOptions {
15
+ forwardedFor?: string
16
+ userAgent?: string
17
+ }
18
+
19
+ class BioClient {
20
+ private baseUrl: string
21
+ private internalKey: string
22
+
23
+ constructor(baseUrl: string, internalKey: string) {
24
+ this.baseUrl = baseUrl.replace(/\/$/, '')
25
+ this.internalKey = internalKey
26
+ }
27
+
28
+ private async request<T>(
29
+ method: string,
30
+ path: string,
31
+ body?: unknown,
32
+ options?: RequestOptions
33
+ ): Promise<ApiResponse<T>> {
34
+ const url = `${this.baseUrl}${path}`
35
+ const headers: Record<string, string> = {
36
+ 'Content-Type': 'application/json',
37
+ 'X-Internal-Key': this.internalKey,
38
+ }
39
+
40
+ if (options?.forwardedFor) {
41
+ headers['X-Forwarded-For'] = options.forwardedFor
42
+ }
43
+ if (options?.userAgent) {
44
+ headers['User-Agent'] = options.userAgent
45
+ }
46
+
47
+ try {
48
+ const response = await fetch(url, {
49
+ method,
50
+ headers,
51
+ body: body ? JSON.stringify(body) : undefined,
52
+ })
53
+
54
+ const json = await response.json() as ApiResponse<T>
55
+
56
+ if (!response.ok && !json.error) {
57
+ return {
58
+ success: false,
59
+ error: {
60
+ code: 'UPSTREAM_ERROR',
61
+ message: `Bio-ID returned ${response.status}`,
62
+ },
63
+ }
64
+ }
65
+
66
+ return json
67
+ } catch (error) {
68
+ logger.error({ error, url, method }, 'Bio-ID request failed')
69
+ return {
70
+ success: false,
71
+ error: {
72
+ code: 'UPSTREAM_ERROR',
73
+ message: 'Bio-ID service unavailable',
74
+ },
75
+ }
76
+ }
77
+ }
78
+
79
+ async listClients(options?: RequestOptions) {
80
+ return this.request<unknown[]>('GET', '/api/admin/oauth-clients', undefined, options)
81
+ }
82
+
83
+ async getClient(clientId: string, options?: RequestOptions) {
84
+ return this.request<unknown>('GET', `/api/admin/oauth-clients/${encodeURIComponent(clientId)}`, undefined, options)
85
+ }
86
+
87
+ async createClient(data: unknown, options?: RequestOptions) {
88
+ return this.request<unknown>('POST', '/api/admin/oauth-clients', data, options)
89
+ }
90
+
91
+ async updateClient(clientId: string, data: unknown, options?: RequestOptions) {
92
+ return this.request<unknown>('PATCH', `/api/admin/oauth-clients/${encodeURIComponent(clientId)}`, data, options)
93
+ }
94
+
95
+ async deleteClient(clientId: string, options?: RequestOptions) {
96
+ return this.request<unknown>('DELETE', `/api/admin/oauth-clients/${encodeURIComponent(clientId)}`, undefined, options)
97
+ }
98
+
99
+ async addRedirectUri(clientId: string, uri: string, options?: RequestOptions) {
100
+ return this.request<unknown>(
101
+ 'POST',
102
+ `/api/admin/oauth-clients/${encodeURIComponent(clientId)}/redirect-uris`,
103
+ { uri },
104
+ options
105
+ )
106
+ }
107
+
108
+ async removeRedirectUri(clientId: string, uri: string, options?: RequestOptions) {
109
+ return this.request<unknown>(
110
+ 'DELETE',
111
+ `/api/admin/oauth-clients/${encodeURIComponent(clientId)}/redirect-uris`,
112
+ { uri },
113
+ options
114
+ )
115
+ }
116
+
117
+ async regenerateSecret(clientId: string, options?: RequestOptions) {
118
+ return this.request<unknown>(
119
+ 'POST',
120
+ `/api/admin/oauth-clients/${encodeURIComponent(clientId)}/regenerate-secret`,
121
+ undefined,
122
+ options
123
+ )
124
+ }
125
+
126
+ async listScopeGrants(filters: {
127
+ requestingServiceId?: string
128
+ targetServiceId?: string
129
+ credentialId?: string
130
+ status?: string
131
+ }, options?: RequestOptions) {
132
+ const params = new URLSearchParams()
133
+ if (filters.requestingServiceId) params.set('requestingServiceId', filters.requestingServiceId)
134
+ if (filters.targetServiceId) params.set('targetServiceId', filters.targetServiceId)
135
+ if (filters.credentialId) params.set('credentialId', filters.credentialId)
136
+ if (filters.status) params.set('status', filters.status)
137
+ const qs = params.toString()
138
+ return this.request<unknown[]>('GET', `/api/admin/scope-grants${qs ? `?${qs}` : ''}`, undefined, options)
139
+ }
140
+
141
+ async createScopeGrant(data: {
142
+ requestingServiceId: string
143
+ targetServiceId: string
144
+ requestedScopes: string[]
145
+ requestNote?: string
146
+ credentialType: 'oauth_client' | 'api_key'
147
+ credentialId: string
148
+ }, options?: RequestOptions) {
149
+ return this.request<unknown>('POST', '/api/admin/scope-grants', data, options)
150
+ }
151
+
152
+ async registerModule(data: {
153
+ moduleId: string
154
+ name: string
155
+ description: string
156
+ serviceId: string
157
+ owner?: string
158
+ homepage?: string
159
+ scopes: ReadonlyArray<{ code: string; name: string; description: string }>
160
+ defaultScopes?: readonly string[]
161
+ onboarding?: {
162
+ requirePasswordSetup?: boolean
163
+ requireProfileCompletion?: boolean
164
+ podOnboardingUrl?: string
165
+ welcomeMessage?: string
166
+ skipForExistingUsers?: boolean
167
+ }
168
+ }, options?: RequestOptions) {
169
+ return this.request<unknown>('POST', '/api/admin/modules', data, options)
170
+ }
171
+ }
172
+
173
+ let bioClientInstance: BioClient | null = null
174
+
175
+ export function getBioClient(): BioClient {
176
+ if (!bioClientInstance) {
177
+ if (!env.BIO_INTERNAL_KEY) {
178
+ throw new Error('BIO_INTERNAL_KEY is not configured')
179
+ }
180
+ bioClientInstance = new BioClient(env.BIO_ID_URL, env.BIO_INTERNAL_KEY)
181
+ }
182
+ return bioClientInstance
183
+ }
184
+
185
+ export function extractRequestMeta(req: { headers: Record<string, string | string[] | undefined> }): RequestOptions {
186
+ const forwardedFor = (req.headers['x-forwarded-for'] as string) || (req.headers['x-real-ip'] as string)
187
+ const userAgent = req.headers['user-agent'] as string
188
+ return { forwardedFor, userAgent }
189
+ }
@@ -0,0 +1,137 @@
1
+ import { Queue, Worker, type Job } from 'bullmq'
2
+ import { env } from '../config/env.js'
3
+ import { logger } from '../utils/logger.js'
4
+ import { executeBuild } from './builder.js'
5
+ import { getBuildsCollection, getRedis } from './database.js'
6
+
7
+ const QUEUE_NAME = 'iec-builds'
8
+ const LEGACY_QUEUE_KEY = 'iec:build:queue'
9
+
10
+ let buildQueue: Queue | null = null
11
+ let buildWorker: Worker | null = null
12
+
13
+ function parseRedisConnection() {
14
+ const url = new URL(env.REDIS_URL)
15
+ return {
16
+ host: url.hostname,
17
+ port: Number(url.port) || 6379,
18
+ ...(url.username ? { username: url.username } : {}),
19
+ ...(url.password ? { password: url.password } : {}),
20
+ maxRetriesPerRequest: null as null, // Required for BullMQ blocking commands
21
+ }
22
+ }
23
+
24
+ export async function initBuildQueue(): Promise<void> {
25
+ const connection = parseRedisConnection()
26
+
27
+ buildQueue = new Queue(QUEUE_NAME, {
28
+ connection,
29
+ defaultJobOptions: {
30
+ attempts: 1,
31
+ removeOnComplete: 100,
32
+ removeOnFail: 200,
33
+ },
34
+ })
35
+
36
+ await drainLegacyQueue()
37
+
38
+ logger.info({ queue: QUEUE_NAME }, 'BullMQ build queue initialized')
39
+ }
40
+
41
+ /**
42
+ * One-time migration: drain any buildIds remaining in the legacy
43
+ * Redis list into BullMQ. After first deploy this is always a no-op.
44
+ */
45
+ async function drainLegacyQueue(): Promise<void> {
46
+ try {
47
+ const redis = getRedis()
48
+ let migrated = 0
49
+
50
+ let buildId = await redis.rpop(LEGACY_QUEUE_KEY)
51
+ while (buildId) {
52
+ await enqueueBuild(buildId)
53
+ migrated++
54
+ buildId = await redis.rpop(LEGACY_QUEUE_KEY)
55
+ }
56
+
57
+ if (migrated > 0) {
58
+ logger.info({ migrated }, 'Drained legacy Redis list queue into BullMQ')
59
+ }
60
+ } catch (err) {
61
+ // Redis is connected at this point (connectRedis ran first); a failure here is unexpected
62
+ logger.error({ err }, 'Failed to drain legacy Redis queue — some builds may have been lost')
63
+ }
64
+ }
65
+
66
+ export async function enqueueBuild(buildId: string): Promise<void> {
67
+ if (!buildQueue) throw new Error('Build queue not initialized')
68
+ await buildQueue.add('build', { buildId }, { jobId: buildId })
69
+ }
70
+
71
+ export function initBuildWorker(): void {
72
+ if (!buildQueue) throw new Error('Build queue must be initialized before worker')
73
+
74
+ const connection = parseRedisConnection()
75
+ const concurrency = env.BUILD_CONCURRENCY
76
+
77
+ buildWorker = new Worker(
78
+ QUEUE_NAME,
79
+ async (job: Job<{ buildId: string }>) => {
80
+ const { buildId } = job.data
81
+
82
+ // Guard: if the build was already marked failed (e.g. by recoverStaleBuilds
83
+ // after a crash) or cancelled, skip re-execution. BullMQ marks this job as
84
+ // completed — intentional, since the build state is managed in MongoDB.
85
+ const builds = getBuildsCollection()
86
+ const build = await builds.findOne({ id: buildId })
87
+ if (!build || build.status === 'failed' || build.status === 'cancelled' || build.status === 'completed') {
88
+ logger.info({ buildId, status: build?.status }, 'Skipping build — already in terminal state')
89
+ return
90
+ }
91
+
92
+ logger.info({ buildId, jobId: job.id, concurrency }, 'Processing build')
93
+ await executeBuild(buildId)
94
+ },
95
+ {
96
+ connection,
97
+ concurrency,
98
+ lockDuration: 20 * 60 * 1000, // 20 min — generous for slow Docker builds
99
+ lockRenewTime: 5 * 60 * 1000, // renew every 5 min (4x before expiry)
100
+ stalledInterval: 5 * 60 * 1000,
101
+ },
102
+ )
103
+
104
+ buildWorker.on('completed', (job) => {
105
+ logger.info({ jobId: job.id, buildId: job.data.buildId }, 'Build job completed')
106
+ })
107
+
108
+ buildWorker.on('failed', (job, err) => {
109
+ logger.error({ jobId: job?.id, buildId: job?.data.buildId, err }, 'Build job failed')
110
+ })
111
+
112
+ buildWorker.on('stalled', (jobId) => {
113
+ logger.warn({ jobId }, 'Build job stalled — BullMQ will attempt recovery')
114
+ })
115
+
116
+ buildWorker.on('error', (err) => {
117
+ logger.error({ err }, 'BullMQ worker error')
118
+ })
119
+
120
+ logger.info({ concurrency, queue: QUEUE_NAME }, 'BullMQ build worker started')
121
+ }
122
+
123
+ export function getBuildQueue(): Queue | null {
124
+ return buildQueue
125
+ }
126
+
127
+ export async function closeBuildQueue(): Promise<void> {
128
+ if (buildWorker) {
129
+ logger.info('Closing BullMQ worker — waiting for active builds to finish...')
130
+ await buildWorker.close()
131
+ logger.info('BullMQ worker closed')
132
+ }
133
+ if (buildQueue) {
134
+ await buildQueue.close()
135
+ logger.info('BullMQ queue closed')
136
+ }
137
+ }