@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.
- package/LICENSE +126 -0
- package/README.md +78 -2
- package/dist/ai/index.d.ts +4 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +4 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/inference.transport.impl.d.ts +5 -0
- package/dist/ai/inference.transport.impl.d.ts.map +1 -0
- package/dist/ai/inference.transport.impl.js +54 -0
- package/dist/ai/inference.transport.impl.js.map +1 -0
- package/dist/ai/provider.plugin.d.ts +6 -0
- package/dist/ai/provider.plugin.d.ts.map +1 -0
- package/dist/ai/provider.plugin.js +126 -0
- package/dist/ai/provider.plugin.js.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.d.ts +62 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.d.ts.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.js +73 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/config/schemas/index.js.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/index.d.ts +2 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/index.d.ts.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/index.js +2 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/index.js.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.d.ts +183 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.d.ts.map +1 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.js +66 -0
- package/dist/ai/providers/cloud/frontier/digitalocean/provider.digitalocean.descriptor.js.map +1 -0
- package/dist/apps/api-client.d.ts +45 -0
- package/dist/apps/api-client.d.ts.map +1 -0
- package/dist/apps/api-client.js +47 -0
- package/dist/apps/api-client.js.map +1 -0
- package/dist/apps/build-spec.d.ts +44 -0
- package/dist/apps/build-spec.d.ts.map +1 -0
- package/dist/apps/build-spec.js +463 -0
- package/dist/apps/build-spec.js.map +1 -0
- package/dist/apps/deploy-client.d.ts +42 -0
- package/dist/apps/deploy-client.d.ts.map +1 -0
- package/dist/apps/deploy-client.js +300 -0
- package/dist/apps/deploy-client.js.map +1 -0
- package/dist/apps/index.d.ts +5 -0
- package/dist/apps/index.d.ts.map +1 -0
- package/dist/apps/index.js +4 -0
- package/dist/apps/index.js.map +1 -0
- package/dist/apps/types.d.ts +204 -0
- package/dist/apps/types.d.ts.map +1 -0
- package/dist/apps/types.js +13 -0
- package/dist/apps/types.js.map +1 -0
- package/dist/client/control-plane.d.ts +49 -0
- package/dist/client/control-plane.d.ts.map +1 -0
- package/dist/client/control-plane.js +85 -0
- package/dist/client/control-plane.js.map +1 -0
- package/dist/client/do-fetch.d.ts +41 -0
- package/dist/client/do-fetch.d.ts.map +1 -0
- package/dist/client/do-fetch.js +199 -0
- package/dist/client/do-fetch.js.map +1 -0
- package/dist/client/errors.d.ts +6 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +34 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/factory.d.ts +53 -0
- package/dist/client/factory.d.ts.map +1 -0
- package/dist/client/factory.js +220 -0
- package/dist/client/factory.js.map +1 -0
- package/dist/client/helpers.d.ts +5 -0
- package/dist/client/helpers.d.ts.map +1 -0
- package/dist/client/helpers.js +8 -0
- package/dist/client/helpers.js.map +1 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/paginated.d.ts +19 -0
- package/dist/client/paginated.d.ts.map +1 -0
- package/dist/client/paginated.js +56 -0
- package/dist/client/paginated.js.map +1 -0
- package/dist/client/retry.d.ts +34 -0
- package/dist/client/retry.d.ts.map +1 -0
- package/dist/client/retry.js +53 -0
- package/dist/client/retry.js.map +1 -0
- package/dist/config/digitalocean.config.descriptor.d.ts +197 -0
- package/dist/config/digitalocean.config.descriptor.d.ts.map +1 -0
- package/dist/config/digitalocean.config.descriptor.js +59 -0
- package/dist/config/digitalocean.config.descriptor.js.map +1 -0
- package/dist/credentials/index.d.ts +38 -0
- package/dist/credentials/index.d.ts.map +1 -0
- package/dist/credentials/index.js +56 -0
- package/dist/credentials/index.js.map +1 -0
- package/dist/databases/index.d.ts +82 -0
- package/dist/databases/index.d.ts.map +1 -0
- package/dist/databases/index.js +57 -0
- package/dist/databases/index.js.map +1 -0
- package/dist/digitalocean-friction.policy.d.ts +29 -0
- package/dist/digitalocean-friction.policy.d.ts.map +1 -0
- package/dist/digitalocean-friction.policy.js +21 -0
- package/dist/digitalocean-friction.policy.js.map +1 -0
- package/dist/digitalocean.plugin.d.ts +3 -0
- package/dist/digitalocean.plugin.d.ts.map +1 -0
- package/dist/digitalocean.plugin.js +27 -0
- package/dist/digitalocean.plugin.js.map +1 -0
- package/dist/droplets/index.d.ts +106 -0
- package/dist/droplets/index.d.ts.map +1 -0
- package/dist/droplets/index.js +227 -0
- package/dist/droplets/index.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/inference-control-plane/index.d.ts +63 -0
- package/dist/inference-control-plane/index.d.ts.map +1 -0
- package/dist/inference-control-plane/index.js +93 -0
- package/dist/inference-control-plane/index.js.map +1 -0
- package/dist/infra/billing.d.ts +26 -0
- package/dist/infra/billing.d.ts.map +1 -0
- package/dist/infra/billing.js +43 -0
- package/dist/infra/billing.js.map +1 -0
- package/dist/infra/credentials.d.ts +11 -0
- package/dist/infra/credentials.d.ts.map +1 -0
- package/dist/infra/credentials.js +15 -0
- package/dist/infra/credentials.js.map +1 -0
- package/dist/infra/deployment-provider/deploy-secrets.d.ts +10 -0
- package/dist/infra/deployment-provider/deploy-secrets.d.ts.map +1 -0
- package/dist/infra/deployment-provider/deploy-secrets.js +66 -0
- package/dist/infra/deployment-provider/deploy-secrets.js.map +1 -0
- package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.d.ts +97 -0
- package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.d.ts.map +1 -0
- package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.js +242 -0
- package/dist/infra/deployment-provider/digitalocean-deployment-provider.impl.js.map +1 -0
- package/dist/infra/deployment-provider/image-publish.d.ts +39 -0
- package/dist/infra/deployment-provider/image-publish.d.ts.map +1 -0
- package/dist/infra/deployment-provider/image-publish.js +146 -0
- package/dist/infra/deployment-provider/image-publish.js.map +1 -0
- package/dist/infra/deployment-provider.descriptor.d.ts +34 -0
- package/dist/infra/deployment-provider.descriptor.d.ts.map +1 -0
- package/dist/infra/deployment-provider.descriptor.js +9 -0
- package/dist/infra/deployment-provider.descriptor.js.map +1 -0
- package/dist/infra/digitalocean-infra.plugin.d.ts +101 -0
- package/dist/infra/digitalocean-infra.plugin.d.ts.map +1 -0
- package/dist/infra/digitalocean-infra.plugin.js +283 -0
- package/dist/infra/digitalocean-infra.plugin.js.map +1 -0
- package/dist/infra/doks/clients.d.ts +183 -0
- package/dist/infra/doks/clients.d.ts.map +1 -0
- package/dist/infra/doks/clients.js +172 -0
- package/dist/infra/doks/clients.js.map +1 -0
- package/dist/infra/doks/digitalocean.d.ts +34 -0
- package/dist/infra/doks/digitalocean.d.ts.map +1 -0
- package/dist/infra/doks/digitalocean.js +267 -0
- package/dist/infra/doks/digitalocean.js.map +1 -0
- package/dist/infra/doks/service-release-schemas.d.ts +1037 -0
- package/dist/infra/doks/service-release-schemas.d.ts.map +1 -0
- package/dist/infra/doks/service-release-schemas.js +51 -0
- package/dist/infra/doks/service-release-schemas.js.map +1 -0
- package/dist/infra/doks/service-release.d.ts +415 -0
- package/dist/infra/doks/service-release.d.ts.map +1 -0
- package/dist/infra/doks/service-release.js +3201 -0
- package/dist/infra/doks/service-release.js.map +1 -0
- package/dist/infra/doks/worker.d.ts +14 -0
- package/dist/infra/doks/worker.d.ts.map +1 -0
- package/dist/infra/doks/worker.js +343 -0
- package/dist/infra/doks/worker.js.map +1 -0
- package/dist/infra/droplet.impl.d.ts +172 -0
- package/dist/infra/droplet.impl.d.ts.map +1 -0
- package/dist/infra/droplet.impl.js +379 -0
- package/dist/infra/droplet.impl.js.map +1 -0
- package/dist/infra/functions.impl.d.ts +123 -0
- package/dist/infra/functions.impl.d.ts.map +1 -0
- package/dist/infra/functions.impl.js +202 -0
- package/dist/infra/functions.impl.js.map +1 -0
- package/dist/infra/index.d.ts +15 -0
- package/dist/infra/index.d.ts.map +1 -0
- package/dist/infra/index.js +12 -0
- package/dist/infra/index.js.map +1 -0
- package/dist/infra/inventory.d.ts +7 -0
- package/dist/infra/inventory.d.ts.map +1 -0
- package/dist/infra/inventory.js +259 -0
- package/dist/infra/inventory.js.map +1 -0
- package/dist/infra/kubernetes-log-stream.d.ts +5 -0
- package/dist/infra/kubernetes-log-stream.d.ts.map +1 -0
- package/dist/infra/kubernetes-log-stream.js +186 -0
- package/dist/infra/kubernetes-log-stream.js.map +1 -0
- package/dist/infra/kubernetes-logs.d.ts +33 -0
- package/dist/infra/kubernetes-logs.d.ts.map +1 -0
- package/dist/infra/kubernetes-logs.descriptor.d.ts +14 -0
- package/dist/infra/kubernetes-logs.descriptor.d.ts.map +1 -0
- package/dist/infra/kubernetes-logs.descriptor.js +28 -0
- package/dist/infra/kubernetes-logs.descriptor.js.map +1 -0
- package/dist/infra/kubernetes-logs.js +196 -0
- package/dist/infra/kubernetes-logs.js.map +1 -0
- package/dist/infra/utilization.d.ts +44 -0
- package/dist/infra/utilization.d.ts.map +1 -0
- package/dist/infra/utilization.js +108 -0
- package/dist/infra/utilization.js.map +1 -0
- package/dist/kubernetes/authority.d.ts +40 -0
- package/dist/kubernetes/authority.d.ts.map +1 -0
- package/dist/kubernetes/authority.js +90 -0
- package/dist/kubernetes/authority.js.map +1 -0
- package/dist/kubernetes/encoding.d.ts +4 -0
- package/dist/kubernetes/encoding.d.ts.map +1 -0
- package/dist/kubernetes/encoding.js +8 -0
- package/dist/kubernetes/encoding.js.map +1 -0
- package/dist/kubernetes/index.d.ts +105 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +99 -0
- package/dist/kubernetes/index.js.map +1 -0
- package/dist/kubernetes/transport.d.ts +11 -0
- package/dist/kubernetes/transport.d.ts.map +1 -0
- package/dist/kubernetes/transport.js +27 -0
- package/dist/kubernetes/transport.js.map +1 -0
- package/dist/network/index.d.ts +24 -0
- package/dist/network/index.d.ts.map +1 -0
- package/dist/network/index.js +16 -0
- package/dist/network/index.js.map +1 -0
- package/dist/registry/index.d.ts +39 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +20 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/schemas/app-deployment.d.ts +1474 -0
- package/dist/schemas/app-deployment.d.ts.map +1 -0
- package/dist/schemas/app-deployment.js +156 -0
- package/dist/schemas/app-deployment.js.map +1 -0
- package/dist/schemas/app-platform.d.ts +755 -0
- package/dist/schemas/app-platform.d.ts.map +1 -0
- package/dist/schemas/app-platform.js +253 -0
- package/dist/schemas/app-platform.js.map +1 -0
- package/dist/schemas/cache-database.d.ts +210 -0
- package/dist/schemas/cache-database.d.ts.map +1 -0
- package/dist/schemas/cache-database.js +105 -0
- package/dist/schemas/cache-database.js.map +1 -0
- package/dist/schemas/control-plane.d.ts +456 -0
- package/dist/schemas/control-plane.d.ts.map +1 -0
- package/dist/schemas/control-plane.js +123 -0
- package/dist/schemas/control-plane.js.map +1 -0
- package/dist/schemas/credential-use.d.ts +714 -0
- package/dist/schemas/credential-use.d.ts.map +1 -0
- package/dist/schemas/credential-use.js +73 -0
- package/dist/schemas/credential-use.js.map +1 -0
- package/dist/schemas/doks-worker.d.ts +4071 -0
- package/dist/schemas/doks-worker.d.ts.map +1 -0
- package/dist/schemas/doks-worker.js +330 -0
- package/dist/schemas/doks-worker.js.map +1 -0
- package/dist/schemas/droplet.d.ts +110 -0
- package/dist/schemas/droplet.d.ts.map +1 -0
- package/dist/schemas/droplet.js +22 -0
- package/dist/schemas/droplet.js.map +1 -0
- package/dist/schemas/functions.d.ts +22 -0
- package/dist/schemas/functions.d.ts.map +1 -0
- package/dist/schemas/functions.js +25 -0
- package/dist/schemas/functions.js.map +1 -0
- package/dist/schemas/gitea-git-hosting.d.ts +20 -0
- package/dist/schemas/gitea-git-hosting.d.ts.map +1 -0
- package/dist/schemas/gitea-git-hosting.js +26 -0
- package/dist/schemas/gitea-git-hosting.js.map +1 -0
- package/dist/schemas/http.d.ts +2594 -0
- package/dist/schemas/http.d.ts.map +1 -0
- package/dist/schemas/http.js +4548 -0
- package/dist/schemas/http.js.map +1 -0
- package/dist/schemas/infra-kinds.d.ts +113 -0
- package/dist/schemas/infra-kinds.d.ts.map +1 -0
- package/dist/schemas/infra-kinds.js +93 -0
- package/dist/schemas/infra-kinds.js.map +1 -0
- package/dist/schemas/kubernetes-responses.d.ts +457 -0
- package/dist/schemas/kubernetes-responses.d.ts.map +1 -0
- package/dist/schemas/kubernetes-responses.js +681 -0
- package/dist/schemas/kubernetes-responses.js.map +1 -0
- package/dist/schemas/kubernetes.d.ts +143 -0
- package/dist/schemas/kubernetes.d.ts.map +1 -0
- package/dist/schemas/kubernetes.js +68 -0
- package/dist/schemas/kubernetes.js.map +1 -0
- package/dist/schemas/logs.d.ts +15 -0
- package/dist/schemas/logs.d.ts.map +1 -0
- package/dist/schemas/logs.js +11 -0
- package/dist/schemas/logs.js.map +1 -0
- package/dist/schemas/object-storage.d.ts +167 -0
- package/dist/schemas/object-storage.d.ts.map +1 -0
- package/dist/schemas/object-storage.js +124 -0
- package/dist/schemas/object-storage.js.map +1 -0
- package/dist/schemas/service-settings.d.ts +9 -0
- package/dist/schemas/service-settings.d.ts.map +1 -0
- package/dist/schemas/service-settings.js +21 -0
- package/dist/schemas/service-settings.js.map +1 -0
- package/dist/schemas/utilization.d.ts +57 -0
- package/dist/schemas/utilization.d.ts.map +1 -0
- package/dist/schemas/utilization.js +58 -0
- package/dist/schemas/utilization.js.map +1 -0
- package/dist/schemas/worker-status.d.ts +22 -0
- package/dist/schemas/worker-status.d.ts.map +1 -0
- package/dist/schemas/worker-status.js +14 -0
- package/dist/schemas/worker-status.js.map +1 -0
- package/dist/schemas/worker.d.ts +125 -0
- package/dist/schemas/worker.d.ts.map +1 -0
- package/dist/schemas/worker.js +129 -0
- package/dist/schemas/worker.js.map +1 -0
- package/dist/secrets/app-platform/app-credentials.impl.d.ts +14 -0
- package/dist/secrets/app-platform/app-credentials.impl.d.ts.map +1 -0
- package/dist/secrets/app-platform/app-credentials.impl.js +237 -0
- package/dist/secrets/app-platform/app-credentials.impl.js.map +1 -0
- package/dist/secrets/container-registry/image-publish-settings.descriptor.d.ts +11 -0
- package/dist/secrets/container-registry/image-publish-settings.descriptor.d.ts.map +1 -0
- package/dist/secrets/container-registry/image-publish-settings.descriptor.js +26 -0
- package/dist/secrets/container-registry/image-publish-settings.descriptor.js.map +1 -0
- package/dist/secrets/doks-secret.descriptor.d.ts +18 -0
- package/dist/secrets/doks-secret.descriptor.d.ts.map +1 -0
- package/dist/secrets/doks-secret.descriptor.js +35 -0
- package/dist/secrets/doks-secret.descriptor.js.map +1 -0
- package/dist/secrets/doks-secret.impl.d.ts +98 -0
- package/dist/secrets/doks-secret.impl.d.ts.map +1 -0
- package/dist/secrets/doks-secret.impl.js +451 -0
- package/dist/secrets/doks-secret.impl.js.map +1 -0
- package/dist/secrets/index.d.ts +4 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +4 -0
- package/dist/secrets/index.js.map +1 -0
- package/dist/secrets/secrets-backend-doks.plugin.d.ts +3 -0
- package/dist/secrets/secrets-backend-doks.plugin.d.ts.map +1 -0
- package/dist/secrets/secrets-backend-doks.plugin.js +35 -0
- package/dist/secrets/secrets-backend-doks.plugin.js.map +1 -0
- package/dist/storage/index.d.ts +50 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +92 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/spaces-keys.d.ts +25 -0
- package/dist/storage/spaces-keys.d.ts.map +1 -0
- package/dist/storage/spaces-keys.js +32 -0
- package/dist/storage/spaces-keys.js.map +1 -0
- 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
|