@vibesdotdev/integrations-digitalocean 0.0.0-stage → 1.0.0-rc.2

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 (323) hide show
  1. package/LICENSE +126 -0
  2. package/README.md +78 -2
  3. package/dist/ai/index.d.ts +4 -0
  4. package/dist/ai/index.d.ts.map +1 -0
  5. package/dist/ai/index.js +4 -0
  6. package/dist/ai/index.js.map +1 -0
  7. package/dist/ai/inference.transport.impl.d.ts +5 -0
  8. package/dist/ai/inference.transport.impl.d.ts.map +1 -0
  9. package/dist/ai/inference.transport.impl.js +54 -0
  10. package/dist/ai/inference.transport.impl.js.map +1 -0
  11. package/dist/ai/provider.plugin.d.ts +6 -0
  12. package/dist/ai/provider.plugin.d.ts.map +1 -0
  13. package/dist/ai/provider.plugin.js +126 -0
  14. package/dist/ai/provider.plugin.js.map +1 -0
  15. package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.d.ts +62 -0
  16. package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.d.ts.map +1 -0
  17. package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.js +73 -0
  18. package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.js.map +1 -0
  19. package/dist/ai/providers/cloud/frontier/digitalocean/index.d.ts +2 -0
  20. package/dist/ai/providers/cloud/frontier/digitalocean/index.d.ts.map +1 -0
  21. package/dist/ai/providers/cloud/frontier/digitalocean/index.js +2 -0
  22. package/dist/ai/providers/cloud/frontier/digitalocean/index.js.map +1 -0
  23. package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.d.ts +183 -0
  24. package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.d.ts.map +1 -0
  25. package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.js +66 -0
  26. package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.js.map +1 -0
  27. package/dist/apps/api-client.d.ts +45 -0
  28. package/dist/apps/api-client.d.ts.map +1 -0
  29. package/dist/apps/api-client.js +47 -0
  30. package/dist/apps/api-client.js.map +1 -0
  31. package/dist/apps/build-spec.d.ts +44 -0
  32. package/dist/apps/build-spec.d.ts.map +1 -0
  33. package/dist/apps/build-spec.js +463 -0
  34. package/dist/apps/build-spec.js.map +1 -0
  35. package/dist/apps/deploy-client.d.ts +42 -0
  36. package/dist/apps/deploy-client.d.ts.map +1 -0
  37. package/dist/apps/deploy-client.js +300 -0
  38. package/dist/apps/deploy-client.js.map +1 -0
  39. package/dist/apps/index.d.ts +5 -0
  40. package/dist/apps/index.d.ts.map +1 -0
  41. package/dist/apps/index.js +4 -0
  42. package/dist/apps/index.js.map +1 -0
  43. package/dist/apps/types.d.ts +204 -0
  44. package/dist/apps/types.d.ts.map +1 -0
  45. package/dist/apps/types.js +13 -0
  46. package/dist/apps/types.js.map +1 -0
  47. package/dist/client/control-plane.d.ts +49 -0
  48. package/dist/client/control-plane.d.ts.map +1 -0
  49. package/dist/client/control-plane.js +85 -0
  50. package/dist/client/control-plane.js.map +1 -0
  51. package/dist/client/do-fetch.d.ts +41 -0
  52. package/dist/client/do-fetch.d.ts.map +1 -0
  53. package/dist/client/do-fetch.js +199 -0
  54. package/dist/client/do-fetch.js.map +1 -0
  55. package/dist/client/errors.d.ts +6 -0
  56. package/dist/client/errors.d.ts.map +1 -0
  57. package/dist/client/errors.js +34 -0
  58. package/dist/client/errors.js.map +1 -0
  59. package/dist/client/factory.d.ts +53 -0
  60. package/dist/client/factory.d.ts.map +1 -0
  61. package/dist/client/factory.js +220 -0
  62. package/dist/client/factory.js.map +1 -0
  63. package/dist/client/helpers.d.ts +5 -0
  64. package/dist/client/helpers.d.ts.map +1 -0
  65. package/dist/client/helpers.js +8 -0
  66. package/dist/client/helpers.js.map +1 -0
  67. package/dist/client/index.d.ts +12 -0
  68. package/dist/client/index.d.ts.map +1 -0
  69. package/dist/client/index.js +10 -0
  70. package/dist/client/index.js.map +1 -0
  71. package/dist/client/paginated.d.ts +19 -0
  72. package/dist/client/paginated.d.ts.map +1 -0
  73. package/dist/client/paginated.js +56 -0
  74. package/dist/client/paginated.js.map +1 -0
  75. package/dist/client/retry.d.ts +34 -0
  76. package/dist/client/retry.d.ts.map +1 -0
  77. package/dist/client/retry.js +53 -0
  78. package/dist/client/retry.js.map +1 -0
  79. package/dist/config/digitalocean.config.descriptor.d.ts +197 -0
  80. package/dist/config/digitalocean.config.descriptor.d.ts.map +1 -0
  81. package/dist/config/digitalocean.config.descriptor.js +59 -0
  82. package/dist/config/digitalocean.config.descriptor.js.map +1 -0
  83. package/dist/credentials/index.d.ts +38 -0
  84. package/dist/credentials/index.d.ts.map +1 -0
  85. package/dist/credentials/index.js +56 -0
  86. package/dist/credentials/index.js.map +1 -0
  87. package/dist/databases/index.d.ts +82 -0
  88. package/dist/databases/index.d.ts.map +1 -0
  89. package/dist/databases/index.js +57 -0
  90. package/dist/databases/index.js.map +1 -0
  91. package/dist/digitalocean-friction.policy.d.ts +29 -0
  92. package/dist/digitalocean-friction.policy.d.ts.map +1 -0
  93. package/dist/digitalocean-friction.policy.js +21 -0
  94. package/dist/digitalocean-friction.policy.js.map +1 -0
  95. package/dist/digitalocean.plugin.d.ts +3 -0
  96. package/dist/digitalocean.plugin.d.ts.map +1 -0
  97. package/dist/digitalocean.plugin.js +27 -0
  98. package/dist/digitalocean.plugin.js.map +1 -0
  99. package/dist/droplets/index.d.ts +106 -0
  100. package/dist/droplets/index.d.ts.map +1 -0
  101. package/dist/droplets/index.js +227 -0
  102. package/dist/droplets/index.js.map +1 -0
  103. package/dist/index.d.ts +35 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +32 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/inference-control-plane/index.d.ts +63 -0
  108. package/dist/inference-control-plane/index.d.ts.map +1 -0
  109. package/dist/inference-control-plane/index.js +93 -0
  110. package/dist/inference-control-plane/index.js.map +1 -0
  111. package/dist/infra/billing.d.ts +26 -0
  112. package/dist/infra/billing.d.ts.map +1 -0
  113. package/dist/infra/billing.js +43 -0
  114. package/dist/infra/billing.js.map +1 -0
  115. package/dist/infra/credentials.d.ts +11 -0
  116. package/dist/infra/credentials.d.ts.map +1 -0
  117. package/dist/infra/credentials.js +15 -0
  118. package/dist/infra/credentials.js.map +1 -0
  119. package/dist/infra/deployment-provider/deploy-secrets.d.ts +10 -0
  120. package/dist/infra/deployment-provider/deploy-secrets.d.ts.map +1 -0
  121. package/dist/infra/deployment-provider/deploy-secrets.js +66 -0
  122. package/dist/infra/deployment-provider/deploy-secrets.js.map +1 -0
  123. package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.d.ts +97 -0
  124. package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.d.ts.map +1 -0
  125. package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.js +242 -0
  126. package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.js.map +1 -0
  127. package/dist/infra/deployment-provider/image-publish.d.ts +39 -0
  128. package/dist/infra/deployment-provider/image-publish.d.ts.map +1 -0
  129. package/dist/infra/deployment-provider/image-publish.js +146 -0
  130. package/dist/infra/deployment-provider/image-publish.js.map +1 -0
  131. package/dist/infra/deployment-provider.descriptor.d.ts +34 -0
  132. package/dist/infra/deployment-provider.descriptor.d.ts.map +1 -0
  133. package/dist/infra/deployment-provider.descriptor.js +9 -0
  134. package/dist/infra/deployment-provider.descriptor.js.map +1 -0
  135. package/dist/infra/digitalocean-infra.plugin.d.ts +101 -0
  136. package/dist/infra/digitalocean-infra.plugin.d.ts.map +1 -0
  137. package/dist/infra/digitalocean-infra.plugin.js +283 -0
  138. package/dist/infra/digitalocean-infra.plugin.js.map +1 -0
  139. package/dist/infra/doks/clients.d.ts +183 -0
  140. package/dist/infra/doks/clients.d.ts.map +1 -0
  141. package/dist/infra/doks/clients.js +172 -0
  142. package/dist/infra/doks/clients.js.map +1 -0
  143. package/dist/infra/doks/digitalocean.d.ts +34 -0
  144. package/dist/infra/doks/digitalocean.d.ts.map +1 -0
  145. package/dist/infra/doks/digitalocean.js +267 -0
  146. package/dist/infra/doks/digitalocean.js.map +1 -0
  147. package/dist/infra/doks/service-release-schemas.d.ts +1037 -0
  148. package/dist/infra/doks/service-release-schemas.d.ts.map +1 -0
  149. package/dist/infra/doks/service-release-schemas.js +51 -0
  150. package/dist/infra/doks/service-release-schemas.js.map +1 -0
  151. package/dist/infra/doks/service-release.d.ts +415 -0
  152. package/dist/infra/doks/service-release.d.ts.map +1 -0
  153. package/dist/infra/doks/service-release.js +3201 -0
  154. package/dist/infra/doks/service-release.js.map +1 -0
  155. package/dist/infra/doks/worker.d.ts +14 -0
  156. package/dist/infra/doks/worker.d.ts.map +1 -0
  157. package/dist/infra/doks/worker.js +343 -0
  158. package/dist/infra/doks/worker.js.map +1 -0
  159. package/dist/infra/droplet.impl.d.ts +172 -0
  160. package/dist/infra/droplet.impl.d.ts.map +1 -0
  161. package/dist/infra/droplet.impl.js +379 -0
  162. package/dist/infra/droplet.impl.js.map +1 -0
  163. package/dist/infra/functions.impl.d.ts +123 -0
  164. package/dist/infra/functions.impl.d.ts.map +1 -0
  165. package/dist/infra/functions.impl.js +202 -0
  166. package/dist/infra/functions.impl.js.map +1 -0
  167. package/dist/infra/index.d.ts +15 -0
  168. package/dist/infra/index.d.ts.map +1 -0
  169. package/dist/infra/index.js +12 -0
  170. package/dist/infra/index.js.map +1 -0
  171. package/dist/infra/inventory.d.ts +7 -0
  172. package/dist/infra/inventory.d.ts.map +1 -0
  173. package/dist/infra/inventory.js +259 -0
  174. package/dist/infra/inventory.js.map +1 -0
  175. package/dist/infra/kubernetes-log-stream.d.ts +5 -0
  176. package/dist/infra/kubernetes-log-stream.d.ts.map +1 -0
  177. package/dist/infra/kubernetes-log-stream.js +186 -0
  178. package/dist/infra/kubernetes-log-stream.js.map +1 -0
  179. package/dist/infra/kubernetes-logs.d.ts +33 -0
  180. package/dist/infra/kubernetes-logs.d.ts.map +1 -0
  181. package/dist/infra/kubernetes-logs.descriptor.d.ts +14 -0
  182. package/dist/infra/kubernetes-logs.descriptor.d.ts.map +1 -0
  183. package/dist/infra/kubernetes-logs.descriptor.js +28 -0
  184. package/dist/infra/kubernetes-logs.descriptor.js.map +1 -0
  185. package/dist/infra/kubernetes-logs.js +196 -0
  186. package/dist/infra/kubernetes-logs.js.map +1 -0
  187. package/dist/infra/utilization.d.ts +44 -0
  188. package/dist/infra/utilization.d.ts.map +1 -0
  189. package/dist/infra/utilization.js +108 -0
  190. package/dist/infra/utilization.js.map +1 -0
  191. package/dist/kubernetes/authority.d.ts +40 -0
  192. package/dist/kubernetes/authority.d.ts.map +1 -0
  193. package/dist/kubernetes/authority.js +90 -0
  194. package/dist/kubernetes/authority.js.map +1 -0
  195. package/dist/kubernetes/encoding.d.ts +4 -0
  196. package/dist/kubernetes/encoding.d.ts.map +1 -0
  197. package/dist/kubernetes/encoding.js +8 -0
  198. package/dist/kubernetes/encoding.js.map +1 -0
  199. package/dist/kubernetes/index.d.ts +105 -0
  200. package/dist/kubernetes/index.d.ts.map +1 -0
  201. package/dist/kubernetes/index.js +99 -0
  202. package/dist/kubernetes/index.js.map +1 -0
  203. package/dist/kubernetes/transport.d.ts +11 -0
  204. package/dist/kubernetes/transport.d.ts.map +1 -0
  205. package/dist/kubernetes/transport.js +27 -0
  206. package/dist/kubernetes/transport.js.map +1 -0
  207. package/dist/network/index.d.ts +24 -0
  208. package/dist/network/index.d.ts.map +1 -0
  209. package/dist/network/index.js +16 -0
  210. package/dist/network/index.js.map +1 -0
  211. package/dist/registry/index.d.ts +39 -0
  212. package/dist/registry/index.d.ts.map +1 -0
  213. package/dist/registry/index.js +20 -0
  214. package/dist/registry/index.js.map +1 -0
  215. package/dist/schemas/app-deployment.d.ts +1474 -0
  216. package/dist/schemas/app-deployment.d.ts.map +1 -0
  217. package/dist/schemas/app-deployment.js +156 -0
  218. package/dist/schemas/app-deployment.js.map +1 -0
  219. package/dist/schemas/app-platform.d.ts +755 -0
  220. package/dist/schemas/app-platform.d.ts.map +1 -0
  221. package/dist/schemas/app-platform.js +253 -0
  222. package/dist/schemas/app-platform.js.map +1 -0
  223. package/dist/schemas/cache-database.d.ts +210 -0
  224. package/dist/schemas/cache-database.d.ts.map +1 -0
  225. package/dist/schemas/cache-database.js +105 -0
  226. package/dist/schemas/cache-database.js.map +1 -0
  227. package/dist/schemas/control-plane.d.ts +456 -0
  228. package/dist/schemas/control-plane.d.ts.map +1 -0
  229. package/dist/schemas/control-plane.js +123 -0
  230. package/dist/schemas/control-plane.js.map +1 -0
  231. package/dist/schemas/credential-use.d.ts +714 -0
  232. package/dist/schemas/credential-use.d.ts.map +1 -0
  233. package/dist/schemas/credential-use.js +73 -0
  234. package/dist/schemas/credential-use.js.map +1 -0
  235. package/dist/schemas/doks-worker.d.ts +4071 -0
  236. package/dist/schemas/doks-worker.d.ts.map +1 -0
  237. package/dist/schemas/doks-worker.js +330 -0
  238. package/dist/schemas/doks-worker.js.map +1 -0
  239. package/dist/schemas/droplet.d.ts +110 -0
  240. package/dist/schemas/droplet.d.ts.map +1 -0
  241. package/dist/schemas/droplet.js +22 -0
  242. package/dist/schemas/droplet.js.map +1 -0
  243. package/dist/schemas/functions.d.ts +22 -0
  244. package/dist/schemas/functions.d.ts.map +1 -0
  245. package/dist/schemas/functions.js +25 -0
  246. package/dist/schemas/functions.js.map +1 -0
  247. package/dist/schemas/gitea-git-hosting.d.ts +20 -0
  248. package/dist/schemas/gitea-git-hosting.d.ts.map +1 -0
  249. package/dist/schemas/gitea-git-hosting.js +26 -0
  250. package/dist/schemas/gitea-git-hosting.js.map +1 -0
  251. package/dist/schemas/http.d.ts +2594 -0
  252. package/dist/schemas/http.d.ts.map +1 -0
  253. package/dist/schemas/http.js +4548 -0
  254. package/dist/schemas/http.js.map +1 -0
  255. package/dist/schemas/infra-kinds.d.ts +113 -0
  256. package/dist/schemas/infra-kinds.d.ts.map +1 -0
  257. package/dist/schemas/infra-kinds.js +93 -0
  258. package/dist/schemas/infra-kinds.js.map +1 -0
  259. package/dist/schemas/kubernetes-responses.d.ts +457 -0
  260. package/dist/schemas/kubernetes-responses.d.ts.map +1 -0
  261. package/dist/schemas/kubernetes-responses.js +681 -0
  262. package/dist/schemas/kubernetes-responses.js.map +1 -0
  263. package/dist/schemas/kubernetes.d.ts +143 -0
  264. package/dist/schemas/kubernetes.d.ts.map +1 -0
  265. package/dist/schemas/kubernetes.js +68 -0
  266. package/dist/schemas/kubernetes.js.map +1 -0
  267. package/dist/schemas/logs.d.ts +15 -0
  268. package/dist/schemas/logs.d.ts.map +1 -0
  269. package/dist/schemas/logs.js +11 -0
  270. package/dist/schemas/logs.js.map +1 -0
  271. package/dist/schemas/object-storage.d.ts +167 -0
  272. package/dist/schemas/object-storage.d.ts.map +1 -0
  273. package/dist/schemas/object-storage.js +124 -0
  274. package/dist/schemas/object-storage.js.map +1 -0
  275. package/dist/schemas/service-settings.d.ts +9 -0
  276. package/dist/schemas/service-settings.d.ts.map +1 -0
  277. package/dist/schemas/service-settings.js +21 -0
  278. package/dist/schemas/service-settings.js.map +1 -0
  279. package/dist/schemas/utilization.d.ts +57 -0
  280. package/dist/schemas/utilization.d.ts.map +1 -0
  281. package/dist/schemas/utilization.js +58 -0
  282. package/dist/schemas/utilization.js.map +1 -0
  283. package/dist/schemas/worker-status.d.ts +22 -0
  284. package/dist/schemas/worker-status.d.ts.map +1 -0
  285. package/dist/schemas/worker-status.js +14 -0
  286. package/dist/schemas/worker-status.js.map +1 -0
  287. package/dist/schemas/worker.d.ts +125 -0
  288. package/dist/schemas/worker.d.ts.map +1 -0
  289. package/dist/schemas/worker.js +129 -0
  290. package/dist/schemas/worker.js.map +1 -0
  291. package/dist/secrets/app-platform/app-credentials.impl.d.ts +14 -0
  292. package/dist/secrets/app-platform/app-credentials.impl.d.ts.map +1 -0
  293. package/dist/secrets/app-platform/app-credentials.impl.js +237 -0
  294. package/dist/secrets/app-platform/app-credentials.impl.js.map +1 -0
  295. package/dist/secrets/container-registry/image-publish-settings.descriptor.d.ts +11 -0
  296. package/dist/secrets/container-registry/image-publish-settings.descriptor.d.ts.map +1 -0
  297. package/dist/secrets/container-registry/image-publish-settings.descriptor.js +26 -0
  298. package/dist/secrets/container-registry/image-publish-settings.descriptor.js.map +1 -0
  299. package/dist/secrets/doks-secret.descriptor.d.ts +18 -0
  300. package/dist/secrets/doks-secret.descriptor.d.ts.map +1 -0
  301. package/dist/secrets/doks-secret.descriptor.js +35 -0
  302. package/dist/secrets/doks-secret.descriptor.js.map +1 -0
  303. package/dist/secrets/doks-secret.impl.d.ts +98 -0
  304. package/dist/secrets/doks-secret.impl.d.ts.map +1 -0
  305. package/dist/secrets/doks-secret.impl.js +451 -0
  306. package/dist/secrets/doks-secret.impl.js.map +1 -0
  307. package/dist/secrets/index.d.ts +4 -0
  308. package/dist/secrets/index.d.ts.map +1 -0
  309. package/dist/secrets/index.js +4 -0
  310. package/dist/secrets/index.js.map +1 -0
  311. package/dist/secrets/secrets-backend-doks.plugin.d.ts +3 -0
  312. package/dist/secrets/secrets-backend-doks.plugin.d.ts.map +1 -0
  313. package/dist/secrets/secrets-backend-doks.plugin.js +35 -0
  314. package/dist/secrets/secrets-backend-doks.plugin.js.map +1 -0
  315. package/dist/storage/index.d.ts +50 -0
  316. package/dist/storage/index.d.ts.map +1 -0
  317. package/dist/storage/index.js +92 -0
  318. package/dist/storage/index.js.map +1 -0
  319. package/dist/storage/spaces-keys.d.ts +25 -0
  320. package/dist/storage/spaces-keys.d.ts.map +1 -0
  321. package/dist/storage/spaces-keys.js +32 -0
  322. package/dist/storage/spaces-keys.js.map +1 -0
  323. package/package.json +172 -4
@@ -0,0 +1,3201 @@
1
+ import { KubernetesFailureSchema, KubernetesIdentityTokenSchema, BoundedKubernetesConditionSchema, KubernetesManifestProjectionSchema } from "../../schemas/kubernetes-responses.js";
2
+ import { KubernetesMetadataSchema, KubernetesSnapshotClassSchema, KubernetesQuotaSpecSchema, KubernetesSecretBodySchema, KubernetesAccountBodySchema, KubernetesCertificateStatusSchema, KubernetesCertificateRequestsSchema, KubernetesOrdersBodySchema, KubernetesChallengesBodySchema, KubernetesDataSchema, KubernetesIssuersSchema, KubernetesSolverNameSchema, KubernetesSnapshotBodySchema, KubernetesIssuerStatusSchema, KubernetesQuotaVersionSchema, KubernetesDeclaredQuotaSchema, KubernetesExistingPvcSchema, KubernetesDeclaredPvcSchema, KubernetesAccessSchema, KubernetesQuotasBodySchema, KubernetesStatefulPodsSchema, KubernetesMigrationJobSchema, KubernetesPodsSchema, KubernetesExpectedLabelsSchema, KubernetesOwnedJobSchema, KubernetesWorkloadStatusSchema, KubernetesBuildPodsSchema, KubernetesEventsSchema, KubernetesReplicaSpecSchema } from "../../schemas/kubernetes-responses.js";
3
+ import { createSha256 } from '@vibesdotdev/shared/hashing';
4
+ import { CapabilityError, settle } from '@vibesdotdev/kernel';
5
+ import { encodeBase64Text } from "../../kubernetes/encoding.js";
6
+ import { kubernetesRequest } from "../../kubernetes/transport.js";
7
+ import { parseKubeconfig } from "../../kubernetes/authority.js";
8
+ import * as z from 'zod/v4';
9
+ import { cappedId } from '@vibesdotdev/infra-core';
10
+ import { DigitalOceanKubernetesServiceReleaseTargetSchema,
11
+ // The DigitalOcean parse of the kind's descriptor: core cross-field checks plus the DigitalOcean-owned config.
12
+ DigitalOceanServiceReleaseDescriptorSchema as ServiceReleaseDescriptorSchema } from "./service-release-schemas.js";
13
+ import { getVibesClient } from '@vibesdotdev/kernel/client';
14
+ import { DOKS_SECRET_DELIVERY_CLIENT_ID } from "../../secrets/doks-secret.descriptor.js";
15
+ import { DigitalOceanDoksWorkerCredentialDeliveryOwnerSchema, DigitalOceanDoksWorkerDescriptorSchema, DigitalOceanDoksWorkerExecutionInputSchema, DigitalOceanDoksWorkerPlanSchema, DigitalOceanDoksWorkerOperationIdSchema, bindDigitalOceanDoksWorkerCredentialLease } from "../../schemas/doks-worker.js";
16
+ import { planDigitalOceanDoksWorkerApply, planDigitalOceanDoksWorkerBuild } from "./worker.js";
17
+ const FIELD_MANAGER = 'vibes-infra-service-release';
18
+ const SECRET_FIELD_MANAGER = 'vibes-secret-management';
19
+ const CERTIFICATE_FIELD_MANAGER = 'vibes-infra-certificate';
20
+ const MIGRATION_FIELD_MANAGER = 'vibes-infra-repository-migration';
21
+ const LABEL_PART_OF = 'app.kubernetes.io/part-of';
22
+ function boundedConditionSummary(value) {
23
+ const parsed = z.array(z.unknown()).safeParse(value);
24
+ if (!parsed.success)
25
+ return [];
26
+ return parsed.data
27
+ .flatMap((entry) => {
28
+ const condition = BoundedKubernetesConditionSchema.safeParse(entry);
29
+ if (!condition.success)
30
+ return [];
31
+ const { type, status, reason } = condition.data;
32
+ return [`${type}:${status}${reason ? '/' + reason : ''}`];
33
+ })
34
+ .slice(0, 8);
35
+ }
36
+ function boundedStatusToken(value, fallback) {
37
+ const token = KubernetesIdentityTokenSchema.safeParse(value);
38
+ return token.success ? token.data : fallback;
39
+ }
40
+ function classifyKubernetesEventMessage(value) {
41
+ const parsed = z.string().safeParse(value);
42
+ if (!parsed.success)
43
+ return '';
44
+ const message = parsed.data.toLowerCase();
45
+ if (message.includes('failed calling webhook'))
46
+ return 'WebhookFailed';
47
+ if (message.includes('exceeded quota'))
48
+ return 'QuotaExceeded';
49
+ if (message.includes('forbidden'))
50
+ return 'Forbidden';
51
+ if (message.includes('not found'))
52
+ return 'NotFound';
53
+ if (message.includes('invalid'))
54
+ return 'Invalid';
55
+ if (message.includes('timed out') || message.includes('timeout'))
56
+ return 'TimedOut';
57
+ return '';
58
+ }
59
+ async function boundedKubernetesFailure(response) {
60
+ const evidence = await settle(async () => KubernetesFailureSchema.parse(await response.json()));
61
+ const body = evidence.ok ? evidence.value : {};
62
+ const reason = boundedStatusToken(body.reason, 'Unspecified');
63
+ const kind = boundedStatusToken(body.details?.kind, 'resource');
64
+ const name = boundedStatusToken(body.details?.name, 'unnamed');
65
+ const message = body.message ?? '';
66
+ let classification = '';
67
+ const namespaceTermination = message.match(/unable to create new content in namespace ["']?([A-Za-z0-9_.-]{1,63})["']? because it is being terminated/i);
68
+ const rbacDenial = message.match(/cannot (create|patch|update|delete|get|list|watch) resource ["']([A-Za-z0-9_.-]{1,64})["'][^\n]{0,160}?namespace ["']([A-Za-z0-9_.-]{1,63})["']/i);
69
+ const quotaDenial = message.match(/exceeded quota: ([A-Za-z0-9_.-]{1,63})/i);
70
+ const webhookDenial = message.match(/admission webhook ["']([A-Za-z0-9_.-]{1,128})["'] denied the request/i);
71
+ if (namespaceTermination)
72
+ classification = ` namespace=${namespaceTermination[1]} state=terminating`;
73
+ else if (rbacDenial)
74
+ classification = ` rbac=${rbacDenial[1]}:${rbacDenial[2]} namespace=${rbacDenial[3]}`;
75
+ else if (quotaDenial)
76
+ classification = ` quota=${quotaDenial[1]}`;
77
+ else if (webhookDenial)
78
+ classification = ` admission-webhook=${webhookDenial[1]}`;
79
+ const causes = (body.details?.causes ?? [])
80
+ .flatMap((cause) => {
81
+ const causeReason = boundedStatusToken(cause.reason, '');
82
+ const field = boundedStatusToken(cause.field, '');
83
+ return causeReason && field ? [`${causeReason}:${field}`] : causeReason ? [causeReason] : [];
84
+ })
85
+ .slice(0, 4);
86
+ return {
87
+ summary: ` reason=${reason} resource=${kind}/${name}${classification}${causes.length > 0 ? ` causes=${causes.join(',')}` : ''}${evidence.ok ? '' : ' diagnostics=Unavailable'}`,
88
+ cause: evidence.ok ? undefined : evidence.cause
89
+ };
90
+ }
91
+ function csiSnapshotAgentScripts() {
92
+ const client = `import { readFileSync } from 'node:fs';
93
+ import https from 'node:https';
94
+ const namespace = process.env.NAMESPACE;
95
+ const token = readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8');
96
+ const ca = readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');
97
+ const host = process.env.KUBERNETES_SERVICE_HOST;
98
+ const port = Number(process.env.KUBERNETES_SERVICE_PORT_HTTPS || 443);
99
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+ const once = (method, path, body) => new Promise((resolve, reject) => {
101
+ const req = https.request({ host, port, path, method, ca, headers: {
102
+ authorization: 'Bearer ' + token,
103
+ accept: 'application/json',
104
+ ...(body ? { 'content-type': method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' } : {})
105
+ } }, (res) => {
106
+ let raw = '';
107
+ res.setEncoding('utf8');
108
+ res.on('data', (chunk) => { raw += chunk; });
109
+ res.on('end', () => {
110
+ const status = res.statusCode || 500;
111
+ let parsed;
112
+ try { parsed = raw ? JSON.parse(raw) : undefined; } catch { parsed = undefined; }
113
+ if (status >= 400) reject(Object.assign(new Error(method + ' ' + path + ' returned HTTP ' + status), { status, retryAfter: Number(res.headers['retry-after']) || 0 }));
114
+ else resolve(parsed);
115
+ });
116
+ });
117
+ req.on('error', reject);
118
+ if (body) req.end(JSON.stringify(body)); else req.end();
119
+ });
120
+ // The API server sheds load with 429 (priority-and-fairness) and 503; both mean
121
+ // "not processed, try later", so retrying is safe for every verb. Anything else
122
+ // (4xx, 5xx that may have mutated) surfaces immediately.
123
+ const request = async (method, path, body) => {
124
+ for (let attempt = 1; ; attempt += 1) {
125
+ try { return await once(method, path, body); }
126
+ catch (error) {
127
+ const status = error?.status;
128
+ if (attempt >= 8 || !(status === 429 || status === 503)) throw error;
129
+ await sleep(error.retryAfter > 0 ? error.retryAfter * 1000 : Math.min(30000, 1000 * 2 ** (attempt - 1)));
130
+ }
131
+ }
132
+ };
133
+ const namespaced = (resource, name = '') => '/apis/snapshot.storage.k8s.io/v1/namespaces/' + encodeURIComponent(namespace) + '/' + resource + (name ? '/' + encodeURIComponent(name) : '');
134
+ const core = (resource, name = '') => '/api/v1/namespaces/' + encodeURIComponent(namespace) + '/' + resource + (name ? '/' + encodeURIComponent(name) : '');
135
+ `;
136
+ const snapshot = `${client}
137
+ const generation = Date.now().toString(36);
138
+ const name = (process.env.SNAPSHOT_PREFIX + '-' + generation).slice(0, 63).replace(/-+$/, '');
139
+ const created = await request('POST', namespaced('volumesnapshots'), {
140
+ apiVersion: 'snapshot.storage.k8s.io/v1', kind: 'VolumeSnapshot',
141
+ metadata: { name, namespace, labels: {
142
+ 'app.kubernetes.io/managed-by': 'vibes-infra-service-release',
143
+ 'vibes.dev/source-pvc': process.env.SOURCE_PVC,
144
+ 'vibes.dev/source-revision': process.env.SOURCE_REVISION,
145
+ 'vibes.dev/generation': generation,
146
+ 'vibes.dev/verified': 'false'
147
+ }, annotations: { 'vibes.dev/storage-schema-version': process.env.STORAGE_SCHEMA_VERSION } },
148
+ spec: { volumeSnapshotClassName: process.env.SNAPSHOT_CLASS, source: { persistentVolumeClaimName: process.env.SOURCE_PVC } }
149
+ });
150
+ const deadline = Date.now() + Number(process.env.TIMEOUT_SECONDS) * 1000;
151
+ while (Date.now() < deadline) {
152
+ const current = await request('GET', namespaced('volumesnapshots', created.metadata.name));
153
+ if (current.status?.error) throw new Error('VolumeSnapshot reported a provider error');
154
+ if (current.status?.readyToUse === true) process.exit(0);
155
+ await sleep(2000);
156
+ }
157
+ throw new Error('VolumeSnapshot did not become ready before the declared deadline');
158
+ `;
159
+ const verify = `${client}
160
+ const selector = encodeURIComponent('vibes.dev/source-pvc=' + process.env.SOURCE_PVC);
161
+ const listed = await request('GET', namespaced('volumesnapshots') + '?labelSelector=' + selector);
162
+ const ready = (listed.items || []).filter((item) => item.status?.readyToUse === true).sort((a, b) => String(b.metadata.creationTimestamp).localeCompare(String(a.metadata.creationTimestamp)));
163
+ const candidate = ready.find((item) => item.metadata.labels?.['vibes.dev/verified'] !== 'true');
164
+ if (!candidate) throw new Error('No ready unverified snapshot exists');
165
+ const generation = candidate.metadata.labels?.['vibes.dev/generation'];
166
+ if (!generation || candidate.metadata.labels?.['vibes.dev/source-revision'] !== process.env.SOURCE_REVISION) throw new Error('Snapshot generation or source revision label is missing');
167
+ const suffix = generation.slice(-16);
168
+ const pvcName = (process.env.VERIFY_PREFIX + '-pvc-' + suffix).slice(0, 63).replace(/-+$/, '');
169
+ const podName = (process.env.VERIFY_PREFIX + '-fsck-' + suffix).slice(0, 63).replace(/-+$/, '');
170
+ const deadline = Date.now() + Number(process.env.TIMEOUT_SECONDS) * 1000;
171
+ let pvcCreated = false;
172
+ let podCreated = false;
173
+ try {
174
+ await request('POST', core('persistentvolumeclaims'), {
175
+ apiVersion: 'v1', kind: 'PersistentVolumeClaim',
176
+ metadata: { name: pvcName, namespace, labels: { 'app.kubernetes.io/managed-by': 'vibes-infra-service-release', 'vibes.dev/restore-source': candidate.metadata.name } },
177
+ spec: { accessModes: ['ReadWriteOnce'], storageClassName: process.env.STORAGE_CLASS, resources: { requests: { storage: process.env.SCRATCH_SIZE } }, dataSource: { apiGroup: 'snapshot.storage.k8s.io', kind: 'VolumeSnapshot', name: candidate.metadata.name } }
178
+ });
179
+ pvcCreated = true;
180
+ let pvcBound = false;
181
+ while (Date.now() < deadline) {
182
+ const pvc = await request('GET', core('persistentvolumeclaims', pvcName));
183
+ if (pvc.status?.phase === 'Bound') { pvcBound = true; break; }
184
+ await sleep(2000);
185
+ }
186
+ if (!pvcBound) throw new Error('Scratch restore PVC did not bind before the declared deadline');
187
+ await request('POST', core('pods'), {
188
+ apiVersion: 'v1', kind: 'Pod',
189
+ metadata: { name: podName, namespace, labels: { 'app.kubernetes.io/managed-by': 'vibes-infra-service-release', 'vibes.dev/restore-source': candidate.metadata.name } },
190
+ spec: { restartPolicy: 'Never', securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: 'RuntimeDefault' } }, containers: [{
191
+ name: 'git-fsck', image: process.env.VERIFY_IMAGE, imagePullPolicy: 'IfNotPresent',
192
+ command: ['/bin/sh', '-c', 'set -eu; heads="$(find /verify -type f -name HEAD -print)"; test -n "$heads"; count=0; for head in $heads; do dir="$(dirname "$head")"; if [ -d "$dir/objects" ] && [ -d "$dir/refs" ]; then git --git-dir="$dir" fsck --full; count=$((count + 1)); fi; done; test "$count" -gt 0'],
193
+ volumeMounts: [{ name: 'restore', mountPath: '/verify', readOnly: true }],
194
+ securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: ['ALL'] } }
195
+ }], volumes: [{ name: 'restore', persistentVolumeClaim: { claimName: pvcName, readOnly: true } }] }
196
+ });
197
+ podCreated = true;
198
+ let succeeded = false;
199
+ while (Date.now() < deadline) {
200
+ const pod = await request('GET', core('pods', podName));
201
+ if (pod.status?.phase === 'Succeeded') { succeeded = true; break; }
202
+ if (pod.status?.phase === 'Failed') throw new Error('Scratch restore git fsck failed');
203
+ await sleep(2000);
204
+ }
205
+ if (!succeeded) throw new Error('Scratch restore git fsck exceeded the declared deadline');
206
+ await request('PATCH', namespaced('volumesnapshots', candidate.metadata.name), { metadata: { labels: { ...candidate.metadata.labels, 'vibes.dev/verified': 'true' }, annotations: { ...candidate.metadata.annotations, 'vibes.dev/verified-at': new Date().toISOString() } } });
207
+ const after = await request('GET', namespaced('volumesnapshots') + '?labelSelector=' + selector);
208
+ const verified = (after.items || []).filter((item) => item.status?.readyToUse === true && item.metadata.labels?.['vibes.dev/verified'] === 'true').sort((a, b) => String(b.metadata.creationTimestamp).localeCompare(String(a.metadata.creationTimestamp)));
209
+ const keep = Number(process.env.KEEP_VERIFIED_GENERATIONS);
210
+ if (verified.length > keep) {
211
+ if (process.env.DELETION_POLICY !== 'Delete') throw new Error('Refusing snapshot pruning without deletionPolicy=Delete');
212
+ const retained = new Set(verified.slice(0, keep).map((item) => item.metadata.name));
213
+ const cutoff = String(verified[keep - 1].metadata.creationTimestamp);
214
+ for (const item of (after.items || [])) {
215
+ if (!retained.has(item.metadata.name) && String(item.metadata.creationTimestamp) < cutoff) {
216
+ await request('DELETE', namespaced('volumesnapshots', item.metadata.name));
217
+ }
218
+ }
219
+ }
220
+ } finally {
221
+ // best-effort cleanup: scratch restore pod/PVC must not leak if verification fails
222
+ if (podCreated) await request('DELETE', core('pods', podName)).catch(() => {});
223
+ if (pvcCreated) await request('DELETE', core('persistentvolumeclaims', pvcName)).catch(() => {});
224
+ }
225
+ `;
226
+ return { snapshot, verify };
227
+ }
228
+ function requiredSecrets(descriptor) {
229
+ const byName = new Map();
230
+ for (const binding of descriptor.secretBindings) {
231
+ const keys = byName.get(binding.target.name) ?? new Set();
232
+ keys.add(binding.target.key);
233
+ byName.set(binding.target.name, keys);
234
+ }
235
+ if (descriptor.config.certificate) {
236
+ const tls = descriptor.controlPlaneRouter.tlsSecretRef;
237
+ const keys = byName.get(tls.name) ?? new Set();
238
+ keys.add(tls.certificateKey);
239
+ keys.add(tls.privateKeyKey);
240
+ byName.set(tls.name, keys);
241
+ }
242
+ return [...byName.entries()]
243
+ .sort(([left], [right]) => left.localeCompare(right))
244
+ .map(([name, keys]) => ({
245
+ namespace: descriptor.namespace,
246
+ name,
247
+ keys: [...keys].sort()
248
+ }));
249
+ }
250
+ function normalizePublicKey(value) {
251
+ const [type, encoded] = value.trim().split(/\s+/, 3);
252
+ if (type !== 'ssh-ed25519' || !encoded)
253
+ throw new Error('SSH host public key must be OpenSSH Ed25519 format.');
254
+ const blob = Uint8Array.from(atob(encoded), char => char.charCodeAt(0));
255
+ if (blob.length < 4 ||
256
+ new DataView(blob.buffer, blob.byteOffset, blob.byteLength).getUint32(0) !== 11 ||
257
+ new TextDecoder().decode(blob.subarray(4, 15)) !== 'ssh-ed25519') {
258
+ throw new Error('SSH host public key did not contain an Ed25519 key blob.');
259
+ }
260
+ return `${type} ${encoded}`;
261
+ }
262
+ function publicKeyFingerprint(publicKey) {
263
+ const normalized = normalizePublicKey(publicKey);
264
+ const encoded = normalized.split(' ')[1];
265
+ const bytes = Uint8Array.from(atob(encoded), char => char.charCodeAt(0));
266
+ const hex = createSha256().update(bytes).digestHex();
267
+ const binary = (hex.match(/../g) ?? []).map(value => String.fromCharCode(parseInt(value, 16))).join('');
268
+ return `SHA256:${btoa(binary).replace(/=+$/, '')}`;
269
+ }
270
+ function labels(descriptor, component) {
271
+ const workload = component === 'gateway' ? descriptor.gateway : descriptor.stateful;
272
+ return {
273
+ 'app.kubernetes.io/name': workload.name,
274
+ 'app.kubernetes.io/component': component,
275
+ [LABEL_PART_OF]: descriptor.id,
276
+ 'app.kubernetes.io/managed-by': FIELD_MANAGER,
277
+ 'vibes.dev/revision': workload.revision
278
+ };
279
+ }
280
+ /** Where the migration Job mounts its authority Secret; declared to the Job as VIBES_SECRETS_MOUNT_DIR. */
281
+ const MIGRATION_SECRETS_MOUNT_DIR = '/run/secrets/platform';
282
+ /** The credential key the migration resolves; the projected file is named for it (mounted-files store). */
283
+ const MIGRATION_AUTHORITY_CREDENTIAL = 'VIBES_INTERNAL_SERVICE_TOKEN';
284
+ /** The tier the release declares for its stateful pod; the Job runs under the same one. */
285
+ function declaredEnvironmentTier(descriptor) {
286
+ const declared = descriptor.stateful.env.find((entry) => entry.source === 'literal' && entry.name === 'VIBES_ENVIRONMENT_TIER');
287
+ if (!declared || declared.source !== 'literal') {
288
+ throw new Error(`Service release ${descriptor.id} declares no VIBES_ENVIRONMENT_TIER on its stateful service; the repository migration Job needs the declared tier.`);
289
+ }
290
+ return declared.value;
291
+ }
292
+ function envProjection(env) {
293
+ return env.map((entry) => entry.source === 'literal'
294
+ ? { name: entry.name, value: entry.value }
295
+ : {
296
+ name: entry.name,
297
+ valueFrom: { secretKeyRef: { name: entry.secretRef.name, key: entry.secretRef.key } }
298
+ });
299
+ }
300
+ function volumeProjection(mounts) {
301
+ const unique = [...new Map(mounts.map((mount) => [mount.secretName, mount])).values()];
302
+ return {
303
+ mounts: unique.map((mount) => ({
304
+ name: cappedId('secret', mount.secretName),
305
+ mountPath: mount.mountPath,
306
+ readOnly: true
307
+ })),
308
+ volumes: unique.map((mount) => ({
309
+ name: cappedId('secret', mount.secretName),
310
+ secret: {
311
+ secretName: mount.secretName,
312
+ items: mount.keys?.length ? mount.keys.map((key) => ({ key, path: key })) : undefined
313
+ }
314
+ }))
315
+ };
316
+ }
317
+ function backupCredentialEnv(name, ref) {
318
+ if (!ref.store)
319
+ throw new Error(`Backup credential ${name} has no Kubernetes Secret store.`);
320
+ return { name, valueFrom: { secretKeyRef: { name: ref.store, key: ref.key } } };
321
+ }
322
+ function resource(manifest, namespace) {
323
+ const metadata = KubernetesMetadataSchema.parse(manifest.metadata);
324
+ return {
325
+ apiVersion: String(manifest.apiVersion),
326
+ kind: String(manifest.kind),
327
+ namespace,
328
+ name: metadata.name,
329
+ manifest
330
+ };
331
+ }
332
+ function applyChange(descriptor, change) {
333
+ const next = structuredClone(descriptor);
334
+ if (change.operation === 'scale') {
335
+ if (!Number.isInteger(change.gatewayReplicas) || (change.gatewayReplicas ?? 0) < 2) {
336
+ throw new Error('Scale requires gatewayReplicas >= 2; the durable service remains singleton.');
337
+ }
338
+ next.gateway.replicas = change.gatewayReplicas;
339
+ }
340
+ if (change.operation === 'rollback') {
341
+ if (!change.rollback) {
342
+ throw new Error('Rollback requires explicit sealed gateway and stateful image digests plus revisions.');
343
+ }
344
+ next.gateway.image = change.rollback.gateway.image;
345
+ next.gateway.revision = change.rollback.gateway.revision;
346
+ next.stateful.image = change.rollback.stateful.image;
347
+ next.stateful.revision = change.rollback.stateful.revision;
348
+ const currentSchema = descriptor.stateful.storage.schemaVersion;
349
+ const target = change.rollback.stateful.storageCompatibility;
350
+ if (target.readFrom > currentSchema || target.readThrough < currentSchema) {
351
+ throw new Error(`Rollback release cannot read PVC schema version ${currentSchema}.`);
352
+ }
353
+ if (target.writeVersion !== currentSchema) {
354
+ throw new Error(`Rollback cannot change PVC schema version ${currentSchema}; restore/migration is a separate operation.`);
355
+ }
356
+ next.stateful.storage.releaseCompatibility = target;
357
+ }
358
+ return ServiceReleaseDescriptorSchema.parse(next);
359
+ }
360
+ export function planDigitalOceanKubernetesServiceRelease(rawDescriptor, change) {
361
+ const descriptor = applyChange(ServiceReleaseDescriptorSchema.parse(rawDescriptor), change);
362
+ const declaredPublicKey = normalizePublicKey(descriptor.gateway.hostKeyAuthority.publicKey);
363
+ const declaredFingerprint = publicKeyFingerprint(declaredPublicKey);
364
+ if (declaredFingerprint !== descriptor.gateway.hostKeyAuthority.fingerprint) {
365
+ throw new Error('Source-owned SSH host public key does not match its declared fingerprint.');
366
+ }
367
+ const namespace = descriptor.namespace;
368
+ const gatewayLabels = labels(descriptor, 'gateway');
369
+ const statefulLabels = labels(descriptor, 'stateful');
370
+ const gatewaySecrets = volumeProjection(descriptor.gateway.secretMounts);
371
+ const statefulSecrets = volumeProjection(descriptor.stateful.secretMounts);
372
+ const dnsOwnerRef = descriptor.config.dnsOwnerRef;
373
+ const gatewayServiceName = cappedId(descriptor.gateway.name, 'public');
374
+ const statefulPvcName = cappedId(descriptor.stateful.name, 'data');
375
+ const routerConfigName = cappedId(descriptor.id, 'router');
376
+ const routerTls = descriptor.controlPlaneRouter.tlsSecretRef;
377
+ const hostAuthority = descriptor.gateway.hostKeyAuthority;
378
+ const hostSecret = hostAuthority.secretRef;
379
+ const backup = descriptor.stateful.backup;
380
+ const repositoryMigration = descriptor.stateful.repositoryMigration;
381
+ const certificate = descriptor.config.certificate;
382
+ const imageRegistry = descriptor.config.imageRegistry;
383
+ const imagePullSecrets = imageRegistry ? [{ name: imageRegistry.name }] : [];
384
+ const privateGitdOrigin = `http://${descriptor.stateful.name}:${descriptor.stateful.port}`;
385
+ const gatewayRuntimeEnv = [
386
+ { name: 'GIT_SSH_GATEWAY_PORT', value: String(descriptor.gateway.listenPort) },
387
+ { name: 'GITD_SESSION_URL', value: privateGitdOrigin },
388
+ {
389
+ name: 'VIBES_SSH_HOST_KEY_FILE',
390
+ value: `${hostSecret.mountPath}/${hostSecret.privateKeyKey}`
391
+ },
392
+ ...envProjection(descriptor.gateway.env)
393
+ ];
394
+ const statefulRuntimeEnv = [
395
+ { name: 'GITD_HOST', value: '0.0.0.0' },
396
+ { name: 'GITD_PORT', value: String(descriptor.stateful.port) },
397
+ { name: 'GITD_ROOT', value: descriptor.stateful.storage.mountPath },
398
+ { name: 'HOME', value: '/tmp' },
399
+ { name: 'TMPDIR', value: '/tmp' },
400
+ ...envProjection(descriptor.stateful.env)
401
+ ];
402
+ const podSecurityContext = {
403
+ runAsNonRoot: true,
404
+ runAsUser: 65532,
405
+ runAsGroup: 65532,
406
+ fsGroup: 65532,
407
+ fsGroupChangePolicy: 'OnRootMismatch',
408
+ seccompProfile: { type: 'RuntimeDefault' }
409
+ };
410
+ const containerSecurityContext = {
411
+ allowPrivilegeEscalation: false,
412
+ readOnlyRootFilesystem: true,
413
+ capabilities: { drop: ['ALL'] }
414
+ };
415
+ const tcpHealthProbes = (port) => ({
416
+ startupProbe: {
417
+ tcpSocket: { port },
418
+ periodSeconds: 2,
419
+ timeoutSeconds: 1,
420
+ failureThreshold: 30
421
+ },
422
+ readinessProbe: {
423
+ tcpSocket: { port },
424
+ periodSeconds: 5,
425
+ timeoutSeconds: 2,
426
+ failureThreshold: 3
427
+ },
428
+ livenessProbe: {
429
+ tcpSocket: { port },
430
+ periodSeconds: 10,
431
+ timeoutSeconds: 2,
432
+ failureThreshold: 3
433
+ }
434
+ });
435
+ const routerHealthProbes = {
436
+ startupProbe: {
437
+ httpGet: { path: '/healthz', port: 'https', scheme: 'HTTPS' },
438
+ periodSeconds: 2,
439
+ timeoutSeconds: 1,
440
+ failureThreshold: 30
441
+ },
442
+ readinessProbe: {
443
+ httpGet: { path: '/healthz', port: 'https', scheme: 'HTTPS' },
444
+ periodSeconds: 5,
445
+ timeoutSeconds: 2,
446
+ failureThreshold: 3
447
+ },
448
+ livenessProbe: {
449
+ httpGet: { path: '/healthz', port: 'https', scheme: 'HTTPS' },
450
+ periodSeconds: 10,
451
+ timeoutSeconds: 2,
452
+ failureThreshold: 3
453
+ }
454
+ };
455
+ const backupLabels = {
456
+ 'app.kubernetes.io/name': descriptor.stateful.name,
457
+ 'app.kubernetes.io/component': 'backup',
458
+ [LABEL_PART_OF]: descriptor.id,
459
+ 'app.kubernetes.io/managed-by': FIELD_MANAGER
460
+ };
461
+ const resticBackup = backup.mode === 'restic-s3' ? backup : undefined;
462
+ const csiBackup = backup.mode === 'csi-volume-snapshot' ? backup : undefined;
463
+ const backupEnv = resticBackup
464
+ ? [
465
+ { name: 'RESTIC_REPOSITORY', value: resticBackup.repository },
466
+ { name: 'HOME', value: '/work' },
467
+ { name: 'TMPDIR', value: '/work/tmp' },
468
+ backupCredentialEnv('AWS_ACCESS_KEY_ID', resticBackup.credentials.accessKeyId),
469
+ backupCredentialEnv('AWS_SECRET_ACCESS_KEY', resticBackup.credentials.secretAccessKey),
470
+ backupCredentialEnv('RESTIC_PASSWORD', resticBackup.credentials.encryptionKey)
471
+ ]
472
+ : [];
473
+ const backupCommand = resticBackup
474
+ ? `set -eu
475
+ mkdir -p /work/tmp
476
+ restic cat config >/dev/null 2>&1 || restic init
477
+ restic backup /data --tag ${descriptor.id} --tag schema-v${descriptor.stateful.storage.schemaVersion}
478
+ restic forget --prune --keep-daily ${resticBackup.retention.keepDaily} --keep-weekly ${resticBackup.retention.keepWeekly} --keep-monthly ${resticBackup.retention.keepMonthly}
479
+ `
480
+ : '';
481
+ const verifyCommand = resticBackup
482
+ ? `set -eu
483
+ mkdir -p /work/tmp
484
+ restic restore latest --target /restore
485
+ find /restore -type f -name HEAD > /work/git-heads
486
+ count=0
487
+ while IFS= read -r head; do
488
+ dir="$(dirname "$head")"
489
+ if [ -d "$dir/objects" ] && [ -d "$dir/refs" ]; then
490
+ git --git-dir="$dir" fsck --full
491
+ count=$((count + 1))
492
+ fi
493
+ done < /work/git-heads
494
+ test "$count" -gt 0
495
+ `
496
+ : '';
497
+ const caddyfile = `{
498
+ admin off
499
+ auto_https off
500
+ }
501
+
502
+ :${descriptor.controlPlaneRouter.listenPort} {
503
+ tls /run/secrets/tls/${routerTls.certificateKey} /run/secrets/tls/${routerTls.privateKeyKey}
504
+ @git_info_refs {
505
+ method GET
506
+ path_regexp git_info_refs ^/~?[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*/[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*\\.git/info/refs$
507
+ query service=git-upload-pack service=git-receive-pack
508
+ }
509
+ @git_rpc {
510
+ method POST
511
+ path_regexp git_rpc ^/~?[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*/[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*\\.git/(git-upload-pack|git-receive-pack)$
512
+ }
513
+ @control_plane path ${descriptor.controlPlaneRouter.allowedPathPrefixes.map((prefix) => `${prefix}*`).join(' ')}${descriptor.controlPlaneRouter.proxyRepoBrowse
514
+ ? `
515
+ @repo_browse {
516
+ method GET
517
+ path_regexp repo_browse ^/~?[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*/[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*(\\.git)?/browse(/.*)?$
518
+ }`
519
+ : ''}${descriptor.controlPlaneRouter.proxyMergeControl
520
+ ? `
521
+ @merge_control {
522
+ method POST
523
+ path_regexp merge_control ^/~?[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*/[A-Za-z0-9]+([._-]?[A-Za-z0-9]+)*/(merge-preview|merge|stack-operation)$
524
+ }`
525
+ : ''}
526
+ route {
527
+ reverse_proxy @git_info_refs ${privateGitdOrigin} {
528
+ header_up Authorization {http.request.header.Authorization}
529
+ flush_interval -1
530
+ }
531
+ reverse_proxy @git_rpc ${privateGitdOrigin} {
532
+ header_up Authorization {http.request.header.Authorization}
533
+ flush_interval -1
534
+ }
535
+ reverse_proxy @control_plane ${privateGitdOrigin} {
536
+ header_up Authorization {http.request.header.Authorization}
537
+ }${descriptor.controlPlaneRouter.proxyRepoBrowse
538
+ ? `
539
+ reverse_proxy @repo_browse ${privateGitdOrigin} {
540
+ header_up Authorization {http.request.header.Authorization}
541
+ }`
542
+ : ''}${descriptor.controlPlaneRouter.proxyMergeControl
543
+ ? `
544
+ reverse_proxy @merge_control ${privateGitdOrigin} {
545
+ header_up Authorization {http.request.header.Authorization}
546
+ }`
547
+ : ''}
548
+ respond /healthz 200
549
+ ${descriptor.controlPlaneRouter.fallbackRedirect ? `redir * ${descriptor.controlPlaneRouter.fallbackRedirect}{uri} 302` : 'respond 404'}
550
+ }
551
+ }
552
+ `;
553
+ const lbAnnotations = {
554
+ ...descriptor.config.loadBalancerAnnotations,
555
+ 'service.beta.kubernetes.io/do-loadbalancer-name': cappedId(descriptor.id, 'ssh'),
556
+ 'vibes.dev/dns-owner-ref': dnsOwnerRef
557
+ };
558
+ const backupResources = resticBackup
559
+ ? [
560
+ resource({
561
+ apiVersion: 'batch/v1',
562
+ kind: 'CronJob',
563
+ metadata: {
564
+ name: cappedId(descriptor.stateful.name, 'backup'),
565
+ namespace,
566
+ labels: { ...backupLabels, 'vibes.dev/egress-profile': 'object-backup' }
567
+ },
568
+ spec: {
569
+ schedule: resticBackup.schedule,
570
+ concurrencyPolicy: 'Forbid',
571
+ successfulJobsHistoryLimit: 3,
572
+ failedJobsHistoryLimit: 3,
573
+ jobTemplate: {
574
+ spec: {
575
+ activeDeadlineSeconds: resticBackup.restoreVerify.timeoutSeconds,
576
+ backoffLimit: 1,
577
+ template: {
578
+ metadata: {
579
+ labels: { ...backupLabels, 'vibes.dev/egress-profile': 'object-backup' }
580
+ },
581
+ spec: {
582
+ restartPolicy: 'Never',
583
+ securityContext: podSecurityContext,
584
+ affinity: {
585
+ podAffinity: {
586
+ requiredDuringSchedulingIgnoredDuringExecution: [
587
+ {
588
+ labelSelector: {
589
+ matchLabels: {
590
+ 'app.kubernetes.io/name': descriptor.stateful.name,
591
+ 'app.kubernetes.io/component': 'stateful'
592
+ }
593
+ },
594
+ topologyKey: 'kubernetes.io/hostname'
595
+ }
596
+ ]
597
+ }
598
+ },
599
+ containers: [
600
+ {
601
+ name: 'backup',
602
+ image: resticBackup.toolImage,
603
+ imagePullPolicy: 'IfNotPresent',
604
+ command: ['/bin/sh', '-c', backupCommand],
605
+ env: backupEnv,
606
+ volumeMounts: [
607
+ { name: 'source-data', mountPath: '/data', readOnly: true },
608
+ { name: 'work', mountPath: '/work' }
609
+ ],
610
+ securityContext: containerSecurityContext
611
+ }
612
+ ],
613
+ volumes: [
614
+ {
615
+ name: 'source-data',
616
+ persistentVolumeClaim: { claimName: statefulPvcName, readOnly: true }
617
+ },
618
+ { name: 'work', emptyDir: {} }
619
+ ]
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+ }, namespace),
626
+ resource({
627
+ apiVersion: 'batch/v1',
628
+ kind: 'CronJob',
629
+ metadata: {
630
+ name: cappedId(descriptor.stateful.name, 'restore-verify'),
631
+ namespace,
632
+ labels: {
633
+ ...backupLabels,
634
+ 'app.kubernetes.io/component': 'restore-verify',
635
+ 'vibes.dev/egress-profile': 'object-backup'
636
+ }
637
+ },
638
+ spec: {
639
+ schedule: resticBackup.restoreVerify.schedule,
640
+ concurrencyPolicy: 'Forbid',
641
+ successfulJobsHistoryLimit: 3,
642
+ failedJobsHistoryLimit: 3,
643
+ jobTemplate: {
644
+ spec: {
645
+ activeDeadlineSeconds: resticBackup.restoreVerify.timeoutSeconds,
646
+ backoffLimit: 0,
647
+ template: {
648
+ metadata: {
649
+ labels: {
650
+ ...backupLabels,
651
+ 'app.kubernetes.io/component': 'restore-verify',
652
+ 'vibes.dev/egress-profile': 'object-backup'
653
+ }
654
+ },
655
+ spec: {
656
+ restartPolicy: 'Never',
657
+ securityContext: podSecurityContext,
658
+ containers: [
659
+ {
660
+ name: 'restore-verify',
661
+ image: resticBackup.toolImage,
662
+ imagePullPolicy: 'IfNotPresent',
663
+ command: ['/bin/sh', '-c', verifyCommand],
664
+ env: backupEnv,
665
+ volumeMounts: [
666
+ { name: 'restore-scratch', mountPath: '/restore' },
667
+ { name: 'work', mountPath: '/work' }
668
+ ],
669
+ securityContext: containerSecurityContext
670
+ }
671
+ ],
672
+ volumes: [
673
+ {
674
+ name: 'restore-scratch',
675
+ emptyDir: { sizeLimit: resticBackup.restoreVerify.scratchSize }
676
+ },
677
+ { name: 'work', emptyDir: {} }
678
+ ]
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
684
+ }, namespace)
685
+ ]
686
+ : (() => {
687
+ if (!csiBackup)
688
+ throw new Error('Service release backup mode was not recognized.');
689
+ const snapshotBackup = csiBackup;
690
+ const scripts = csiSnapshotAgentScripts();
691
+ const configName = cappedId(descriptor.stateful.name, 'snapshot-agent');
692
+ const serviceAccountName = cappedId(descriptor.stateful.name, 'snapshot-agent');
693
+ const snapshotName = cappedId(descriptor.stateful.name, 'snapshot');
694
+ const verifyName = cappedId(descriptor.stateful.name, 'snapshot-verify');
695
+ const snapshotLabels = { ...backupLabels, 'vibes.dev/backup-mode': 'csi-volume-snapshot' };
696
+ // Requests are the co-scheduling budget, not the working set: the snapshot
697
+ // agent must fit BESIDE the stateful pod on its RWO node (podAffinity, so
698
+ // `sync -f /data` sees the live filesystem), and the namespace LimitRange
699
+ // default of 250m/256Mi could not fit next to gitd on a 2-vCPU node —
700
+ // every daily snapshot from 2026-08-20 to 2026-08-29 sat Pending until
701
+ // its deadline. Both agents only poll the API server from Node.
702
+ const agentResources = {
703
+ requests: { cpu: '20m', memory: '64Mi' },
704
+ limits: { cpu: '500m', memory: '256Mi' }
705
+ };
706
+ const commonEnv = [
707
+ { name: 'NAMESPACE', valueFrom: { fieldRef: { fieldPath: 'metadata.namespace' } } },
708
+ { name: 'SOURCE_PVC', value: statefulPvcName },
709
+ { name: 'SOURCE_REVISION', value: descriptor.stateful.revision },
710
+ {
711
+ name: 'STORAGE_SCHEMA_VERSION',
712
+ value: String(descriptor.stateful.storage.schemaVersion)
713
+ },
714
+ { name: 'SNAPSHOT_CLASS', value: snapshotBackup.snapshotClassName },
715
+ { name: 'DELETION_POLICY', value: snapshotBackup.deletionPolicy },
716
+ { name: 'TIMEOUT_SECONDS', value: String(snapshotBackup.restoreVerify.timeoutSeconds) }
717
+ ];
718
+ return [
719
+ resource({
720
+ apiVersion: 'v1',
721
+ kind: 'ConfigMap',
722
+ metadata: { name: configName, namespace, labels: snapshotLabels },
723
+ data: { 'snapshot.mjs': scripts.snapshot, 'verify.mjs': scripts.verify }
724
+ }, namespace),
725
+ resource({
726
+ apiVersion: 'v1',
727
+ kind: 'ServiceAccount',
728
+ metadata: { name: serviceAccountName, namespace, labels: snapshotLabels },
729
+ automountServiceAccountToken: true,
730
+ imagePullSecrets
731
+ }, namespace),
732
+ resource({
733
+ apiVersion: 'rbac.authorization.k8s.io/v1',
734
+ kind: 'Role',
735
+ metadata: { name: serviceAccountName, namespace, labels: snapshotLabels },
736
+ rules: [
737
+ {
738
+ apiGroups: ['snapshot.storage.k8s.io'],
739
+ resources: ['volumesnapshots'],
740
+ verbs: ['get', 'list', 'create', 'patch', 'delete']
741
+ },
742
+ {
743
+ apiGroups: [''],
744
+ resources: ['persistentvolumeclaims', 'pods'],
745
+ verbs: ['get', 'list', 'create', 'delete']
746
+ }
747
+ ]
748
+ }, namespace),
749
+ resource({
750
+ apiVersion: 'rbac.authorization.k8s.io/v1',
751
+ kind: 'RoleBinding',
752
+ metadata: { name: serviceAccountName, namespace, labels: snapshotLabels },
753
+ roleRef: {
754
+ apiGroup: 'rbac.authorization.k8s.io',
755
+ kind: 'Role',
756
+ name: serviceAccountName
757
+ },
758
+ subjects: [{ kind: 'ServiceAccount', name: serviceAccountName, namespace }]
759
+ }, namespace),
760
+ resource({
761
+ apiVersion: 'batch/v1',
762
+ kind: 'CronJob',
763
+ metadata: { name: snapshotName, namespace, labels: snapshotLabels },
764
+ spec: {
765
+ schedule: snapshotBackup.schedule,
766
+ concurrencyPolicy: 'Forbid',
767
+ successfulJobsHistoryLimit: 3,
768
+ failedJobsHistoryLimit: 3,
769
+ jobTemplate: {
770
+ spec: {
771
+ activeDeadlineSeconds: snapshotBackup.restoreVerify.timeoutSeconds,
772
+ backoffLimit: 0,
773
+ template: {
774
+ metadata: { labels: snapshotLabels },
775
+ spec: {
776
+ serviceAccountName,
777
+ restartPolicy: 'Never',
778
+ securityContext: podSecurityContext,
779
+ affinity: {
780
+ podAffinity: {
781
+ requiredDuringSchedulingIgnoredDuringExecution: [
782
+ {
783
+ labelSelector: {
784
+ matchLabels: {
785
+ 'app.kubernetes.io/name': descriptor.stateful.name,
786
+ 'app.kubernetes.io/component': 'stateful'
787
+ }
788
+ },
789
+ topologyKey: 'kubernetes.io/hostname'
790
+ }
791
+ ]
792
+ }
793
+ },
794
+ containers: [
795
+ {
796
+ name: 'snapshot',
797
+ image: snapshotBackup.toolImage,
798
+ imagePullPolicy: 'IfNotPresent',
799
+ command: [
800
+ '/bin/sh',
801
+ '-c',
802
+ 'set -eu; sync -f /data; node /opt/vibes-backup/snapshot.mjs'
803
+ ],
804
+ env: [...commonEnv, { name: 'SNAPSHOT_PREFIX', value: snapshotName }],
805
+ resources: agentResources,
806
+ volumeMounts: [
807
+ { name: 'source-data', mountPath: '/data', readOnly: true },
808
+ { name: 'scripts', mountPath: '/opt/vibes-backup', readOnly: true },
809
+ { name: 'tmp', mountPath: '/tmp' }
810
+ ],
811
+ securityContext: containerSecurityContext
812
+ }
813
+ ],
814
+ volumes: [
815
+ {
816
+ name: 'source-data',
817
+ persistentVolumeClaim: { claimName: statefulPvcName, readOnly: true }
818
+ },
819
+ { name: 'scripts', configMap: { name: configName, defaultMode: 0o444 } },
820
+ { name: 'tmp', emptyDir: {} }
821
+ ]
822
+ }
823
+ }
824
+ }
825
+ }
826
+ }
827
+ }, namespace),
828
+ resource({
829
+ apiVersion: 'batch/v1',
830
+ kind: 'CronJob',
831
+ metadata: {
832
+ name: verifyName,
833
+ namespace,
834
+ labels: { ...snapshotLabels, 'app.kubernetes.io/component': 'restore-verify' }
835
+ },
836
+ spec: {
837
+ schedule: snapshotBackup.restoreVerify.schedule,
838
+ concurrencyPolicy: 'Forbid',
839
+ successfulJobsHistoryLimit: 3,
840
+ failedJobsHistoryLimit: 3,
841
+ jobTemplate: {
842
+ spec: {
843
+ activeDeadlineSeconds: snapshotBackup.restoreVerify.timeoutSeconds,
844
+ backoffLimit: 0,
845
+ template: {
846
+ metadata: {
847
+ labels: {
848
+ ...snapshotLabels,
849
+ 'app.kubernetes.io/component': 'restore-verify'
850
+ }
851
+ },
852
+ spec: {
853
+ serviceAccountName,
854
+ restartPolicy: 'Never',
855
+ securityContext: podSecurityContext,
856
+ containers: [
857
+ {
858
+ name: 'restore-verify',
859
+ image: snapshotBackup.toolImage,
860
+ imagePullPolicy: 'IfNotPresent',
861
+ command: ['node', '/opt/vibes-backup/verify.mjs'],
862
+ env: [
863
+ ...commonEnv,
864
+ { name: 'VERIFY_PREFIX', value: verifyName },
865
+ { name: 'VERIFY_IMAGE', value: descriptor.stateful.image },
866
+ {
867
+ name: 'STORAGE_CLASS',
868
+ value: descriptor.stateful.storage.storageClassName
869
+ },
870
+ {
871
+ name: 'SCRATCH_SIZE',
872
+ value: snapshotBackup.restoreVerify.scratchSize
873
+ },
874
+ {
875
+ name: 'KEEP_VERIFIED_GENERATIONS',
876
+ value: String(snapshotBackup.retention.verifiedGenerations)
877
+ }
878
+ ],
879
+ resources: agentResources,
880
+ volumeMounts: [
881
+ { name: 'scripts', mountPath: '/opt/vibes-backup', readOnly: true },
882
+ { name: 'tmp', mountPath: '/tmp' }
883
+ ],
884
+ securityContext: containerSecurityContext
885
+ }
886
+ ],
887
+ volumes: [
888
+ { name: 'scripts', configMap: { name: configName, defaultMode: 0o444 } },
889
+ { name: 'tmp', emptyDir: {} }
890
+ ]
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ }, namespace),
897
+ resource({
898
+ apiVersion: 'cilium.io/v2',
899
+ kind: 'CiliumNetworkPolicy',
900
+ metadata: {
901
+ name: cappedId(descriptor.stateful.name, 'snapshot-api-egress'),
902
+ namespace
903
+ },
904
+ spec: {
905
+ endpointSelector: {
906
+ matchLabels: { 'vibes.dev/backup-mode': 'csi-volume-snapshot' }
907
+ },
908
+ egress: [
909
+ {
910
+ toEntities: ['kube-apiserver'],
911
+ toPorts: [{ ports: [{ port: '443', protocol: 'TCP' }] }]
912
+ }
913
+ ]
914
+ }
915
+ }, namespace)
916
+ ];
917
+ })();
918
+ const manifests = [
919
+ resource({
920
+ apiVersion: 'v1',
921
+ kind: 'Namespace',
922
+ metadata: { name: namespace, labels: { [LABEL_PART_OF]: descriptor.id } }
923
+ }),
924
+ resource({
925
+ apiVersion: 'v1',
926
+ kind: 'ConfigMap',
927
+ metadata: {
928
+ name: routerConfigName,
929
+ namespace,
930
+ labels: { [LABEL_PART_OF]: descriptor.id },
931
+ annotations: {
932
+ 'vibes.dev/https-smart-git-authority': 'gitd',
933
+ 'vibes.dev/https-smart-git-path-contract': descriptor.controlPlaneRouter.smartGit.pathContract
934
+ }
935
+ },
936
+ data: { Caddyfile: caddyfile }
937
+ }, namespace),
938
+ resource({
939
+ apiVersion: 'apps/v1',
940
+ kind: 'Deployment',
941
+ metadata: {
942
+ name: descriptor.gateway.name,
943
+ namespace,
944
+ labels: gatewayLabels,
945
+ annotations: {
946
+ 'vibes.dev/router-revision': descriptor.controlPlaneRouter.revision,
947
+ 'vibes.dev/ssh-host-key-fingerprint': hostAuthority.fingerprint,
948
+ 'vibes.dev/ssh-host-key-generation': String(hostAuthority.rotation.generation)
949
+ }
950
+ },
951
+ spec: {
952
+ replicas: descriptor.gateway.replicas,
953
+ strategy: {
954
+ type: 'RollingUpdate',
955
+ rollingUpdate: {
956
+ maxUnavailable: descriptor.gateway.maxUnavailable,
957
+ maxSurge: descriptor.gateway.maxSurge
958
+ }
959
+ },
960
+ selector: {
961
+ matchLabels: {
962
+ 'app.kubernetes.io/name': descriptor.gateway.name,
963
+ 'app.kubernetes.io/component': 'gateway'
964
+ }
965
+ },
966
+ template: {
967
+ metadata: {
968
+ labels: gatewayLabels,
969
+ annotations: {
970
+ 'vibes.dev/router-revision': descriptor.controlPlaneRouter.revision,
971
+ 'vibes.dev/ssh-host-key-fingerprint': hostAuthority.fingerprint,
972
+ 'vibes.dev/ssh-host-key-generation': String(hostAuthority.rotation.generation)
973
+ }
974
+ },
975
+ spec: {
976
+ securityContext: podSecurityContext,
977
+ topologySpreadConstraints: [
978
+ {
979
+ maxSkew: 1,
980
+ topologyKey: 'kubernetes.io/hostname',
981
+ whenUnsatisfiable: 'DoNotSchedule',
982
+ labelSelector: {
983
+ matchLabels: {
984
+ 'app.kubernetes.io/name': descriptor.gateway.name,
985
+ 'app.kubernetes.io/component': 'gateway'
986
+ }
987
+ }
988
+ }
989
+ ],
990
+ containers: [
991
+ {
992
+ name: 'gateway',
993
+ image: descriptor.gateway.image,
994
+ imagePullPolicy: 'IfNotPresent',
995
+ ports: [
996
+ { name: 'ssh', containerPort: descriptor.gateway.listenPort, protocol: 'TCP' }
997
+ ],
998
+ env: gatewayRuntimeEnv,
999
+ volumeMounts: [
1000
+ ...gatewaySecrets.mounts,
1001
+ { name: 'ssh-host-key', mountPath: hostSecret.mountPath, readOnly: true },
1002
+ { name: 'tmp', mountPath: '/tmp' }
1003
+ ],
1004
+ resources: descriptor.gateway.resources,
1005
+ securityContext: containerSecurityContext,
1006
+ ...tcpHealthProbes('ssh')
1007
+ },
1008
+ {
1009
+ name: 'https-router',
1010
+ image: descriptor.controlPlaneRouter.image,
1011
+ imagePullPolicy: 'IfNotPresent',
1012
+ ports: [
1013
+ {
1014
+ name: 'https',
1015
+ containerPort: descriptor.controlPlaneRouter.listenPort,
1016
+ protocol: 'TCP'
1017
+ }
1018
+ ],
1019
+ volumeMounts: [
1020
+ { name: 'router-config', mountPath: '/etc/caddy', readOnly: true },
1021
+ { name: 'router-tls', mountPath: '/run/secrets/tls', readOnly: true },
1022
+ { name: 'router-data', mountPath: '/data' },
1023
+ { name: 'router-runtime', mountPath: '/config' },
1024
+ { name: 'tmp', mountPath: '/tmp' }
1025
+ ],
1026
+ resources: descriptor.controlPlaneRouter.resources,
1027
+ securityContext: containerSecurityContext,
1028
+ ...routerHealthProbes
1029
+ }
1030
+ ],
1031
+ volumes: [
1032
+ ...gatewaySecrets.volumes,
1033
+ {
1034
+ name: 'ssh-host-key',
1035
+ secret: {
1036
+ secretName: hostSecret.name,
1037
+ items: [
1038
+ {
1039
+ key: hostSecret.privateKeyKey,
1040
+ path: hostSecret.privateKeyKey,
1041
+ mode: 0o440
1042
+ },
1043
+ { key: hostSecret.publicKeyKey, path: hostSecret.publicKeyKey, mode: 0o444 }
1044
+ ]
1045
+ }
1046
+ },
1047
+ {
1048
+ name: 'router-config',
1049
+ configMap: {
1050
+ name: routerConfigName,
1051
+ items: [{ key: 'Caddyfile', path: 'Caddyfile' }]
1052
+ }
1053
+ },
1054
+ {
1055
+ name: 'router-tls',
1056
+ secret: {
1057
+ secretName: routerTls.name,
1058
+ items: [
1059
+ {
1060
+ key: routerTls.certificateKey,
1061
+ path: routerTls.certificateKey,
1062
+ mode: 0o444
1063
+ },
1064
+ { key: routerTls.privateKeyKey, path: routerTls.privateKeyKey, mode: 0o440 }
1065
+ ]
1066
+ }
1067
+ },
1068
+ { name: 'router-data', emptyDir: {} },
1069
+ { name: 'router-runtime', emptyDir: {} },
1070
+ { name: 'tmp', emptyDir: {} }
1071
+ ]
1072
+ }
1073
+ }
1074
+ }
1075
+ }, namespace),
1076
+ resource({
1077
+ apiVersion: 'policy/v1',
1078
+ kind: 'PodDisruptionBudget',
1079
+ metadata: {
1080
+ name: cappedId(descriptor.gateway.name, 'availability'),
1081
+ namespace,
1082
+ labels: gatewayLabels
1083
+ },
1084
+ spec: {
1085
+ minAvailable: 2,
1086
+ selector: {
1087
+ matchLabels: {
1088
+ 'app.kubernetes.io/name': descriptor.gateway.name,
1089
+ 'app.kubernetes.io/component': 'gateway'
1090
+ }
1091
+ }
1092
+ }
1093
+ }, namespace),
1094
+ resource({
1095
+ apiVersion: 'v1',
1096
+ kind: 'Service',
1097
+ metadata: {
1098
+ name: gatewayServiceName,
1099
+ namespace,
1100
+ labels: gatewayLabels,
1101
+ annotations: lbAnnotations
1102
+ },
1103
+ spec: {
1104
+ type: 'LoadBalancer',
1105
+ externalTrafficPolicy: 'Local',
1106
+ selector: {
1107
+ 'app.kubernetes.io/name': descriptor.gateway.name,
1108
+ 'app.kubernetes.io/component': 'gateway'
1109
+ },
1110
+ ports: [
1111
+ { name: 'ssh', protocol: 'TCP', port: descriptor.gateway.port, targetPort: 'ssh' },
1112
+ {
1113
+ name: 'https',
1114
+ protocol: 'TCP',
1115
+ port: descriptor.controlPlaneRouter.publicPort,
1116
+ targetPort: 'https'
1117
+ }
1118
+ ]
1119
+ }
1120
+ }, namespace),
1121
+ resource({
1122
+ apiVersion: 'v1',
1123
+ kind: 'PersistentVolumeClaim',
1124
+ metadata: {
1125
+ name: statefulPvcName,
1126
+ namespace,
1127
+ labels: statefulLabels,
1128
+ annotations: {
1129
+ 'vibes.dev/storage-schema-version': String(descriptor.stateful.storage.schemaVersion)
1130
+ }
1131
+ },
1132
+ spec: {
1133
+ accessModes: ['ReadWriteOnce'],
1134
+ storageClassName: descriptor.stateful.storage.storageClassName
1135
+ ? descriptor.stateful.storage.storageClassName
1136
+ : undefined,
1137
+ resources: { requests: { storage: descriptor.stateful.storage.size } }
1138
+ }
1139
+ }, namespace),
1140
+ resource({
1141
+ apiVersion: 'v1',
1142
+ kind: 'Service',
1143
+ metadata: { name: descriptor.stateful.name, namespace, labels: statefulLabels },
1144
+ spec: {
1145
+ type: 'ClusterIP',
1146
+ clusterIP: 'None',
1147
+ publishNotReadyAddresses: false,
1148
+ selector: {
1149
+ 'app.kubernetes.io/name': descriptor.stateful.name,
1150
+ 'app.kubernetes.io/component': 'stateful'
1151
+ },
1152
+ ports: [
1153
+ {
1154
+ name: 'session',
1155
+ protocol: 'TCP',
1156
+ port: descriptor.stateful.port,
1157
+ targetPort: 'session'
1158
+ }
1159
+ ]
1160
+ }
1161
+ }, namespace),
1162
+ resource({
1163
+ apiVersion: 'apps/v1',
1164
+ kind: 'StatefulSet',
1165
+ metadata: {
1166
+ name: descriptor.stateful.name,
1167
+ namespace,
1168
+ labels: statefulLabels,
1169
+ annotations: {
1170
+ 'vibes.dev/storage-schema-version': String(descriptor.stateful.storage.schemaVersion)
1171
+ }
1172
+ },
1173
+ spec: {
1174
+ replicas: 1,
1175
+ serviceName: descriptor.stateful.name,
1176
+ updateStrategy: { type: 'RollingUpdate' },
1177
+ podManagementPolicy: 'OrderedReady',
1178
+ selector: {
1179
+ matchLabels: {
1180
+ 'app.kubernetes.io/name': descriptor.stateful.name,
1181
+ 'app.kubernetes.io/component': 'stateful'
1182
+ }
1183
+ },
1184
+ template: {
1185
+ metadata: { labels: statefulLabels },
1186
+ spec: {
1187
+ securityContext: podSecurityContext,
1188
+ containers: [
1189
+ {
1190
+ name: 'stateful',
1191
+ image: descriptor.stateful.image,
1192
+ imagePullPolicy: 'IfNotPresent',
1193
+ ports: [
1194
+ { name: 'session', containerPort: descriptor.stateful.port, protocol: 'TCP' }
1195
+ ],
1196
+ env: statefulRuntimeEnv,
1197
+ volumeMounts: [
1198
+ ...statefulSecrets.mounts,
1199
+ { name: 'data', mountPath: descriptor.stateful.storage.mountPath },
1200
+ { name: 'tmp', mountPath: '/tmp' }
1201
+ ],
1202
+ resources: descriptor.stateful.resources,
1203
+ securityContext: containerSecurityContext,
1204
+ ...tcpHealthProbes('session')
1205
+ }
1206
+ ],
1207
+ volumes: [
1208
+ ...statefulSecrets.volumes,
1209
+ {
1210
+ name: 'data',
1211
+ persistentVolumeClaim: { claimName: statefulPvcName, readOnly: false }
1212
+ },
1213
+ { name: 'tmp', emptyDir: {} }
1214
+ ]
1215
+ }
1216
+ }
1217
+ }
1218
+ }, namespace),
1219
+ ...backupResources,
1220
+ // The repository-migration Job is labeled with this egress profile, but
1221
+ // under default-deny nothing ever granted the profile its network: the
1222
+ // mirror-ssh run needs TCP 22 to the legacy source host and TCP 443 for
1223
+ // the platform inventory/lease APIs. Its absence surfaced as
1224
+ // "ssh: connect to host git.vibes.dev port 22: Connection timed out"
1225
+ // (2026-08-18, generation-1 attempt 3) — the two earlier attempts died
1226
+ // before reaching the network, keeping this gap latent. Port-scoped and
1227
+ // profile-scoped; the source hostname resolves via public DNS so the
1228
+ // destination stays an ipBlock, not a pinned address.
1229
+ ...(descriptor.stateful.repositoryMigration
1230
+ ? [
1231
+ resource({
1232
+ apiVersion: 'networking.k8s.io/v1',
1233
+ kind: 'NetworkPolicy',
1234
+ metadata: { name: cappedId(descriptor.id, 'migration-egress'), namespace },
1235
+ spec: {
1236
+ podSelector: {
1237
+ matchLabels: { 'vibes.dev/egress-profile': 'repository-migration' }
1238
+ },
1239
+ policyTypes: ['Egress'],
1240
+ egress: [
1241
+ {
1242
+ to: [{ ipBlock: { cidr: '0.0.0.0/0' } }],
1243
+ ports: [
1244
+ { protocol: 'TCP', port: 22 },
1245
+ { protocol: 'TCP', port: 443 }
1246
+ ]
1247
+ }
1248
+ ]
1249
+ }
1250
+ }, namespace)
1251
+ ]
1252
+ : []),
1253
+ resource({
1254
+ apiVersion: 'networking.k8s.io/v1',
1255
+ kind: 'NetworkPolicy',
1256
+ metadata: { name: cappedId(descriptor.id, 'default-deny-ingress'), namespace },
1257
+ spec: { podSelector: {}, policyTypes: ['Ingress'], ingress: [] }
1258
+ }, namespace),
1259
+ resource({
1260
+ apiVersion: 'networking.k8s.io/v1',
1261
+ kind: 'NetworkPolicy',
1262
+ metadata: { name: cappedId(descriptor.id, 'default-deny-egress'), namespace },
1263
+ spec: { podSelector: {}, policyTypes: ['Egress'], egress: [] }
1264
+ }, namespace),
1265
+ resource({
1266
+ apiVersion: 'networking.k8s.io/v1',
1267
+ kind: 'NetworkPolicy',
1268
+ metadata: { name: cappedId(descriptor.gateway.name, 'public-ssh'), namespace },
1269
+ spec: {
1270
+ podSelector: {
1271
+ matchLabels: {
1272
+ 'app.kubernetes.io/name': descriptor.gateway.name,
1273
+ 'app.kubernetes.io/component': 'gateway'
1274
+ }
1275
+ },
1276
+ policyTypes: ['Ingress'],
1277
+ ingress: [
1278
+ {
1279
+ from: [
1280
+ { ipBlock: { cidr: '0.0.0.0/0' } },
1281
+ { ipBlock: { cidr: '::/0' } },
1282
+ { namespaceSelector: {} }
1283
+ ],
1284
+ ports: [
1285
+ { protocol: 'TCP', port: descriptor.gateway.listenPort },
1286
+ { protocol: 'TCP', port: descriptor.controlPlaneRouter.listenPort }
1287
+ ]
1288
+ }
1289
+ ]
1290
+ }
1291
+ }, namespace),
1292
+ resource({
1293
+ apiVersion: 'networking.k8s.io/v1',
1294
+ kind: 'NetworkPolicy',
1295
+ metadata: { name: cappedId(descriptor.gateway.name, 'egress'), namespace },
1296
+ spec: {
1297
+ podSelector: {
1298
+ matchLabels: {
1299
+ 'app.kubernetes.io/name': descriptor.gateway.name,
1300
+ 'app.kubernetes.io/component': 'gateway'
1301
+ }
1302
+ },
1303
+ policyTypes: ['Egress'],
1304
+ egress: [
1305
+ {
1306
+ to: [
1307
+ {
1308
+ namespaceSelector: {
1309
+ matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' }
1310
+ },
1311
+ podSelector: { matchLabels: { 'k8s-app': 'kube-dns' } }
1312
+ }
1313
+ ],
1314
+ ports: [
1315
+ { protocol: 'UDP', port: 53 },
1316
+ { protocol: 'TCP', port: 53 }
1317
+ ]
1318
+ },
1319
+ {
1320
+ to: [
1321
+ {
1322
+ podSelector: {
1323
+ matchLabels: {
1324
+ 'app.kubernetes.io/name': descriptor.stateful.name,
1325
+ 'app.kubernetes.io/component': 'stateful'
1326
+ }
1327
+ }
1328
+ }
1329
+ ],
1330
+ ports: [{ protocol: 'TCP', port: descriptor.stateful.port }]
1331
+ },
1332
+ {
1333
+ to: descriptor.networkPolicy.egress.platformAuthCidrs.map((cidr) => ({
1334
+ ipBlock: { cidr }
1335
+ })),
1336
+ ports: [{ protocol: 'TCP', port: 443 }]
1337
+ }
1338
+ ]
1339
+ }
1340
+ }, namespace),
1341
+ resource({
1342
+ apiVersion: 'networking.k8s.io/v1',
1343
+ kind: 'NetworkPolicy',
1344
+ metadata: { name: cappedId(descriptor.stateful.name, 'private'), namespace },
1345
+ spec: {
1346
+ podSelector: {
1347
+ matchLabels: {
1348
+ 'app.kubernetes.io/name': descriptor.stateful.name,
1349
+ 'app.kubernetes.io/component': 'stateful'
1350
+ }
1351
+ },
1352
+ policyTypes: ['Ingress'],
1353
+ ingress: [
1354
+ {
1355
+ from: [
1356
+ {
1357
+ podSelector: {
1358
+ matchLabels: {
1359
+ 'app.kubernetes.io/name': descriptor.gateway.name,
1360
+ 'app.kubernetes.io/component': 'gateway'
1361
+ }
1362
+ }
1363
+ }
1364
+ ],
1365
+ ports: [{ protocol: 'TCP', port: descriptor.stateful.port }]
1366
+ }
1367
+ ]
1368
+ }
1369
+ }, namespace),
1370
+ ...(resticBackup
1371
+ ? [
1372
+ resource({
1373
+ apiVersion: 'networking.k8s.io/v1',
1374
+ kind: 'NetworkPolicy',
1375
+ metadata: { name: cappedId(descriptor.stateful.name, 'backup-egress'), namespace },
1376
+ spec: {
1377
+ podSelector: { matchLabels: { 'vibes.dev/egress-profile': 'object-backup' } },
1378
+ policyTypes: ['Egress'],
1379
+ egress: [
1380
+ {
1381
+ to: [
1382
+ {
1383
+ namespaceSelector: {
1384
+ matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' }
1385
+ },
1386
+ podSelector: { matchLabels: { 'k8s-app': 'kube-dns' } }
1387
+ }
1388
+ ],
1389
+ ports: [
1390
+ { protocol: 'UDP', port: 53 },
1391
+ { protocol: 'TCP', port: 53 }
1392
+ ]
1393
+ },
1394
+ ...(descriptor.networkPolicy.egress.objectBackupCidrs
1395
+ ? [
1396
+ {
1397
+ to: descriptor.networkPolicy.egress.objectBackupCidrs.map((cidr) => ({
1398
+ ipBlock: { cidr }
1399
+ })),
1400
+ ports: [{ protocol: 'TCP', port: 443 }]
1401
+ }
1402
+ ]
1403
+ : [])
1404
+ ]
1405
+ }
1406
+ }, namespace),
1407
+ ...(descriptor.networkPolicy.egress.objectBackupHostnames
1408
+ ? [
1409
+ resource({
1410
+ apiVersion: 'cilium.io/v2',
1411
+ kind: 'CiliumNetworkPolicy',
1412
+ metadata: {
1413
+ name: cappedId(descriptor.stateful.name, 'backup-fqdn-egress'),
1414
+ namespace
1415
+ },
1416
+ spec: {
1417
+ endpointSelector: {
1418
+ matchLabels: { 'vibes.dev/egress-profile': 'object-backup' }
1419
+ },
1420
+ egress: [
1421
+ {
1422
+ toFQDNs: descriptor.networkPolicy.egress.objectBackupHostnames.map((matchName) => ({ matchName })),
1423
+ toPorts: [{ ports: [{ port: '443', protocol: 'TCP' }] }]
1424
+ }
1425
+ ]
1426
+ }
1427
+ }, namespace)
1428
+ ]
1429
+ : [])
1430
+ ]
1431
+ : [])
1432
+ ];
1433
+ return {
1434
+ provider: 'digitalocean-kubernetes',
1435
+ cluster: descriptor.config.cluster,
1436
+ operation: change.operation,
1437
+ fieldManager: FIELD_MANAGER,
1438
+ conflictPolicy: 'fail',
1439
+ dns: { hostname: descriptor.gateway.hostname, ownerRef: dnsOwnerRef, managedByThisPlan: false },
1440
+ hostKeyAuthority: {
1441
+ publicKey: declaredPublicKey,
1442
+ fingerprint: hostAuthority.fingerprint,
1443
+ generation: hostAuthority.rotation.generation,
1444
+ source: hostAuthority.source,
1445
+ secret: {
1446
+ namespace,
1447
+ name: hostSecret.name,
1448
+ provisioningOwner: 'secret-management',
1449
+ rotationStrategy: 'preprovisioned-atomic'
1450
+ }
1451
+ },
1452
+ namespaceQuota: descriptor.config.namespaceQuota
1453
+ ? {
1454
+ namespace,
1455
+ name: descriptor.config.namespaceQuota.name,
1456
+ hard: { ...descriptor.config.namespaceQuota.hard }
1457
+ }
1458
+ : undefined,
1459
+ imageRegistry: imageRegistry
1460
+ ? {
1461
+ provider: imageRegistry.provider,
1462
+ name: imageRegistry.name,
1463
+ pullSecretName: imageRegistry.name
1464
+ }
1465
+ : undefined,
1466
+ backup: backup.mode === 'restic-s3'
1467
+ ? {
1468
+ mode: 'restic-s3',
1469
+ repository: backup.repository,
1470
+ encryption: 'restic-repository-key',
1471
+ schedule: backup.schedule,
1472
+ verifySchedule: backup.restoreVerify.schedule,
1473
+ retention: backup.retention
1474
+ }
1475
+ : {
1476
+ mode: 'csi-volume-snapshot',
1477
+ snapshotClassName: backup.snapshotClassName,
1478
+ deletionPolicy: backup.deletionPolicy,
1479
+ schedule: backup.schedule,
1480
+ verifySchedule: backup.restoreVerify.schedule,
1481
+ retention: backup.retention
1482
+ },
1483
+ repositoryMigration: repositoryMigration
1484
+ ? {
1485
+ migrationId: repositoryMigration.migrationId,
1486
+ mode: repositoryMigration.mode,
1487
+ sourceOrigin: repositoryMigration.source.origin,
1488
+ inventoryUrl: repositoryMigration.source.inventoryUrl,
1489
+ sshLeaseUrl: repositoryMigration.source.sshLeaseUrl,
1490
+ releaseRevision: descriptor.stateful.revision,
1491
+ expectedRepositoryCount: repositoryMigration.expected.repositoryCount,
1492
+ expectedOwners: repositoryMigration.expected.owners.map((owner) => ({ ...owner })),
1493
+ inventoryAuthoritySecret: {
1494
+ namespace,
1495
+ name: repositoryMigration.source.inventoryAuthoritySecretRef.name,
1496
+ key: repositoryMigration.source.inventoryAuthoritySecretRef.key
1497
+ },
1498
+ environmentTier: declaredEnvironmentTier(descriptor),
1499
+ destination: {
1500
+ pvcName: statefulPvcName,
1501
+ mountPath: descriptor.stateful.storage.mountPath
1502
+ },
1503
+ statefulName: descriptor.stateful.name,
1504
+ image: descriptor.stateful.image,
1505
+ receipt: { namespace, name: repositoryMigration.receipt.configMapName },
1506
+ stablePasses: repositoryMigration.convergence.stablePasses,
1507
+ rollback: repositoryMigration.rollback.mode
1508
+ }
1509
+ : undefined,
1510
+ certificates: certificate
1511
+ ? [
1512
+ {
1513
+ namespace,
1514
+ name: routerTls.name,
1515
+ secretName: routerTls.name,
1516
+ dnsNames: [descriptor.gateway.hostname],
1517
+ issuerRef: certificate.issuerRef,
1518
+ duration: certificate.duration,
1519
+ renewBefore: certificate.renewBefore,
1520
+ privateKey: certificate.privateKey
1521
+ }
1522
+ ]
1523
+ : [],
1524
+ requiresCiliumNetworkPolicy: backup.mode === 'csi-volume-snapshot' ||
1525
+ Boolean(descriptor.networkPolicy.egress.objectBackupHostnames),
1526
+ requiredSecrets: requiredSecrets(descriptor),
1527
+ resources: manifests,
1528
+ warnings: [
1529
+ `DNS is not mutated by this plan; ${dnsOwnerRef} must track the LoadBalancer ingress.`,
1530
+ 'The stateful service has one RWO primary; its rollout is ordered but not zero-downtime.'
1531
+ ]
1532
+ };
1533
+ }
1534
+ function resourcePath(resource) {
1535
+ const name = encodeURIComponent(resource.name);
1536
+ const ns = resource.namespace ? `/namespaces/${encodeURIComponent(resource.namespace)}` : '';
1537
+ const key = `${resource.apiVersion}:${resource.kind}`;
1538
+ const paths = {
1539
+ 'v1:Namespace': `/api/v1/namespaces/${name}`,
1540
+ 'v1:Service': `/api/v1${ns}/services/${name}`,
1541
+ 'v1:ServiceAccount': `/api/v1${ns}/serviceaccounts/${name}`,
1542
+ 'v1:ConfigMap': `/api/v1${ns}/configmaps/${name}`,
1543
+ 'v1:PersistentVolumeClaim': `/api/v1${ns}/persistentvolumeclaims/${name}`,
1544
+ 'v1:ResourceQuota': `/api/v1${ns}/resourcequotas/${name}`,
1545
+ 'apps/v1:Deployment': `/apis/apps/v1${ns}/deployments/${name}`,
1546
+ 'apps/v1:StatefulSet': `/apis/apps/v1${ns}/statefulsets/${name}`,
1547
+ 'batch/v1:Job': `/apis/batch/v1${ns}/jobs/${name}`,
1548
+ 'batch/v1:CronJob': `/apis/batch/v1${ns}/cronjobs/${name}`,
1549
+ 'rbac.authorization.k8s.io/v1:Role': `/apis/rbac.authorization.k8s.io/v1${ns}/roles/${name}`,
1550
+ 'rbac.authorization.k8s.io/v1:RoleBinding': `/apis/rbac.authorization.k8s.io/v1${ns}/rolebindings/${name}`,
1551
+ 'policy/v1:PodDisruptionBudget': `/apis/policy/v1${ns}/poddisruptionbudgets/${name}`,
1552
+ 'autoscaling/v2:HorizontalPodAutoscaler': `/apis/autoscaling/v2${ns}/horizontalpodautoscalers/${name}`,
1553
+ 'networking.k8s.io/v1:NetworkPolicy': `/apis/networking.k8s.io/v1${ns}/networkpolicies/${name}`,
1554
+ 'cilium.io/v2:CiliumNetworkPolicy': `/apis/cilium.io/v2${ns}/ciliumnetworkpolicies/${name}`
1555
+ };
1556
+ const path = Object.entries(paths).find(([candidate]) => candidate === key)?.[1];
1557
+ if (!path)
1558
+ throw new Error(`Refusing resource outside service-release allowlist: ${key}.`);
1559
+ return path;
1560
+ }
1561
+ function invalidWorkerPlan(message, reason) {
1562
+ return new CapabilityError(message, 'INVALID_INPUT', { reason });
1563
+ }
1564
+ export class DigitalOceanKubernetesServiceReleaseClient {
1565
+ runtime;
1566
+ kubernetes;
1567
+ fetchImpl;
1568
+ spacesKeys;
1569
+ constructor(runtime, kubernetes, fetchImpl, spacesKeys) {
1570
+ this.runtime = runtime;
1571
+ this.kubernetes = kubernetes;
1572
+ this.fetchImpl = fetchImpl;
1573
+ this.spacesKeys = spacesKeys;
1574
+ }
1575
+ /**
1576
+ * Resolve the DOKS Secret delivery capability through its semantic runtime
1577
+ * client. The secrets/store projection remains limited to store operations.
1578
+ */
1579
+ async workerCredentialDeliveryOwner() {
1580
+ let client;
1581
+ try {
1582
+ client = await getVibesClient(DOKS_SECRET_DELIVERY_CLIENT_ID, this.runtime);
1583
+ }
1584
+ catch (cause) {
1585
+ throw new CapabilityError('The DOKS Secret credential delivery owner is not composed.', 'UNAVAILABLE', { reason: 'doks-worker-credential-owner-unavailable', cause });
1586
+ }
1587
+ // The contract is validated on the resolved client, and the validated
1588
+ // client itself is returned. A schema projection of the delivery members
1589
+ // would lose the runtime client's receiver, and with it the store and
1590
+ // connection state its delivery operations read through `this`.
1591
+ const owner = DigitalOceanDoksWorkerCredentialDeliveryOwnerSchema.safeParse(client);
1592
+ if (!owner.success) {
1593
+ throw new CapabilityError('The DOKS Secret credential delivery owner has an invalid runtime contract.', 'UNAVAILABLE', { reason: 'doks-worker-credential-owner-unavailable', cause: owner.error });
1594
+ }
1595
+ return client;
1596
+ }
1597
+ /**
1598
+ * Admission for every externally reachable worker execution. The caller
1599
+ * supplies only the execution input (operation, worker id, source
1600
+ * revision); the authorized projection is regenerated from the descriptor
1601
+ * the runtime selects by id, so a descriptor embedded in an untrusted plan
1602
+ * is never independent authority.
1603
+ */
1604
+ async authorizeWorkerExecution(execution, allowCredentialLease) {
1605
+ return this.authorizeWorkerPlan(execution, allowCredentialLease);
1606
+ }
1607
+ /**
1608
+ * Regenerate the authorized projection from the descriptor the runtime
1609
+ * selects by id. A credential lease the Secrets owner issued mid-execution
1610
+ * is the only caller state that may re-enter, and only when the caller is
1611
+ * allowed to reuse it.
1612
+ */
1613
+ async authorizeWorkerPlan(execution, allowCredentialLease, issuedLease) {
1614
+ const selected = this.runtime
1615
+ .query('infra/worker')
1616
+ .withId(execution.worker)
1617
+ .enabled()
1618
+ .firstDescriptor();
1619
+ if (!selected) {
1620
+ throw new CapabilityError('DOKS worker descriptor is not registered.', 'NOT_FOUND', {
1621
+ reason: 'doks-worker-descriptor-not-found'
1622
+ });
1623
+ }
1624
+ const descriptor = DigitalOceanDoksWorkerDescriptorSchema.safeParse(selected);
1625
+ if (!descriptor.success || descriptor.data.id !== execution.worker) {
1626
+ throw invalidWorkerPlan('Selected DOKS worker descriptor is invalid.', 'doks-worker-descriptor-invalid');
1627
+ }
1628
+ if (execution.environment !== undefined &&
1629
+ descriptor.data.runtime.scope.qualifiers.environmentTier !== execution.environment) {
1630
+ throw invalidWorkerPlan('DOKS worker descriptor does not match the requested environment.', 'doks-worker-environment-mismatch');
1631
+ }
1632
+ let authorizedPlan;
1633
+ try {
1634
+ authorizedPlan =
1635
+ execution.operation === 'apply'
1636
+ ? planDigitalOceanDoksWorkerApply(descriptor.data)
1637
+ : planDigitalOceanDoksWorkerBuild(descriptor.data, execution.sourceRevision);
1638
+ if (issuedLease) {
1639
+ if (!allowCredentialLease) {
1640
+ throw invalidWorkerPlan('A DOKS worker execution cannot reuse a source credential lease.', 'doks-worker-plan-replay-refused');
1641
+ }
1642
+ authorizedPlan = bindDigitalOceanDoksWorkerCredentialLease(authorizedPlan, issuedLease.operationId);
1643
+ }
1644
+ }
1645
+ catch (cause) {
1646
+ if (cause instanceof CapabilityError)
1647
+ throw cause;
1648
+ throw invalidWorkerPlan('Selected DOKS worker descriptor cannot authorize this plan.', 'doks-worker-descriptor-invalid');
1649
+ }
1650
+ return authorizedPlan;
1651
+ }
1652
+ /**
1653
+ * Recover the issued source lease only from the live, owned Job. The
1654
+ * operation annotation and the Job's source Secret reference must agree;
1655
+ * a plan regenerated from the descriptor alone is not cleanup authority.
1656
+ */
1657
+ recoverWorkerSourceLease(plan, input) {
1658
+ const jobResult = KubernetesOwnedJobSchema.safeParse(input);
1659
+ if (!jobResult.success) {
1660
+ throw new CapabilityError('DOKS worker Job does not provide usable credential recovery evidence.', 'UNAVAILABLE', { reason: 'doks-worker-operation-evidence-unavailable', cause: jobResult.error });
1661
+ }
1662
+ if (plan.operation !== 'build')
1663
+ return plan;
1664
+ const sourceBinding = plan.credentialBindings?.find((binding) => binding.kind === 'source-lease');
1665
+ const operationValue = jobResult.data.metadata?.annotations?.['vibes.dev/source-operation-id'];
1666
+ const operationId = DigitalOceanDoksWorkerOperationIdSchema.safeParse(operationValue);
1667
+ if (!sourceBinding || !operationId.success) {
1668
+ throw new CapabilityError('DOKS worker Job does not identify its issued source lease.', 'UNAVAILABLE', { reason: 'doks-worker-operation-evidence-unavailable' });
1669
+ }
1670
+ const labels = jobResult.data.metadata?.labels ?? {};
1671
+ const owned = labels['app.kubernetes.io/managed-by'] === plan.fieldManager &&
1672
+ labels['app.kubernetes.io/name'] === plan.worker &&
1673
+ labels['vibes.dev/source-sha'] === plan.sourceRevision;
1674
+ if (!owned) {
1675
+ throw new CapabilityError('DOKS worker Job does not match the selected worker descriptor.', 'UNAVAILABLE', { reason: 'doks-worker-job-ownership-unavailable' });
1676
+ }
1677
+ const recovered = bindDigitalOceanDoksWorkerCredentialLease(plan, operationId.data);
1678
+ const lease = recovered.credentialLease;
1679
+ const sourceReferences = jobResult.data.spec?.template?.spec?.containers?.flatMap((container) => (container.env ?? []).flatMap((environment) => {
1680
+ const reference = environment.valueFrom?.secretKeyRef;
1681
+ return reference?.key === sourceBinding.key && reference.name
1682
+ ? [reference.name]
1683
+ : [];
1684
+ })) ?? [];
1685
+ if (!lease || sourceReferences.length !== 1 || sourceReferences[0] !== lease.name) {
1686
+ throw new CapabilityError('DOKS worker Job source reference does not match its issued lease.', 'UNAVAILABLE', { reason: 'doks-worker-operation-evidence-unavailable' });
1687
+ }
1688
+ return recovered;
1689
+ }
1690
+ async revokeWorkerSourceLease(plan) {
1691
+ if (!plan.credentialLease)
1692
+ return;
1693
+ const owner = await this.workerCredentialDeliveryOwner();
1694
+ await owner.revokeWorkerCredentials(plan.credentialLease);
1695
+ }
1696
+ async backupAuthorities(input) {
1697
+ const keys = await this.backupAuthorityHandles(input);
1698
+ return {
1699
+ provider: 'digitalocean-spaces',
1700
+ authorities: keys.map((key) => ({
1701
+ name: key.name,
1702
+ grants: key.grants,
1703
+ createdAt: key.createdAt
1704
+ }))
1705
+ };
1706
+ }
1707
+ async backupAuthorityHandles(input) {
1708
+ if (!this.spacesKeys)
1709
+ throw new Error('DigitalOcean Spaces-key authority is not composed.');
1710
+ const result = await settle(() => this.spacesKeys.list({ bucket: input.bucket }));
1711
+ if (!result.ok)
1712
+ throw this.backupAuthorityError(result.cause, input.requiredControlPlaneScopes);
1713
+ const keys = result.value;
1714
+ return keys
1715
+ .filter((key) => key.name === input.namePrefix || key.name.startsWith(`${input.namePrefix}-`))
1716
+ .map((key) => ({
1717
+ name: key.name,
1718
+ accessKeyId: key.access_key,
1719
+ grants: key.grants,
1720
+ createdAt: key.created_at
1721
+ }));
1722
+ }
1723
+ async createBackupAuthorityKey(input) {
1724
+ if (!this.spacesKeys)
1725
+ throw new Error('DigitalOcean Spaces-key authority is not composed.');
1726
+ const result = await settle(() => this.spacesKeys.create({ name: input.name, grants: input.grants }));
1727
+ if (!result.ok)
1728
+ throw this.backupAuthorityError(result.cause, input.requiredControlPlaneScopes);
1729
+ const created = result.value;
1730
+ return {
1731
+ name: created.name,
1732
+ accessKeyId: created.access_key,
1733
+ secretAccessKey: created.secret_key,
1734
+ grants: created.grants
1735
+ };
1736
+ }
1737
+ async deleteBackupAuthorityKey(accessKeyId) {
1738
+ if (!this.spacesKeys)
1739
+ throw new Error('DigitalOcean Spaces-key authority is not composed.');
1740
+ await this.spacesKeys.delete(accessKeyId);
1741
+ }
1742
+ backupAuthorityError(error, requiredScopes) {
1743
+ if (error instanceof CapabilityError &&
1744
+ error.reason === 'digitalocean-http-failed' &&
1745
+ (error.details?.status === 401 ||
1746
+ error.details?.status === 403 ||
1747
+ error.details?.status === 404)) {
1748
+ return new CapabilityError(`DigitalOcean Spaces-key authority is unavailable (HTTP ${error.details?.status}); ` +
1749
+ `the endpoint may not be enabled or the operator credential may lack required scopes: ${requiredScopes.join(', ')}.`, 'UNAVAILABLE', { reason: 'digitalocean-backup-authority-unavailable', cause: error });
1750
+ }
1751
+ return error instanceof CapabilityError
1752
+ ? error
1753
+ : new CapabilityError('DigitalOcean backup authority failed', 'UNAVAILABLE', {
1754
+ reason: 'digitalocean-backup-authority-failed',
1755
+ cause: error
1756
+ });
1757
+ }
1758
+ plan(descriptor, change) {
1759
+ return planDigitalOceanKubernetesServiceRelease(descriptor, change);
1760
+ }
1761
+ async connect(plan) {
1762
+ const cluster = plan.cluster.id
1763
+ ? await this.kubernetes.getCluster(plan.cluster.id)
1764
+ : await this.kubernetes.findClusterByName(plan.cluster.name);
1765
+ if (!cluster)
1766
+ throw new Error('Target DOKS cluster was not found.');
1767
+ if (cluster.status.state !== 'running')
1768
+ throw new Error(`Target DOKS cluster is not running (${cluster.status.state}).`);
1769
+ const authority = parseKubeconfig(await this.kubernetes.fetchKubeconfig(cluster.id, { expirySeconds: 300 }));
1770
+ const request = async (path, init, allowNotFound = false, allowConflict = false) => {
1771
+ const headers = new Headers(init.headers);
1772
+ if (authority.token)
1773
+ headers.set('authorization', `Bearer ${authority.token}`);
1774
+ const response = await kubernetesRequest(this.runtime, authority, `${authority.server}${path}`, {
1775
+ ...init,
1776
+ headers
1777
+ }, this.fetchImpl);
1778
+ if (allowNotFound && response.status === 404)
1779
+ return null;
1780
+ if (allowConflict && response.status === 409)
1781
+ return response;
1782
+ if (!response.ok) {
1783
+ const method = init.method !== undefined && /^[A-Z]+$/.test(init.method) ? init.method : 'REQUEST';
1784
+ const pathname = new URL(path, 'https://kubernetes.invalid').pathname;
1785
+ const failure = await boundedKubernetesFailure(response);
1786
+ throw new Error(`Kubernetes API request failed (${response.status}) for ${method} ${pathname};${failure.summary}; ` +
1787
+ 'the response body was intentionally redacted.', { cause: failure.cause });
1788
+ }
1789
+ return response;
1790
+ };
1791
+ return { cluster: { id: cluster.id, name: cluster.name }, request };
1792
+ }
1793
+ async assertReadiness(plan, connection) {
1794
+ const { request } = connection ?? (await this.connect(plan));
1795
+ const missing = [];
1796
+ if (plan.requiresCiliumNetworkPolicy) {
1797
+ const capability = await request('/apis/apiextensions.k8s.io/v1/customresourcedefinitions/ciliumnetworkpolicies.cilium.io', { method: 'GET', headers: { accept: 'application/json' } }, true);
1798
+ if (!capability)
1799
+ missing.push('CiliumNetworkPolicy capability');
1800
+ }
1801
+ let snapshotClass;
1802
+ if (plan.backup.mode === 'csi-volume-snapshot') {
1803
+ const response = await request(`/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses/${encodeURIComponent(plan.backup.snapshotClassName)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1804
+ if (!response) {
1805
+ missing.push(`VolumeSnapshotClass ${plan.backup.snapshotClassName}`);
1806
+ }
1807
+ else {
1808
+ const candidate = KubernetesSnapshotClassSchema.parse(await response.json());
1809
+ if (candidate.driver !== 'dobs.csi.digitalocean.com') {
1810
+ missing.push(`VolumeSnapshotClass ${plan.backup.snapshotClassName} DigitalOcean CSI driver`);
1811
+ }
1812
+ else if (candidate.deletionPolicy !== plan.backup.deletionPolicy) {
1813
+ missing.push(`VolumeSnapshotClass ${plan.backup.snapshotClassName} deletionPolicy=${plan.backup.deletionPolicy}`);
1814
+ }
1815
+ else {
1816
+ snapshotClass = {
1817
+ name: plan.backup.snapshotClassName,
1818
+ driver: 'dobs.csi.digitalocean.com',
1819
+ deletionPolicy: 'Delete'
1820
+ };
1821
+ }
1822
+ }
1823
+ }
1824
+ let namespaceQuota;
1825
+ if (plan.namespaceQuota) {
1826
+ const declared = plan.namespaceQuota;
1827
+ const response = await request(`/api/v1/namespaces/${encodeURIComponent(declared.namespace)}/resourcequotas/${encodeURIComponent(declared.name)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1828
+ if (!response) {
1829
+ missing.push(`ResourceQuota ${declared.namespace}/${declared.name}`);
1830
+ }
1831
+ else {
1832
+ const current = KubernetesQuotaSpecSchema.parse(await response.json());
1833
+ const hard = current.spec?.hard ?? {};
1834
+ const exact = Object.keys(hard).length === Object.keys(declared.hard).length &&
1835
+ Object.entries(declared.hard).every(([key, value]) => hard[key] === value);
1836
+ if (!exact) {
1837
+ missing.push(`ResourceQuota ${declared.namespace}/${declared.name} exact hard limits`);
1838
+ }
1839
+ else {
1840
+ namespaceQuota = { ...declared, hard: { ...declared.hard } };
1841
+ }
1842
+ }
1843
+ }
1844
+ let imageRegistry;
1845
+ if (plan.imageRegistry) {
1846
+ const declared = plan.imageRegistry;
1847
+ const secret = await request(`/api/v1/namespaces/${encodeURIComponent(plan.hostKeyAuthority.secret.namespace)}/secrets/${encodeURIComponent(declared.pullSecretName)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1848
+ const defaultServiceAccount = await request(`/api/v1/namespaces/${encodeURIComponent(plan.hostKeyAuthority.secret.namespace)}/serviceaccounts/default`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1849
+ const secretBody = secret ? KubernetesSecretBodySchema.parse(await secret.json()) : undefined;
1850
+ const accountBody = defaultServiceAccount
1851
+ ? KubernetesAccountBodySchema.parse(await defaultServiceAccount.json())
1852
+ : undefined;
1853
+ const exact = secretBody?.type === 'kubernetes.io/dockerconfigjson' &&
1854
+ '.dockerconfigjson' in (secretBody.data ?? {}) &&
1855
+ (accountBody?.imagePullSecrets ?? []).some((entry) => entry.name === declared.pullSecretName);
1856
+ if (!exact) {
1857
+ missing.push(`DigitalOcean registry ${declared.name} pull authority in namespace ${plan.hostKeyAuthority.secret.namespace}`);
1858
+ }
1859
+ else {
1860
+ imageRegistry = { ...declared };
1861
+ }
1862
+ }
1863
+ for (const secret of plan.requiredSecrets) {
1864
+ const response = await request(`/api/v1/namespaces/${encodeURIComponent(secret.namespace)}/secrets/${encodeURIComponent(secret.name)}`, {
1865
+ method: 'GET',
1866
+ headers: { accept: 'application/json' }
1867
+ }, true);
1868
+ if (!response) {
1869
+ const certificate = plan.certificates.find((candidate) => candidate.namespace === secret.namespace && candidate.secretName === secret.name);
1870
+ if (certificate) {
1871
+ const certificateResponse = await request(`/apis/cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/certificates/${encodeURIComponent(certificate.name)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1872
+ if (certificateResponse) {
1873
+ const raw = KubernetesCertificateStatusSchema.parse(await certificateResponse.json());
1874
+ const conditions = boundedConditionSummary(raw.status?.conditions);
1875
+ const ready = raw.status?.conditions?.find((condition) => condition.type === 'Ready');
1876
+ const summary = conditions.length > 0 ? ` [${conditions.join(',')}]` : '';
1877
+ let state = ready?.status === 'True'
1878
+ ? 'claims Ready but its Secret is absent'
1879
+ : `is not Ready${summary}`;
1880
+ if (ready?.status !== 'True') {
1881
+ const requestsResponse = await request(`/apis/cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/certificaterequests`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1882
+ if (requestsResponse) {
1883
+ const body = KubernetesCertificateRequestsSchema.parse(await requestsResponse.json());
1884
+ const owned = (body.items ?? [])
1885
+ .filter((item) => item.metadata?.ownerReferences?.some((owner) => owner.kind === 'Certificate' && owner.name === certificate.name))
1886
+ .sort((left, right) => String(right.metadata?.creationTimestamp).localeCompare(String(left.metadata?.creationTimestamp)));
1887
+ const latest = owned[0];
1888
+ if (!latest) {
1889
+ state += '; no owned CertificateRequest exists';
1890
+ }
1891
+ else {
1892
+ const requestName = latest.metadata?.name !== undefined &&
1893
+ /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(latest.metadata.name)
1894
+ ? latest.metadata.name
1895
+ : 'owned';
1896
+ const requestConditions = boundedConditionSummary(latest.status?.conditions);
1897
+ const requestSummary = requestConditions.length > 0 ? ` [${requestConditions.join(',')}]` : '';
1898
+ state += `; CertificateRequest ${requestName}${requestSummary}`;
1899
+ if (latest.metadata?.name !== undefined) {
1900
+ const ordersResponse = await request(`/apis/acme.cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/orders`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1901
+ if (ordersResponse) {
1902
+ const ordersBody = KubernetesOrdersBodySchema.parse(await ordersResponse.json());
1903
+ const orders = (ordersBody.items ?? [])
1904
+ .filter((item) => item.metadata?.ownerReferences?.some((owner) => owner.kind === 'CertificateRequest' &&
1905
+ owner.name === latest.metadata?.name))
1906
+ .sort((left, right) => String(right.metadata?.creationTimestamp).localeCompare(String(left.metadata?.creationTimestamp)));
1907
+ const order = orders[0];
1908
+ if (!order) {
1909
+ state += '; no owned ACME Order exists';
1910
+ }
1911
+ else {
1912
+ const orderName = boundedStatusToken(order.metadata?.name, 'owned');
1913
+ const orderState = boundedStatusToken(order.status?.state, 'unknown');
1914
+ const orderReason = boundedStatusToken(order.status?.reason, '');
1915
+ state += `; Order ${orderName} state=${orderState}${orderReason ? ` reason=${orderReason}` : ''}`;
1916
+ if (order.metadata?.name !== undefined) {
1917
+ const challengesResponse = await request(`/apis/acme.cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/challenges`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1918
+ if (challengesResponse) {
1919
+ const challengesBody = KubernetesChallengesBodySchema.parse(await challengesResponse.json());
1920
+ const challenges = (challengesBody.items ?? []).filter((item) => item.metadata?.ownerReferences?.some((owner) => owner.kind === 'Order' && owner.name === order.metadata?.name));
1921
+ if (challenges.length === 0) {
1922
+ state += '; no owned ACME Challenge exists';
1923
+ }
1924
+ else {
1925
+ const summaries = challenges.slice(0, 8).map((challenge) => {
1926
+ const name = boundedStatusToken(challenge.metadata?.name, 'owned');
1927
+ const challengeState = boundedStatusToken(challenge.status?.state, 'unknown');
1928
+ const reason = boundedStatusToken(challenge.status?.reason, '');
1929
+ return `${name}:${challengeState}${reason ? `/${reason}` : ''}:presented=${challenge.status?.presented === true}:processing=${challenge.status?.processing === true}`;
1930
+ });
1931
+ state += `; Challenges [${summaries.join(',')}]`;
1932
+ }
1933
+ }
1934
+ }
1935
+ }
1936
+ }
1937
+ }
1938
+ }
1939
+ }
1940
+ }
1941
+ missing.push(`Secret ${secret.namespace}/${secret.name} (Certificate ${certificate.name} ${state})`);
1942
+ continue;
1943
+ }
1944
+ }
1945
+ missing.push(`Secret ${secret.namespace}/${secret.name}`);
1946
+ continue;
1947
+ }
1948
+ const raw = KubernetesDataSchema.parse(await response.json());
1949
+ const present = new Set(Object.keys(raw.data ?? {}));
1950
+ const missingKeys = secret.keys.filter((key) => !present.has(key));
1951
+ if (missingKeys.length > 0) {
1952
+ missing.push(`Secret ${secret.namespace}/${secret.name} keys [${missingKeys.join(', ')}]`);
1953
+ }
1954
+ }
1955
+ let repositoryMigration;
1956
+ if (plan.repositoryMigration) {
1957
+ const migration = plan.repositoryMigration;
1958
+ const response = await request(`/api/v1/namespaces/${encodeURIComponent(migration.receipt.namespace)}/configmaps/${encodeURIComponent(migration.receipt.name)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
1959
+ if (!response) {
1960
+ missing.push(`repository migration receipt ConfigMap ${migration.receipt.namespace}/${migration.receipt.name}`);
1961
+ }
1962
+ else {
1963
+ const raw = KubernetesDataSchema.parse(await response.json());
1964
+ const data = raw.data ?? {};
1965
+ // The receipt proves the one-time migration ran to parity. The
1966
+ // schema IS the parity contract — literals pin every identity
1967
+ // field to the descriptor, so a divergence fails validation and
1968
+ // the plan error NAMES the diverging field (the old boolean chain
1969
+ // reported only "exact parity", which hid three different
1970
+ // mismatches behind one opaque message on 2026-08-18).
1971
+ // `release-revision` records the revision that RAN the migration —
1972
+ // history, deliberately NOT pinned to the descriptor's current
1973
+ // revision: pinning it froze the release at the migration revision
1974
+ // and failed every subsequent image update.
1975
+ const receiptSchema = z.object({
1976
+ 'migration-id': z.literal(migration.migrationId),
1977
+ 'source-origin': z.literal(migration.sourceOrigin),
1978
+ 'release-revision': z.string().min(1),
1979
+ 'repository-count': z.literal(String(migration.expectedRepositoryCount)),
1980
+ 'stable-passes': z.literal(String(migration.stablePasses)),
1981
+ 'rollback-fence': z.literal(migration.rollback),
1982
+ 'inventory-digest': z.string().regex(/^[a-f0-9]{64}$/),
1983
+ 'parity-digest': z.string().regex(/^[a-f0-9]{64}$/)
1984
+ });
1985
+ const receipt = receiptSchema.safeParse(data);
1986
+ if (!receipt.success) {
1987
+ const divergence = receipt.error.issues
1988
+ .map((issue) => `${issue.path.join('.')} ${issue.message}`)
1989
+ .join('; ');
1990
+ missing.push(`repository migration receipt ${migration.receipt.namespace}/${migration.receipt.name} parity: ${divergence}`);
1991
+ }
1992
+ else {
1993
+ repositoryMigration = {
1994
+ migrationId: migration.migrationId,
1995
+ repositoryCount: migration.expectedRepositoryCount,
1996
+ inventoryDigest: receipt.data['inventory-digest'],
1997
+ parityDigest: receipt.data['parity-digest'],
1998
+ stablePasses: 2,
1999
+ rollback: migration.rollback
2000
+ };
2001
+ }
2002
+ }
2003
+ }
2004
+ if (missing.length > 0) {
2005
+ throw new Error(`Service release prerequisites are not ready: ${missing.join('; ')}.`);
2006
+ }
2007
+ return {
2008
+ cluster: 'running',
2009
+ secrets: plan.requiredSecrets,
2010
+ imageRegistry: imageRegistry ? imageRegistry : undefined,
2011
+ namespaceQuota: namespaceQuota ? namespaceQuota : undefined,
2012
+ snapshotClass: snapshotClass ? snapshotClass : undefined,
2013
+ repositoryMigration: repositoryMigration ? repositoryMigration : undefined
2014
+ };
2015
+ }
2016
+ async readiness(descriptor, change) {
2017
+ const plan = this.plan(descriptor, change);
2018
+ plan.readiness = await this.assertReadiness(plan);
2019
+ return plan;
2020
+ }
2021
+ async certificateAuthorities(target) {
2022
+ const parsed = DigitalOceanKubernetesServiceReleaseTargetSchema.parse(target);
2023
+ const { cluster, request } = await this.connect({ cluster: parsed.cluster });
2024
+ const response = await request('/apis/cert-manager.io/v1/clusterissuers', {
2025
+ method: 'GET',
2026
+ headers: { accept: 'application/json' }
2027
+ }, true);
2028
+ if (!response)
2029
+ return { provider: 'digitalocean-kubernetes', cluster, authorities: [] };
2030
+ const body = KubernetesIssuersSchema.parse(await response.json());
2031
+ const authorities = (body.items ?? []).flatMap((item) => {
2032
+ if (item.metadata?.name === undefined)
2033
+ return [];
2034
+ const solverTypes = [
2035
+ ...new Set((item.spec?.acme?.solvers ?? []).flatMap((solver) => {
2036
+ const types = [];
2037
+ if (solver.http01)
2038
+ types.push('http01');
2039
+ if (solver.dns01) {
2040
+ for (const [provider, config] of Object.entries(solver.dns01)) {
2041
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,31}$/.test(provider))
2042
+ continue;
2043
+ if (provider === 'webhook' &&
2044
+ KubernetesSolverNameSchema.safeParse(config).success) {
2045
+ const solverName = boundedStatusToken(KubernetesSolverNameSchema.parse(config).solverName, 'unknown');
2046
+ types.push(`dns01/webhook/${solverName}`);
2047
+ }
2048
+ else {
2049
+ types.push(`dns01/${provider}`);
2050
+ }
2051
+ }
2052
+ }
2053
+ return types;
2054
+ }))
2055
+ ].sort();
2056
+ const acmeServerValue = item.spec?.acme?.server;
2057
+ const acmeServer = acmeServerValue &&
2058
+ URL.canParse(acmeServerValue) &&
2059
+ new URL(acmeServerValue).protocol === 'https:' &&
2060
+ !new URL(acmeServerValue).username &&
2061
+ !new URL(acmeServerValue).password
2062
+ ? new URL(acmeServerValue).toString()
2063
+ : undefined;
2064
+ const contactEmail = item.spec?.acme?.email !== undefined &&
2065
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(item.spec.acme.email)
2066
+ ? item.spec.acme.email
2067
+ : undefined;
2068
+ return [
2069
+ {
2070
+ name: item.metadata.name,
2071
+ kind: 'ClusterIssuer',
2072
+ ready: item.status?.conditions?.some((condition) => condition.type === 'Ready' && condition.status === 'True') ?? false,
2073
+ solverTypes,
2074
+ acmeServer: acmeServer ? acmeServer : undefined,
2075
+ contactEmail: contactEmail ? contactEmail : undefined
2076
+ }
2077
+ ];
2078
+ });
2079
+ return { provider: 'digitalocean-kubernetes', cluster, authorities };
2080
+ }
2081
+ async clusterCapabilities(target) {
2082
+ const parsed = DigitalOceanKubernetesServiceReleaseTargetSchema.parse(target);
2083
+ const { cluster, request } = await this.connect({ cluster: parsed.cluster });
2084
+ const ciliumNetworkPolicy = await request('/apis/apiextensions.k8s.io/v1/customresourcedefinitions/ciliumnetworkpolicies.cilium.io', { method: 'GET', headers: { accept: 'application/json' } }, true);
2085
+ const snapshotClassesResponse = await request('/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses', { method: 'GET', headers: { accept: 'application/json' } }, true);
2086
+ const snapshotBody = snapshotClassesResponse
2087
+ ? KubernetesSnapshotBodySchema.parse(await snapshotClassesResponse.json())
2088
+ : undefined;
2089
+ const classes = (snapshotBody?.items ?? []).flatMap((item) => {
2090
+ if (item.metadata?.name === undefined ||
2091
+ item.driver === undefined ||
2092
+ item.deletionPolicy === undefined)
2093
+ return [];
2094
+ const annotations = item.metadata.annotations ?? {};
2095
+ return [
2096
+ {
2097
+ name: item.metadata.name,
2098
+ driver: item.driver,
2099
+ deletionPolicy: item.deletionPolicy,
2100
+ isDefault: annotations['snapshot.storage.kubernetes.io/is-default-class'] === 'true' ||
2101
+ annotations['snapshot.storage.k8s.io/is-default-class'] === 'true'
2102
+ }
2103
+ ];
2104
+ });
2105
+ return {
2106
+ provider: 'digitalocean-kubernetes',
2107
+ cluster,
2108
+ ciliumNetworkPolicy: Boolean(ciliumNetworkPolicy),
2109
+ volumeSnapshots: { apiAvailable: Boolean(snapshotClassesResponse), classes }
2110
+ };
2111
+ }
2112
+ async provisionSecrets(descriptor, change, material) {
2113
+ const parsed = ServiceReleaseDescriptorSchema.parse(descriptor);
2114
+ const plan = this.plan(parsed, change);
2115
+ const expected = new Set(parsed.secretBindings.map((binding) => `${binding.target.name}/${binding.target.key}`));
2116
+ const supplied = new Map();
2117
+ for (const entry of material) {
2118
+ const key = `${entry.target.name}/${entry.target.key}`;
2119
+ if (!expected.has(key))
2120
+ throw new Error(`Refusing undeclared service-release secret material for ${key}.`);
2121
+ if (supplied.has(key))
2122
+ throw new Error(`Duplicate service-release secret material for ${key}.`);
2123
+ if (!entry.value)
2124
+ throw new Error(`Service-release secret material for ${key} is empty.`);
2125
+ supplied.set(key, entry.value);
2126
+ }
2127
+ const missing = [...expected].filter((key) => !supplied.has(key));
2128
+ if (missing.length > 0)
2129
+ throw new Error(`Missing service-release secret material for ${missing.join(', ')}.`);
2130
+ const { cluster, request } = await this.connect(plan);
2131
+ const provisionable = requiredSecrets({
2132
+ ...parsed,
2133
+ config: { ...parsed.config, certificate: undefined }
2134
+ });
2135
+ for (const secret of provisionable) {
2136
+ const data = Object.fromEntries(secret.keys.map((key) => [
2137
+ key,
2138
+ encodeBase64Text(supplied.get(`${secret.name}/${key}`))
2139
+ ]));
2140
+ const query = new URLSearchParams({ fieldManager: SECRET_FIELD_MANAGER, force: 'false' });
2141
+ await request(`/api/v1/namespaces/${encodeURIComponent(secret.namespace)}/secrets/${encodeURIComponent(secret.name)}?${query}`, {
2142
+ method: 'PATCH',
2143
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2144
+ body: JSON.stringify({
2145
+ apiVersion: 'v1',
2146
+ kind: 'Secret',
2147
+ metadata: { name: secret.name, namespace: secret.namespace },
2148
+ type: secret.name === parsed.controlPlaneRouter.tlsSecretRef.name
2149
+ ? 'kubernetes.io/tls'
2150
+ : 'Opaque',
2151
+ data
2152
+ })
2153
+ });
2154
+ }
2155
+ return { provider: 'digitalocean-kubernetes', cluster, secrets: provisionable };
2156
+ }
2157
+ async provisionImageRegistry(descriptor, change) {
2158
+ const plan = this.plan(descriptor, change);
2159
+ const registry = plan.imageRegistry;
2160
+ if (!registry)
2161
+ throw new Error('Service release does not declare a DigitalOcean image registry.');
2162
+ const target = plan.cluster.id
2163
+ ? await this.kubernetes.getCluster(plan.cluster.id)
2164
+ : await this.kubernetes.findClusterByName(plan.cluster.name);
2165
+ if (!target)
2166
+ throw new Error('Target DOKS cluster was not found.');
2167
+ if (target.status.state !== 'running') {
2168
+ throw new Error(`Target DOKS cluster is not running (${target.status.state}).`);
2169
+ }
2170
+ await this.kubernetes.addRegistries([target.id], [registry.name]);
2171
+ const { cluster, request } = await this.connect(plan);
2172
+ const namespace = plan.hostKeyAuthority.secret.namespace;
2173
+ for (let attempt = 0; attempt < 30; attempt += 1) {
2174
+ const secret = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/secrets/${encodeURIComponent(registry.pullSecretName)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2175
+ const account = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/serviceaccounts/default`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2176
+ const secretBody = secret ? KubernetesSecretBodySchema.parse(await secret.json()) : undefined;
2177
+ const accountBody = account
2178
+ ? KubernetesAccountBodySchema.parse(await account.json())
2179
+ : undefined;
2180
+ if (secretBody?.type === 'kubernetes.io/dockerconfigjson' &&
2181
+ '.dockerconfigjson' in (secretBody.data ?? {}) &&
2182
+ (accountBody?.imagePullSecrets ?? []).some((entry) => entry.name === registry.pullSecretName)) {
2183
+ return {
2184
+ provider: 'digitalocean-kubernetes',
2185
+ cluster,
2186
+ registry: {
2187
+ provider: registry.provider,
2188
+ name: registry.name,
2189
+ pullSecret: {
2190
+ namespace,
2191
+ name: registry.pullSecretName,
2192
+ type: 'kubernetes.io/dockerconfigjson'
2193
+ }
2194
+ }
2195
+ };
2196
+ }
2197
+ await this.runtime.lifecycle.delay(1_000);
2198
+ }
2199
+ throw new Error(`DigitalOcean registry ${registry.name} did not propagate its cluster-managed pull authority into namespace ${namespace}.`);
2200
+ }
2201
+ async provisionCertificateAuthority(descriptor, change, material) {
2202
+ const parsed = ServiceReleaseDescriptorSchema.parse(descriptor);
2203
+ const certificate = parsed.config.certificate;
2204
+ const provisioning = certificate?.provisioning;
2205
+ if (!certificate || certificate.issuerRef.kind !== 'Issuer' || !provisioning) {
2206
+ throw new Error('Service release does not declare a provisionable namespace certificate authority.');
2207
+ }
2208
+ if (!provisioning.solver.apiToken.sourceKeys.includes(material.sourceKey)) {
2209
+ throw new Error(`Refusing undeclared certificate-authority source key ${material.sourceKey}.`);
2210
+ }
2211
+ if (!material.value)
2212
+ throw new Error('Certificate-authority token material is empty.');
2213
+ const plan = this.plan(parsed, change);
2214
+ const { cluster, request } = await this.connect(plan);
2215
+ const namespace = parsed.namespace;
2216
+ const existingNamespace = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2217
+ if (!existingNamespace) {
2218
+ const namespaceQuery = new URLSearchParams({
2219
+ fieldManager: CERTIFICATE_FIELD_MANAGER,
2220
+ force: 'false'
2221
+ });
2222
+ await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}?${namespaceQuery}`, {
2223
+ method: 'PATCH',
2224
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2225
+ body: JSON.stringify({ apiVersion: 'v1', kind: 'Namespace', metadata: { name: namespace } })
2226
+ });
2227
+ }
2228
+ const tokenTarget = provisioning.solver.apiToken.target;
2229
+ const secretQuery = new URLSearchParams({ fieldManager: SECRET_FIELD_MANAGER, force: 'false' });
2230
+ await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/secrets/${encodeURIComponent(tokenTarget.name)}?${secretQuery}`, {
2231
+ method: 'PATCH',
2232
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2233
+ body: JSON.stringify({
2234
+ apiVersion: 'v1',
2235
+ kind: 'Secret',
2236
+ metadata: { name: tokenTarget.name, namespace },
2237
+ type: 'Opaque',
2238
+ data: { [tokenTarget.key]: encodeBase64Text(material.value) }
2239
+ })
2240
+ });
2241
+ const issuerQuery = new URLSearchParams({
2242
+ fieldManager: CERTIFICATE_FIELD_MANAGER,
2243
+ force: 'false'
2244
+ });
2245
+ await request(`/apis/cert-manager.io/v1/namespaces/${encodeURIComponent(namespace)}/issuers/${encodeURIComponent(certificate.issuerRef.name)}?${issuerQuery}`, {
2246
+ method: 'PATCH',
2247
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2248
+ body: JSON.stringify({
2249
+ apiVersion: 'cert-manager.io/v1',
2250
+ kind: 'Issuer',
2251
+ metadata: { name: certificate.issuerRef.name, namespace },
2252
+ spec: {
2253
+ acme: {
2254
+ server: provisioning.server,
2255
+ email: provisioning.email,
2256
+ privateKeySecretRef: provisioning.privateKeySecretRef,
2257
+ solvers: [
2258
+ {
2259
+ selector: { dnsZones: provisioning.solver.dnsZones },
2260
+ dns01: {
2261
+ cloudflare: {
2262
+ apiTokenSecretRef: { name: tokenTarget.name, key: tokenTarget.key }
2263
+ }
2264
+ }
2265
+ }
2266
+ ]
2267
+ }
2268
+ }
2269
+ })
2270
+ });
2271
+ return {
2272
+ provider: 'digitalocean-kubernetes',
2273
+ cluster,
2274
+ authority: {
2275
+ namespace,
2276
+ name: certificate.issuerRef.name,
2277
+ kind: 'Issuer',
2278
+ solver: 'dns01/cloudflare',
2279
+ dnsZones: [...provisioning.solver.dnsZones],
2280
+ secret: { name: tokenTarget.name, keys: [tokenTarget.key] },
2281
+ sourceKey: material.sourceKey
2282
+ }
2283
+ };
2284
+ }
2285
+ async provisionCertificates(descriptor, change) {
2286
+ const plan = this.plan(descriptor, change);
2287
+ if (plan.certificates.length === 0) {
2288
+ throw new Error('Service release does not declare a managed certificate.');
2289
+ }
2290
+ const { cluster, request } = await this.connect(plan);
2291
+ for (const certificate of plan.certificates) {
2292
+ const issuerPath = certificate.issuerRef.kind === 'ClusterIssuer'
2293
+ ? `/apis/cert-manager.io/v1/clusterissuers/${encodeURIComponent(certificate.issuerRef.name)}`
2294
+ : `/apis/cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/issuers/${encodeURIComponent(certificate.issuerRef.name)}`;
2295
+ const issuer = await request(issuerPath, { method: 'GET', headers: { accept: 'application/json' } }, true);
2296
+ if (!issuer)
2297
+ throw new Error(`Declared ${certificate.issuerRef.kind} ${certificate.issuerRef.name} was not found.`);
2298
+ const status = KubernetesIssuerStatusSchema.parse(await issuer.json());
2299
+ const ready = status.status?.conditions?.some((condition) => condition.type === 'Ready' && condition.status === 'True') ?? false;
2300
+ if (!ready)
2301
+ throw new Error(`Declared ${certificate.issuerRef.kind} ${certificate.issuerRef.name} is not Ready.`);
2302
+ }
2303
+ const namespace = plan.certificates[0].namespace;
2304
+ const existingNamespace = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2305
+ if (!existingNamespace) {
2306
+ const namespaceQuery = new URLSearchParams({
2307
+ fieldManager: CERTIFICATE_FIELD_MANAGER,
2308
+ force: 'false'
2309
+ });
2310
+ await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}?${namespaceQuery}`, {
2311
+ method: 'PATCH',
2312
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2313
+ body: JSON.stringify({
2314
+ apiVersion: 'v1',
2315
+ kind: 'Namespace',
2316
+ metadata: { name: namespace }
2317
+ })
2318
+ });
2319
+ }
2320
+ for (const certificate of plan.certificates) {
2321
+ const query = new URLSearchParams({
2322
+ fieldManager: CERTIFICATE_FIELD_MANAGER,
2323
+ force: 'false'
2324
+ });
2325
+ await request(`/apis/cert-manager.io/v1/namespaces/${encodeURIComponent(certificate.namespace)}/certificates/${encodeURIComponent(certificate.name)}?${query}`, {
2326
+ method: 'PATCH',
2327
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2328
+ body: JSON.stringify({
2329
+ apiVersion: 'cert-manager.io/v1',
2330
+ kind: 'Certificate',
2331
+ metadata: { name: certificate.name, namespace: certificate.namespace },
2332
+ spec: {
2333
+ secretName: certificate.secretName,
2334
+ dnsNames: certificate.dnsNames,
2335
+ duration: certificate.duration,
2336
+ renewBefore: certificate.renewBefore,
2337
+ privateKey: certificate.privateKey,
2338
+ issuerRef: certificate.issuerRef
2339
+ }
2340
+ })
2341
+ });
2342
+ }
2343
+ return {
2344
+ provider: 'digitalocean-kubernetes',
2345
+ cluster,
2346
+ certificates: plan.certificates.map(({ namespace: certNamespace, name, secretName, dnsNames }) => ({
2347
+ namespace: certNamespace,
2348
+ name,
2349
+ secretName,
2350
+ dnsNames
2351
+ }))
2352
+ };
2353
+ }
2354
+ async provisionRepositoryMigration(descriptor, change) {
2355
+ const plan = this.plan(descriptor, change);
2356
+ const migration = plan.repositoryMigration;
2357
+ if (!migration) {
2358
+ throw new Error('Service release does not declare a repository migration.');
2359
+ }
2360
+ const { cluster, request } = await this.connect(plan);
2361
+ const secret = await request(`/api/v1/namespaces/${encodeURIComponent(migration.inventoryAuthoritySecret.namespace)}/secrets/${encodeURIComponent(migration.inventoryAuthoritySecret.name)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2362
+ if (!secret) {
2363
+ throw new Error(`Repository migration authority Secret ${migration.inventoryAuthoritySecret.namespace}/${migration.inventoryAuthoritySecret.name} is not ready.`);
2364
+ }
2365
+ const secretBody = KubernetesDataSchema.parse(await secret.json());
2366
+ if (!(migration.inventoryAuthoritySecret.key in (secretBody.data ?? {}))) {
2367
+ throw new Error(`Repository migration authority Secret ${migration.inventoryAuthoritySecret.namespace}/${migration.inventoryAuthoritySecret.name} is missing key ${migration.inventoryAuthoritySecret.key}.`);
2368
+ }
2369
+ const cilium = await request('/apis/apiextensions.k8s.io/v1/customresourcedefinitions/ciliumnetworkpolicies.cilium.io', { method: 'GET', headers: { accept: 'application/json' } }, true);
2370
+ if (!cilium)
2371
+ throw new Error('Repository migration requires CiliumNetworkPolicy capability.');
2372
+ const namespace = migration.receipt.namespace;
2373
+ const jobName = cappedId('gitd-migration', migration.migrationId);
2374
+ const serviceAccountName = cappedId('gitd-migration', 'runner');
2375
+ const labels = {
2376
+ 'app.kubernetes.io/name': jobName,
2377
+ 'app.kubernetes.io/component': 'repository-migration',
2378
+ 'app.kubernetes.io/managed-by': MIGRATION_FIELD_MANAGER,
2379
+ 'vibes.dev/migration-id': migration.migrationId,
2380
+ 'vibes.dev/egress-profile': 'repository-migration',
2381
+ 'vibes.dev/release-revision': migration.releaseRevision
2382
+ };
2383
+ const namespaceResource = plan.resources.find((item) => item.kind === 'Namespace' && item.name === namespace);
2384
+ const pvcResource = plan.resources.find((item) => item.kind === 'PersistentVolumeClaim' &&
2385
+ item.namespace === namespace &&
2386
+ item.name === migration.destination.pvcName);
2387
+ if (!namespaceResource || !pvcResource) {
2388
+ throw new Error('Repository migration destination Namespace/PVC projection is unavailable.');
2389
+ }
2390
+ const platformHostname = new URL(migration.inventoryUrl).hostname;
2391
+ const sourceHostname = new URL(migration.sourceOrigin).hostname;
2392
+ const quotaResource = plan.namespaceQuota
2393
+ ? resource({
2394
+ apiVersion: 'v1',
2395
+ kind: 'ResourceQuota',
2396
+ metadata: {
2397
+ name: plan.namespaceQuota.name,
2398
+ namespace,
2399
+ labels: { 'app.kubernetes.io/managed-by': MIGRATION_FIELD_MANAGER }
2400
+ },
2401
+ spec: { hard: { ...plan.namespaceQuota.hard } }
2402
+ }, namespace)
2403
+ : undefined;
2404
+ const prerequisites = [
2405
+ namespaceResource,
2406
+ ...(quotaResource ? [quotaResource] : []),
2407
+ pvcResource,
2408
+ resource({
2409
+ apiVersion: 'v1',
2410
+ kind: 'ConfigMap',
2411
+ metadata: { name: migration.receipt.name, namespace, labels }
2412
+ }, namespace),
2413
+ resource({
2414
+ apiVersion: 'v1',
2415
+ kind: 'ServiceAccount',
2416
+ metadata: { name: serviceAccountName, namespace, labels },
2417
+ automountServiceAccountToken: true,
2418
+ imagePullSecrets: plan.imageRegistry ? [{ name: plan.imageRegistry.pullSecretName }] : []
2419
+ }, namespace),
2420
+ resource({
2421
+ apiVersion: 'rbac.authorization.k8s.io/v1',
2422
+ kind: 'Role',
2423
+ metadata: { name: serviceAccountName, namespace, labels },
2424
+ rules: [
2425
+ {
2426
+ apiGroups: [''],
2427
+ resources: ['configmaps'],
2428
+ resourceNames: [migration.receipt.name],
2429
+ verbs: ['get', 'patch', 'update']
2430
+ }
2431
+ ]
2432
+ }, namespace),
2433
+ resource({
2434
+ apiVersion: 'rbac.authorization.k8s.io/v1',
2435
+ kind: 'RoleBinding',
2436
+ metadata: { name: serviceAccountName, namespace, labels },
2437
+ roleRef: {
2438
+ apiGroup: 'rbac.authorization.k8s.io',
2439
+ kind: 'Role',
2440
+ name: serviceAccountName
2441
+ },
2442
+ subjects: [{ kind: 'ServiceAccount', name: serviceAccountName, namespace }]
2443
+ }, namespace),
2444
+ resource({
2445
+ apiVersion: 'cilium.io/v2',
2446
+ kind: 'CiliumNetworkPolicy',
2447
+ metadata: { name: cappedId('gitd-migration', 'egress'), namespace, labels },
2448
+ spec: {
2449
+ endpointSelector: {
2450
+ matchLabels: { 'vibes.dev/egress-profile': 'repository-migration' }
2451
+ },
2452
+ egress: [
2453
+ {
2454
+ toEndpoints: [
2455
+ {
2456
+ matchLabels: {
2457
+ 'k8s:io.kubernetes.pod.namespace': 'kube-system',
2458
+ 'k8s:k8s-app': 'kube-dns'
2459
+ }
2460
+ }
2461
+ ],
2462
+ toPorts: [
2463
+ {
2464
+ ports: [
2465
+ { port: '53', protocol: 'UDP' },
2466
+ { port: '53', protocol: 'TCP' }
2467
+ ]
2468
+ }
2469
+ ]
2470
+ },
2471
+ {
2472
+ toFQDNs: [{ matchName: sourceHostname }],
2473
+ toPorts: [{ ports: [{ port: '22', protocol: 'TCP' }] }]
2474
+ },
2475
+ {
2476
+ toFQDNs: [{ matchName: platformHostname }],
2477
+ toPorts: [{ ports: [{ port: '443', protocol: 'TCP' }] }]
2478
+ },
2479
+ {
2480
+ toEntities: ['kube-apiserver'],
2481
+ toPorts: [{ ports: [{ port: '443', protocol: 'TCP' }] }]
2482
+ }
2483
+ ]
2484
+ }
2485
+ }, namespace)
2486
+ ];
2487
+ for (const item of prerequisites) {
2488
+ const query = new URLSearchParams({ fieldManager: MIGRATION_FIELD_MANAGER, force: 'false' });
2489
+ if (item.kind === 'ResourceQuota') {
2490
+ const path = resourcePath(item);
2491
+ const existing = await request(path, { method: 'GET', headers: { accept: 'application/json' } }, true);
2492
+ if (!existing) {
2493
+ const collectionPath = path.slice(0, path.lastIndexOf('/'));
2494
+ await request(`${collectionPath}?${new URLSearchParams({ fieldManager: MIGRATION_FIELD_MANAGER })}`, {
2495
+ method: 'POST',
2496
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
2497
+ body: JSON.stringify(item.manifest)
2498
+ });
2499
+ continue;
2500
+ }
2501
+ const current = KubernetesQuotaVersionSchema.parse(await existing.json());
2502
+ const desired = KubernetesDeclaredQuotaSchema.parse(item.manifest);
2503
+ const hard = current.spec?.hard ?? {};
2504
+ const desiredKeys = Object.keys(desired.spec.hard);
2505
+ const nonStorageExact = Object.keys(hard).length === desiredKeys.length &&
2506
+ desiredKeys
2507
+ .filter((key) => key !== 'requests.storage')
2508
+ .every((key) => hard[key] === desired.spec.hard[key]);
2509
+ if (!nonStorageExact) {
2510
+ throw new Error(`Existing ResourceQuota ${item.namespace}/${item.name} has non-storage hard limits that differ from the source-owned declaration.`);
2511
+ }
2512
+ if (hard['requests.storage'] === desired.spec.hard['requests.storage'])
2513
+ continue;
2514
+ if (current.metadata?.resourceVersion === undefined || !current.metadata.resourceVersion) {
2515
+ throw new Error(`Existing ResourceQuota ${item.namespace}/${item.name} has no resourceVersion for compare-and-set.`);
2516
+ }
2517
+ await request(`${path}?${new URLSearchParams({ fieldManager: MIGRATION_FIELD_MANAGER })}`, {
2518
+ method: 'PUT',
2519
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
2520
+ body: JSON.stringify({
2521
+ ...desired,
2522
+ metadata: { ...desired.metadata, resourceVersion: current.metadata.resourceVersion }
2523
+ })
2524
+ });
2525
+ continue;
2526
+ }
2527
+ if (item.kind === 'PersistentVolumeClaim') {
2528
+ const path = resourcePath(item);
2529
+ const existing = await request(path, { method: 'GET', headers: { accept: 'application/json' } }, true);
2530
+ if (existing) {
2531
+ const current = KubernetesExistingPvcSchema.parse(await existing.json());
2532
+ const desired = KubernetesDeclaredPvcSchema.parse(item.manifest);
2533
+ const accessModes = current.spec?.accessModes ?? [];
2534
+ const exact = accessModes.length === desired.spec.accessModes.length &&
2535
+ desired.spec.accessModes.every((mode) => accessModes.includes(mode)) &&
2536
+ current.spec?.storageClassName === desired.spec.storageClassName &&
2537
+ current.spec?.resources?.requests?.storage ===
2538
+ desired.spec.resources.requests.storage &&
2539
+ current.metadata?.annotations?.['vibes.dev/storage-schema-version'] ===
2540
+ desired.metadata.annotations?.['vibes.dev/storage-schema-version'];
2541
+ if (!exact) {
2542
+ throw new Error(`Existing repository migration destination PVC ${item.namespace}/${item.name} does not match the declared storage contract.`);
2543
+ }
2544
+ continue;
2545
+ }
2546
+ const accessReview = await request('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', {
2547
+ method: 'POST',
2548
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
2549
+ body: JSON.stringify({
2550
+ apiVersion: 'authorization.k8s.io/v1',
2551
+ kind: 'SelfSubjectAccessReview',
2552
+ spec: {
2553
+ resourceAttributes: {
2554
+ namespace: item.namespace,
2555
+ verb: 'create',
2556
+ group: '',
2557
+ resource: 'persistentvolumeclaims'
2558
+ }
2559
+ }
2560
+ })
2561
+ });
2562
+ const access = KubernetesAccessSchema.parse(await accessReview.json());
2563
+ if (access.status?.allowed !== true || access.status?.denied === true) {
2564
+ throw new Error(`DOKS Kubernetes authority is not allowed to create PersistentVolumeClaims in namespace ${item.namespace}.`);
2565
+ }
2566
+ const quotasResponse = await request(`/api/v1/namespaces/${encodeURIComponent(item.namespace)}/resourcequotas`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2567
+ const quotasBody = quotasResponse
2568
+ ? KubernetesQuotasBodySchema.parse(await quotasResponse.json())
2569
+ : undefined;
2570
+ const quotaSummary = (quotasBody?.items ?? [])
2571
+ .flatMap((quota) => {
2572
+ const quotaName = boundedStatusToken(quota.metadata?.name, '');
2573
+ if (!quotaName)
2574
+ return [];
2575
+ const values = (section) => Object.entries(section ?? {})
2576
+ .flatMap(([key, value]) => /^[A-Za-z0-9./_-]{1,64}$/.test(key) &&
2577
+ z.string().safeParse(value).success &&
2578
+ /^[0-9]+(?:\.[0-9]+)?(?:[EPTGMK]i?|m)?$/.test(String(value))
2579
+ ? [`${key}=${value}`]
2580
+ : [])
2581
+ .sort()
2582
+ .slice(0, 24)
2583
+ .join(',');
2584
+ return [
2585
+ `${quotaName} hard[${values(quota.status?.hard)}] used[${values(quota.status?.used)}]`
2586
+ ];
2587
+ })
2588
+ .slice(0, 8);
2589
+ const collectionPath = path.slice(0, path.lastIndexOf('/'));
2590
+ try {
2591
+ await request(`${collectionPath}?${query}`, {
2592
+ method: 'POST',
2593
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
2594
+ body: JSON.stringify(item.manifest)
2595
+ });
2596
+ }
2597
+ catch (error) {
2598
+ throw new Error(`${error instanceof Error ? error.message : String(error)}` +
2599
+ `${quotaSummary.length > 0 ? ` Namespace quota inventory: ${quotaSummary.join('; ')}.` : ''}`, { cause: error });
2600
+ }
2601
+ continue;
2602
+ }
2603
+ await request(`${resourcePath(item)}?${query}`, {
2604
+ method: 'PATCH',
2605
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2606
+ body: JSON.stringify(item.manifest)
2607
+ });
2608
+ }
2609
+ // The destination PVC is ReadWriteOnce: node-scoped attachment. When the
2610
+ // stateful pod is running it holds the volume on its node, and a job pod
2611
+ // scheduled anywhere else dies in Multi-Attach (observed generation-2,
2612
+ // 2026-08-18). Co-schedule beside a running stateful pod; when none is
2613
+ // running (generation-1 ran before the stateful service existed) the
2614
+ // volume is unattached and required affinity would deadlock scheduling,
2615
+ // so omit it.
2616
+ const statefulPodsResponse = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${encodeURIComponent(`app.kubernetes.io/name=${migration.statefulName},app.kubernetes.io/component=stateful`)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2617
+ const statefulPods = statefulPodsResponse
2618
+ ? (KubernetesStatefulPodsSchema.parse(await statefulPodsResponse.json()).items ?? [])
2619
+ : [];
2620
+ const statefulRunning = statefulPods.some((pod) => pod.status?.phase === 'Running' || pod.status?.phase === 'Pending');
2621
+ const migrationAffinity = statefulRunning
2622
+ ? {
2623
+ affinity: {
2624
+ podAffinity: {
2625
+ requiredDuringSchedulingIgnoredDuringExecution: [
2626
+ {
2627
+ labelSelector: {
2628
+ matchLabels: {
2629
+ 'app.kubernetes.io/name': migration.statefulName,
2630
+ 'app.kubernetes.io/component': 'stateful'
2631
+ }
2632
+ },
2633
+ topologyKey: 'kubernetes.io/hostname'
2634
+ }
2635
+ ]
2636
+ }
2637
+ }
2638
+ }
2639
+ : {};
2640
+ const jobPath = `/apis/batch/v1/namespaces/${encodeURIComponent(namespace)}/jobs/${encodeURIComponent(jobName)}`;
2641
+ let jobResponse = await request(jobPath, { method: 'GET', headers: { accept: 'application/json' } }, true);
2642
+ if (!jobResponse) {
2643
+ const job = resource({
2644
+ apiVersion: 'batch/v1',
2645
+ kind: 'Job',
2646
+ metadata: { name: jobName, namespace, labels },
2647
+ spec: {
2648
+ activeDeadlineSeconds: 21_600,
2649
+ backoffLimit: 0,
2650
+ template: {
2651
+ metadata: { labels },
2652
+ spec: {
2653
+ serviceAccountName,
2654
+ restartPolicy: 'Never',
2655
+ ...migrationAffinity,
2656
+ securityContext: {
2657
+ runAsNonRoot: true,
2658
+ runAsUser: 65532,
2659
+ runAsGroup: 65532,
2660
+ fsGroup: 65532,
2661
+ fsGroupChangePolicy: 'OnRootMismatch',
2662
+ seccompProfile: { type: 'RuntimeDefault' }
2663
+ },
2664
+ containers: [
2665
+ {
2666
+ name: 'repository-migration',
2667
+ image: migration.image,
2668
+ imagePullPolicy: 'IfNotPresent',
2669
+ command: ['bun', '/app/migrate.js'],
2670
+ env: [
2671
+ { name: 'MIGRATION_ID', value: migration.migrationId },
2672
+ { name: 'SOURCE_ORIGIN', value: migration.sourceOrigin },
2673
+ { name: 'PLATFORM_INVENTORY_URL', value: migration.inventoryUrl },
2674
+ { name: 'PLATFORM_SSH_LEASE_URL', value: migration.sshLeaseUrl },
2675
+ { name: 'RELEASE_REVISION', value: migration.releaseRevision },
2676
+ { name: 'DESTINATION_ROOT', value: migration.destination.mountPath },
2677
+ {
2678
+ name: 'NAMESPACE',
2679
+ valueFrom: { fieldRef: { fieldPath: 'metadata.namespace' } }
2680
+ },
2681
+ { name: 'RECEIPT_CONFIG_MAP', value: migration.receipt.name },
2682
+ {
2683
+ name: 'EXPECTED_INVENTORY',
2684
+ value: JSON.stringify({
2685
+ repositoryCount: migration.expectedRepositoryCount,
2686
+ owners: migration.expectedOwners
2687
+ })
2688
+ },
2689
+ { name: 'VIBES_ENVIRONMENT_TIER', value: migration.environmentTier },
2690
+ { name: 'VIBES_SECRETS_MOUNT_DIR', value: MIGRATION_SECRETS_MOUNT_DIR },
2691
+ {
2692
+ name: 'NODE_EXTRA_CA_CERTS',
2693
+ value: '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
2694
+ },
2695
+ { name: 'HOME', value: '/tmp' },
2696
+ { name: 'TMPDIR', value: '/tmp' }
2697
+ ],
2698
+ volumeMounts: [
2699
+ { name: 'data', mountPath: migration.destination.mountPath },
2700
+ {
2701
+ name: 'platform-authority',
2702
+ mountPath: MIGRATION_SECRETS_MOUNT_DIR,
2703
+ readOnly: true
2704
+ },
2705
+ { name: 'tmp', mountPath: '/tmp' }
2706
+ ],
2707
+ resources: {
2708
+ // Requests are the co-scheduling budget, not the working set:
2709
+ // the job must fit BESIDE the stateful pod on one RWO node
2710
+ // (see migrationAffinity), and 500m/512Mi could not fit next
2711
+ // to gitd on any node in the pool (generation-2, 2026-08-18).
2712
+ // The mirror is network/disk-bound; limits keep its bursts.
2713
+ requests: { cpu: '100m', memory: '256Mi' },
2714
+ limits: { cpu: '4', memory: '8Gi' }
2715
+ },
2716
+ securityContext: {
2717
+ allowPrivilegeEscalation: false,
2718
+ readOnlyRootFilesystem: true,
2719
+ capabilities: { drop: ['ALL'] }
2720
+ }
2721
+ }
2722
+ ],
2723
+ volumes: [
2724
+ {
2725
+ name: 'data',
2726
+ persistentVolumeClaim: {
2727
+ claimName: migration.destination.pvcName,
2728
+ readOnly: false
2729
+ }
2730
+ },
2731
+ {
2732
+ name: 'platform-authority',
2733
+ secret: {
2734
+ secretName: migration.inventoryAuthoritySecret.name,
2735
+ items: [
2736
+ {
2737
+ key: migration.inventoryAuthoritySecret.key,
2738
+ path: MIGRATION_AUTHORITY_CREDENTIAL,
2739
+ mode: 0o400
2740
+ }
2741
+ ]
2742
+ }
2743
+ },
2744
+ { name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '8Mi' } }
2745
+ ]
2746
+ }
2747
+ }
2748
+ }
2749
+ }, namespace);
2750
+ const query = new URLSearchParams({ fieldManager: MIGRATION_FIELD_MANAGER, force: 'false' });
2751
+ jobResponse = await request(`${resourcePath(job)}?${query}`, {
2752
+ method: 'PATCH',
2753
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2754
+ body: JSON.stringify(job.manifest)
2755
+ });
2756
+ }
2757
+ const status = KubernetesMigrationJobSchema.parse(await jobResponse.json());
2758
+ const state = status.status?.succeeded !== undefined && status.status.succeeded > 0
2759
+ ? 'succeeded'
2760
+ : status.status?.failed !== undefined && status.status.failed > 0
2761
+ ? 'failed'
2762
+ : status.status?.active !== undefined && status.status.active > 0
2763
+ ? 'running'
2764
+ : 'started';
2765
+ const failedCondition = status.status?.conditions?.find((condition) => condition.type === 'Failed' && condition.status === 'True');
2766
+ const reason = state === 'failed' ? boundedStatusToken(failedCondition?.reason, 'JobFailed') : undefined;
2767
+ let completedRepositories = 0;
2768
+ let lastCompletedRepository;
2769
+ let failedRepository;
2770
+ let progressReason;
2771
+ const podSelector = new URLSearchParams({ labelSelector: `job-name=${jobName}` });
2772
+ const podsResponse = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?${podSelector}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2773
+ if (podsResponse) {
2774
+ const pods = KubernetesPodsSchema.parse(await podsResponse.json());
2775
+ const pod = (pods.items ?? [])
2776
+ .filter((pod) => pod.metadata?.name !== undefined &&
2777
+ /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(pod.metadata.name))
2778
+ .sort((left, right) => String(right.metadata?.creationTimestamp ?? '').localeCompare(String(left.metadata?.creationTimestamp ?? '')))
2779
+ .at(0);
2780
+ const podName = pod?.metadata?.name;
2781
+ const container = pod?.status?.containerStatuses?.find((candidate) => candidate.name === 'repository-migration');
2782
+ const waitingReason = boundedStatusToken(container?.state?.waiting?.reason, '');
2783
+ if (waitingReason)
2784
+ progressReason = waitingReason;
2785
+ else if (pod?.status?.phase !== undefined) {
2786
+ progressReason = boundedStatusToken(pod.status.phase, '');
2787
+ }
2788
+ const logsAvailable = Boolean(container?.state?.running || container?.state?.terminated);
2789
+ if (podName && logsAvailable) {
2790
+ const logQuery = new URLSearchParams({
2791
+ container: 'repository-migration',
2792
+ tailLines: '512',
2793
+ limitBytes: String(256 * 1024)
2794
+ });
2795
+ const logsResponse = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}/log?${logQuery}`, { method: 'GET', headers: { accept: '*/*' } }, true);
2796
+ if (logsResponse) {
2797
+ const lines = (await logsResponse.text()).split('\n').slice(-512);
2798
+ const migrated = lines.flatMap((line) => {
2799
+ const match = line.match(/^MIGRATED ([~A-Za-z0-9][~A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*) refs=\d+$/);
2800
+ return match ? [match[1]] : [];
2801
+ });
2802
+ completedRepositories = migrated.length;
2803
+ lastCompletedRepository = migrated.at(-1);
2804
+ if (state === 'failed') {
2805
+ failedRepository = lines
2806
+ .flatMap((line) => {
2807
+ const byId = line.match(/\bRepository ([0-9a-f]{8}-[0-9a-f-]{27,36})\b/i);
2808
+ if (byId)
2809
+ return [byId[1].toLowerCase()];
2810
+ const byPath = line.match(/\b([~A-Za-z0-9][~A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*)\.git\b/);
2811
+ return byPath ? [byPath[1]] : [];
2812
+ })
2813
+ .at(-1);
2814
+ }
2815
+ }
2816
+ }
2817
+ }
2818
+ const hasDeclaredPullAuthority = plan.imageRegistry
2819
+ ? (status.spec?.template?.spec?.imagePullSecrets ?? []).some((entry) => entry.name === plan.imageRegistry.pullSecretName)
2820
+ : true;
2821
+ if (plan.imageRegistry &&
2822
+ !hasDeclaredPullAuthority &&
2823
+ completedRepositories === 0 &&
2824
+ progressReason === 'ImagePullBackOff') {
2825
+ const receiptResponse = await request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/configmaps/${encodeURIComponent(migration.receipt.name)}`, { method: 'GET', headers: { accept: 'application/json' } }, true);
2826
+ const receipt = receiptResponse
2827
+ ? KubernetesDataSchema.parse(await receiptResponse.json())
2828
+ : undefined;
2829
+ if (Object.keys(receipt?.data ?? {}).length > 0) {
2830
+ throw new Error('Refusing to replace an image-pull-blocked migration Job after receipt data exists.');
2831
+ }
2832
+ await request(`${jobPath}?${new URLSearchParams({ propagationPolicy: 'Foreground' })}`, {
2833
+ method: 'DELETE',
2834
+ headers: { accept: 'application/json' }
2835
+ });
2836
+ for (let attempt = 0; attempt < 30; attempt += 1) {
2837
+ const remaining = await request(jobPath, { method: 'GET', headers: { accept: 'application/json' } }, true);
2838
+ if (!remaining)
2839
+ break;
2840
+ await this.runtime.lifecycle.delay(1_000);
2841
+ }
2842
+ return {
2843
+ provider: 'digitalocean-kubernetes',
2844
+ cluster,
2845
+ migration: {
2846
+ migrationId: migration.migrationId,
2847
+ namespace,
2848
+ jobName,
2849
+ receiptConfigMapName: migration.receipt.name,
2850
+ repositoryCount: migration.expectedRepositoryCount,
2851
+ state: 'started',
2852
+ completedRepositories: 0,
2853
+ progressReason: 'StuckImagePullJobReplaced'
2854
+ }
2855
+ };
2856
+ }
2857
+ return {
2858
+ provider: 'digitalocean-kubernetes',
2859
+ cluster,
2860
+ migration: {
2861
+ migrationId: migration.migrationId,
2862
+ namespace,
2863
+ jobName,
2864
+ receiptConfigMapName: migration.receipt.name,
2865
+ repositoryCount: migration.expectedRepositoryCount,
2866
+ state,
2867
+ completedRepositories,
2868
+ lastCompletedRepository: lastCompletedRepository ? lastCompletedRepository : undefined,
2869
+ failedRepository: failedRepository ? failedRepository : undefined,
2870
+ progressReason: progressReason ? progressReason : undefined,
2871
+ reason: reason ? reason : undefined
2872
+ }
2873
+ };
2874
+ }
2875
+ async apply(descriptor, change) {
2876
+ const plan = this.plan(descriptor, change);
2877
+ const connection = await this.connect(plan);
2878
+ await this.assertReadiness(plan, connection);
2879
+ const { request } = connection;
2880
+ for (const item of plan.resources) {
2881
+ const query = new URLSearchParams({ fieldManager: FIELD_MANAGER, force: 'false' });
2882
+ await request(`${resourcePath(item)}?${query}`, {
2883
+ method: 'PATCH',
2884
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2885
+ body: JSON.stringify(item.manifest)
2886
+ });
2887
+ }
2888
+ return plan;
2889
+ }
2890
+ /**
2891
+ * Apply a worker execution through the same short-lived DOKS authority and
2892
+ * bounded error surface as service releases. The caller names the worker
2893
+ * and operation; the plan that mutates the cluster is the projection this
2894
+ * method re-derives from the runtime-selected descriptor.
2895
+ */
2896
+ async applyWorkerPlan(input) {
2897
+ const parsedExecution = DigitalOceanDoksWorkerExecutionInputSchema.safeParse(input);
2898
+ if (!parsedExecution.success) {
2899
+ throw invalidWorkerPlan('DOKS worker execution input is invalid.', 'doks-worker-execution-invalid');
2900
+ }
2901
+ const execution = parsedExecution.data;
2902
+ const plan = await this.authorizeWorkerExecution(execution, false);
2903
+ let opened;
2904
+ const connection = {
2905
+ cluster: { id: plan.cluster.id ?? '', name: plan.cluster.name ?? '' },
2906
+ request: (path, init, allowNotFound, allowConflict) => {
2907
+ opened ??= this.connect(plan);
2908
+ return opened.then((live) => live.request(path, init, allowNotFound, allowConflict));
2909
+ }
2910
+ };
2911
+ const query = new URLSearchParams({ fieldManager: plan.fieldManager, force: 'false' });
2912
+ const bindings = plan.credentialBindings ?? [];
2913
+ const job = plan.operation === 'build'
2914
+ ? plan.resources.find((resource) => resource.kind === 'Job')
2915
+ : undefined;
2916
+ let owner;
2917
+ let delivered;
2918
+ let appliedPlan = plan;
2919
+ const createJob = Boolean(job);
2920
+ const isOwnedJob = (body) => {
2921
+ if (!job)
2922
+ return false;
2923
+ const expectedLabels = KubernetesExpectedLabelsSchema.parse(job.manifest).metadata?.labels ?? {};
2924
+ return (body.metadata?.labels?.['app.kubernetes.io/managed-by'] === plan.fieldManager &&
2925
+ body.metadata?.labels?.['vibes.dev/source-sha'] === expectedLabels['vibes.dev/source-sha'] &&
2926
+ body.metadata?.labels?.['app.kubernetes.io/name'] === expectedLabels['app.kubernetes.io/name']);
2927
+ };
2928
+ try {
2929
+ if (bindings.length > 0) {
2930
+ owner = await this.workerCredentialDeliveryOwner();
2931
+ await owner.admitWorkerCredentials(plan);
2932
+ }
2933
+ // Target admission is credential-free. Once it passes, create the
2934
+ // Namespace before the delivery owner writes any namespaced Secret.
2935
+ const namespace = plan.resources.find((resource) => resource.kind === 'Namespace');
2936
+ if (namespace) {
2937
+ await connection.request(`${resourcePath(namespace)}?${query}`, {
2938
+ method: 'PATCH',
2939
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2940
+ body: JSON.stringify(namespace.manifest)
2941
+ });
2942
+ }
2943
+ // Reuse a live Job and its issued operation lease. A failed Job must
2944
+ // relinquish that exact lease and Secret before UID-safe replacement.
2945
+ if (job) {
2946
+ const existing = await connection.request(resourcePath(job), { method: 'GET', headers: { accept: 'application/json' } }, true);
2947
+ if (existing) {
2948
+ const body = KubernetesOwnedJobSchema.parse(await existing.json());
2949
+ if (!isOwnedJob(body)) {
2950
+ throw new Error(`Refusing to mutate foreign DOKS worker build Job ${job.namespace}/${job.name}.`);
2951
+ }
2952
+ const failed = (body.status?.failed ?? 0) > 0 ||
2953
+ body.status?.conditions?.some((condition) => condition.type === 'Failed' && condition.status === 'True');
2954
+ if (failed) {
2955
+ const oldOperation = this.recoverWorkerSourceLease(plan, body);
2956
+ await this.revokeWorkerSourceLease(oldOperation);
2957
+ const uid = boundedStatusToken(body.metadata?.uid, '');
2958
+ if (!uid) {
2959
+ throw new Error(`Refusing to replace DOKS worker build Job ${job.namespace}/${job.name} without its UID.`);
2960
+ }
2961
+ await connection.request(resourcePath(job), {
2962
+ method: 'DELETE',
2963
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
2964
+ body: JSON.stringify({ propagationPolicy: 'Background', preconditions: { uid } })
2965
+ });
2966
+ for (let attempt = 0; attempt < 120; attempt += 1) {
2967
+ const remaining = await connection.request(resourcePath(job), { method: 'GET', headers: { accept: 'application/json' } }, true);
2968
+ if (!remaining)
2969
+ break;
2970
+ if (attempt === 119) {
2971
+ throw new Error(`Timed out waiting to replace failed DOKS worker build Job ${job.namespace}/${job.name}.`);
2972
+ }
2973
+ await this.runtime.lifecycle.delay(250);
2974
+ }
2975
+ }
2976
+ else {
2977
+ return this.recoverWorkerSourceLease(plan, body);
2978
+ }
2979
+ }
2980
+ }
2981
+ if (bindings.length > 0) {
2982
+ if (!owner) {
2983
+ throw new CapabilityError('The DOKS Secret credential delivery owner is not composed.', 'UNAVAILABLE', { reason: 'doks-worker-credential-owner-unavailable' });
2984
+ }
2985
+ delivered = DigitalOceanDoksWorkerPlanSchema.parse(await owner.prepareWorkerCredentials(plan));
2986
+ if (plan.operation === 'build') {
2987
+ if (!delivered.credentialLease) {
2988
+ throw invalidWorkerPlan('DOKS build credential delivery returned no source lease.', 'doks-worker-source-lease-missing');
2989
+ }
2990
+ appliedPlan = await this.authorizeWorkerPlan(execution, true, delivered.credentialLease);
2991
+ }
2992
+ }
2993
+ for (const resource of appliedPlan.resources) {
2994
+ if (resource.kind === 'Job' || resource.kind === 'Namespace')
2995
+ continue;
2996
+ await connection.request(`${resourcePath(resource)}?${query}`, {
2997
+ method: 'PATCH',
2998
+ headers: { 'content-type': 'application/apply-patch+yaml', accept: 'application/json' },
2999
+ body: JSON.stringify(resource.manifest)
3000
+ });
3001
+ }
3002
+ if (job && createJob) {
3003
+ const appliedJob = appliedPlan.resources.find((resource) => resource.kind === 'Job');
3004
+ if (!appliedJob) {
3005
+ throw invalidWorkerPlan('DOKS build execution has no authorized Job.', 'doks-worker-job-missing');
3006
+ }
3007
+ const createPath = `/apis/batch/v1/namespaces/${encodeURIComponent(appliedJob.namespace ?? 'default')}/jobs`;
3008
+ const created = await connection.request(createPath, {
3009
+ method: 'POST',
3010
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
3011
+ body: JSON.stringify(appliedJob.manifest)
3012
+ }, false, true);
3013
+ if (created?.status === 409) {
3014
+ const occupant = await connection.request(resourcePath(appliedJob), { method: 'GET', headers: { accept: 'application/json' } });
3015
+ if (!occupant) {
3016
+ throw new CapabilityError('DOKS worker Job create conflicted but no current Job can be recovered.', 'UNAVAILABLE', { reason: 'doks-worker-job-race-evidence-unavailable' });
3017
+ }
3018
+ const body = KubernetesOwnedJobSchema.parse(await occupant.json());
3019
+ if (!isOwnedJob(body)) {
3020
+ throw new Error(`Refusing to accept foreign DOKS worker build Job ${appliedJob.namespace}/${appliedJob.name} after create conflict.`);
3021
+ }
3022
+ const winner = this.recoverWorkerSourceLease(plan, body);
3023
+ const losingOperation = delivered;
3024
+ delivered = undefined;
3025
+ if (losingOperation?.credentialLease) {
3026
+ await this.revokeWorkerSourceLease(losingOperation);
3027
+ }
3028
+ return winner;
3029
+ }
3030
+ }
3031
+ return appliedPlan;
3032
+ }
3033
+ catch (cause) {
3034
+ const issued = delivered;
3035
+ delivered = undefined;
3036
+ if (issued?.credentialLease) {
3037
+ const revoked = await settle(() => this.revokeWorkerSourceLease(issued));
3038
+ if (!revoked.ok) {
3039
+ throw new CapabilityError('DOKS worker apply and credential cleanup failed.', 'UNAVAILABLE', {
3040
+ reason: 'doks-worker-apply-cleanup-failed',
3041
+ cause: new AggregateError([cause, revoked.cause])
3042
+ });
3043
+ }
3044
+ }
3045
+ throw cause;
3046
+ }
3047
+ }
3048
+ async getWorkerPlanStatus(input) {
3049
+ const parsedExecution = DigitalOceanDoksWorkerExecutionInputSchema.safeParse(input);
3050
+ if (!parsedExecution.success) {
3051
+ throw invalidWorkerPlan('DOKS worker execution input is invalid.', 'doks-worker-execution-invalid');
3052
+ }
3053
+ const plan = await this.authorizeWorkerExecution(parsedExecution.data, true);
3054
+ const target = plan.resources.find((item) => plan.operation === 'build' ? item.kind === 'Job' : item.kind === 'Deployment');
3055
+ if (!target)
3056
+ throw new Error(`DOKS worker ${plan.operation} plan has no status-bearing resource.`);
3057
+ const connection = await this.connect(plan);
3058
+ const response = await connection.request(resourcePath(target), {
3059
+ method: 'GET',
3060
+ headers: { accept: 'application/json' }
3061
+ });
3062
+ const body = KubernetesWorkloadStatusSchema.parse(await response.json());
3063
+ const conditions = body.status?.conditions ?? [];
3064
+ const errors = [];
3065
+ const recordFailure = (reason, cause) => {
3066
+ errors.push(new CapabilityError('DOKS worker evidence is incomplete.', 'UNAVAILABLE', { reason, cause }));
3067
+ };
3068
+ if (plan.operation === 'build') {
3069
+ const cleanupCredential = async () => {
3070
+ const revokeErrorResult = await settle(async () => {
3071
+ const operationPlan = this.recoverWorkerSourceLease(plan, body);
3072
+ await this.revokeWorkerSourceLease(operationPlan);
3073
+ return true;
3074
+ });
3075
+ if (revokeErrorResult.ok)
3076
+ return revokeErrorResult.value;
3077
+ {
3078
+ const revokeError = revokeErrorResult.cause;
3079
+ recordFailure('doks-worker-credential-cleanup-failed', revokeError);
3080
+ return false;
3081
+ }
3082
+ };
3083
+ const podDiagnostic = async () => {
3084
+ const diagnosticErrorResult = await settle(async () => {
3085
+ const namespace = target.namespace ?? 'default';
3086
+ const selector = new URLSearchParams({ labelSelector: `job-name=${target.name}` });
3087
+ const podResponse = await connection.request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?${selector}`, { method: 'GET', headers: { accept: 'application/json' } });
3088
+ const pods = KubernetesBuildPodsSchema.parse(await podResponse.json());
3089
+ const pod = pods.items?.[0];
3090
+ if (!pod) {
3091
+ const jobUid = boundedStatusToken(body.metadata?.uid, '');
3092
+ if (!jobUid)
3093
+ return 'NoPods';
3094
+ const eventSelector = new URLSearchParams({
3095
+ fieldSelector: `involvedObject.kind=Job,involvedObject.name=${target.name},involvedObject.uid=${jobUid}`
3096
+ });
3097
+ const eventResponse = await connection.request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/events?${eventSelector}`, { method: 'GET', headers: { accept: 'application/json' } });
3098
+ const events = KubernetesEventsSchema.parse(await eventResponse.json());
3099
+ const event = (events.items ?? []).sort((left, right) => String(right.eventTime ?? right.lastTimestamp ?? '').localeCompare(String(left.eventTime ?? left.lastTimestamp ?? '')))[0];
3100
+ const reason = boundedStatusToken(event?.reason, '');
3101
+ const classification = classifyKubernetesEventMessage(event?.message);
3102
+ return ['NoPods', reason, classification].filter(Boolean).join('/');
3103
+ }
3104
+ const containerReason = pod.status?.containerStatuses
3105
+ ?.map((item) => boundedStatusToken(item.state?.waiting?.reason ?? item.state?.terminated?.reason, ''))
3106
+ .find(Boolean);
3107
+ const conditionReason = boundedStatusToken(pod.status?.conditions?.find((item) => item.status === 'False' && item.reason)?.reason, '');
3108
+ let logClassification = '';
3109
+ const diagnosticPodName = pod.metadata?.name;
3110
+ if (diagnosticPodName && (pod.status?.phase === 'Failed' || containerReason)) {
3111
+ const logErrorResult = await settle(async () => {
3112
+ const logResponse = await connection.request(`/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(diagnosticPodName)}/log?container=builder&tailLines=20`, { method: 'GET', headers: {} });
3113
+ if (logResponse?.ok) {
3114
+ logClassification = classifyKubernetesEventMessage(await logResponse.text());
3115
+ }
3116
+ });
3117
+ if (!logErrorResult.ok) {
3118
+ const logError = logErrorResult.cause;
3119
+ recordFailure('doks-worker-log-evidence-unavailable', logError);
3120
+ }
3121
+ }
3122
+ return [
3123
+ boundedStatusToken(pod.status?.phase, 'Unknown'),
3124
+ containerReason || conditionReason,
3125
+ logClassification
3126
+ ]
3127
+ .filter(Boolean)
3128
+ .join('/');
3129
+ });
3130
+ if (diagnosticErrorResult.ok)
3131
+ return diagnosticErrorResult.value;
3132
+ {
3133
+ const diagnosticError = diagnosticErrorResult.cause;
3134
+ recordFailure('doks-worker-diagnostics-unavailable', diagnosticError);
3135
+ return 'DiagnosticsUnavailable';
3136
+ }
3137
+ };
3138
+ const failure = conditions.find((item) => item.type === 'Failed' && item.status === 'True');
3139
+ if ((body.status?.failed ?? 0) > 0 || failure) {
3140
+ const cleaned = await cleanupCredential();
3141
+ return {
3142
+ state: 'failed',
3143
+ reason: `${boundedStatusToken(failure?.reason, 'JobFailed')} (${await podDiagnostic()})${cleaned ? '' : '/CredentialCleanupFailed'}`,
3144
+ worker: plan.worker,
3145
+ operation: plan.operation,
3146
+ errors: errors.length > 0 ? errors : undefined
3147
+ };
3148
+ }
3149
+ if ((body.status?.succeeded ?? 0) > 0) {
3150
+ const cleaned = await cleanupCredential();
3151
+ return {
3152
+ state: cleaned ? 'succeeded' : 'failed',
3153
+ reason: cleaned ? 'JobComplete' : 'JobComplete/CredentialCleanupFailed',
3154
+ worker: plan.worker,
3155
+ operation: plan.operation,
3156
+ errors: errors.length > 0 ? errors : undefined
3157
+ };
3158
+ }
3159
+ return {
3160
+ state: 'pending',
3161
+ reason: `JobRunning (${await podDiagnostic()})`,
3162
+ worker: plan.worker,
3163
+ operation: plan.operation,
3164
+ errors: errors.length > 0 ? errors : undefined
3165
+ };
3166
+ }
3167
+ const failed = conditions.find((item) => item.type === 'Progressing' &&
3168
+ item.status === 'False' &&
3169
+ item.reason === 'ProgressDeadlineExceeded');
3170
+ if (failed)
3171
+ return {
3172
+ state: 'failed',
3173
+ reason: 'ProgressDeadlineExceeded',
3174
+ worker: plan.worker,
3175
+ operation: plan.operation,
3176
+ errors: errors.length > 0 ? errors : undefined
3177
+ };
3178
+ const desired = Number(KubernetesReplicaSpecSchema.parse(target.manifest).spec?.replicas ?? 1);
3179
+ const generation = body.metadata?.generation ?? 0;
3180
+ const ready = (body.status?.observedGeneration ?? -1) >= generation &&
3181
+ (body.status?.replicas ?? 0) === desired &&
3182
+ (body.status?.availableReplicas ?? 0) >= desired &&
3183
+ (body.status?.updatedReplicas ?? 0) >= desired;
3184
+ return ready
3185
+ ? {
3186
+ state: 'succeeded',
3187
+ reason: 'DeploymentAvailable',
3188
+ worker: plan.worker,
3189
+ operation: plan.operation,
3190
+ errors: errors.length > 0 ? errors : undefined
3191
+ }
3192
+ : {
3193
+ state: 'pending',
3194
+ reason: 'DeploymentProgressing',
3195
+ worker: plan.worker,
3196
+ operation: plan.operation,
3197
+ errors: errors.length > 0 ? errors : undefined
3198
+ };
3199
+ }
3200
+ }
3201
+ //# sourceMappingURL=service-release.js.map