@opensoha/contracts 0.1.17 → 0.1.19
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/COMPATIBILITY.md +72 -0
- package/README.md +38 -0
- package/auth/catalog_generated.go +2 -2
- package/auth/outpost_headers.go +17 -0
- package/auth/outpost_headers_test.go +16 -0
- package/auth/permission-catalog.json +383 -1
- package/compat/consumer-revisions.json +7 -0
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +78 -46
- package/delivery/canonical.go +136 -0
- package/delivery/document.go +203 -0
- package/delivery/document.schema.json +1527 -0
- package/delivery/document_test.go +191 -0
- package/delivery/parser.go +155 -0
- package/evaluation/evaluation-run.schema.json +64 -10
- package/fixtures/json-schema/gateway-manifest/invalid/bad-execution-mode.json +73 -0
- package/fixtures/json-schema/gateway-manifest/invalid/unversioned-check.json +85 -0
- package/fixtures/json-schema/gateway-manifest/valid/checks.json +86 -0
- package/fixtures/json-schema/gateway-manifest/valid/versioned-task.json +73 -0
- package/fixtures/json-schema/network-ingest-event/invalid/vpn-probe-negative-rtt.json +34 -0
- package/fixtures/json-schema/network-ingest-event/invalid/vpn-stats-secret.json +27 -0
- package/fixtures/json-schema/network-ingest-event/valid/vpn-probe-batch.json +45 -0
- package/fixtures/json-schema/network-ingest-event/valid/vpn-tunnel-stats.json +26 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/vpn-managed-bad-token.json +15 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/vpn-managed-mode-override.json +16 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/managed-vpn-macos-enrollment.json +27 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/vpn-managed-connect.json +15 -0
- package/fixtures/openapi/AgentChatInput/invalid/empty-question.json +4 -0
- package/fixtures/openapi/AgentChatInput/invalid/system-role.json +9 -0
- package/fixtures/openapi/AgentChatInput/valid/authorized-background.json +5 -0
- package/fixtures/openapi/AgentChatInput/valid/conversation.json +15 -0
- package/fixtures/openapi/AgentRunnerToolCallRequest/invalid/model-identity.json +1 -0
- package/fixtures/openapi/AgentRunnerToolCallRequest/valid/search.json +1 -0
- package/fixtures/openapi/ApplicationInput/invalid/negative-version.json +6 -0
- package/fixtures/openapi/ApplicationInput/invalid/zero-version.json +6 -0
- package/fixtures/openapi/ApplicationInput/valid/versioned.json +6 -0
- package/fixtures/openapi/ApprovalRequest/invalid/resolved-scopes.json +20 -0
- package/fixtures/openapi/ApprovalRequest/valid/resolved-scopes.json +21 -0
- package/fixtures/openapi/BuildTemplateInput/invalid/negative-revision.json +6 -0
- package/fixtures/openapi/BuildTemplateInput/valid/draft.json +11 -0
- package/fixtures/openapi/BuildTriggerRequest/invalid/no-ref.json +1 -0
- package/fixtures/openapi/BuildTriggerRequest/valid/no-environment.json +1 -0
- package/fixtures/openapi/BuildpacksConfiguration/invalid/mutable-builder.json +5 -0
- package/fixtures/openapi/BuildpacksConfiguration/invalid/privilege-override.json +6 -0
- package/fixtures/openapi/BuildpacksConfiguration/invalid/unsupported-platform.json +5 -0
- package/fixtures/openapi/BuildpacksConfiguration/valid/pinned.json +5 -0
- package/fixtures/openapi/CapabilityAssessment/invalid/contract.json +16 -0
- package/fixtures/openapi/CapabilityAssessment/valid/contract.json +16 -0
- package/fixtures/openapi/CapabilityPlan/invalid/contract.json +16 -0
- package/fixtures/openapi/CapabilityPlan/valid/contract.json +19 -0
- package/fixtures/openapi/CapabilityPlanValidation/invalid/contract.json +5 -0
- package/fixtures/openapi/CapabilityPlanValidation/valid/contract.json +11 -0
- package/fixtures/openapi/CapabilityTask/invalid/contract.json +33 -0
- package/fixtures/openapi/CapabilityTask/valid/contract.json +34 -0
- package/fixtures/openapi/CapabilityTaskInput/invalid/contract.json +22 -0
- package/fixtures/openapi/CapabilityTaskInput/valid/contract.json +22 -0
- package/fixtures/openapi/CapabilityValueSemantic/invalid/contract.json +4 -0
- package/fixtures/openapi/CapabilityValueSemantic/valid/contract.json +7 -0
- package/fixtures/openapi/ConnectionCheckResult/invalid/missing-health.json +1 -0
- package/fixtures/openapi/ConnectionCheckResult/invalid/task-id.json +1 -0
- package/fixtures/openapi/ConnectionCheckResult/valid/failure.json +1 -0
- package/fixtures/openapi/ConnectionCheckResult/valid/healthy.json +1 -0
- package/fixtures/openapi/DeliveryAccessResult/invalid/release-verification.json +8 -0
- package/fixtures/openapi/DeliveryAccessResult/valid/release-verification.json +8 -0
- package/fixtures/openapi/DeliveryBatchAssessment/invalid/release-verification.json +11 -0
- package/fixtures/openapi/DeliveryBatchAssessment/valid/release-verification.json +11 -0
- package/fixtures/openapi/DeliveryBatchAssessmentInput/invalid/release-verification.json +9 -0
- package/fixtures/openapi/DeliveryBatchAssessmentInput/valid/release-verification.json +8 -0
- package/fixtures/openapi/DeliveryBatchInput/invalid/ambiguous-source.json +19 -0
- package/fixtures/openapi/DeliveryBatchInput/invalid/missing-idempotency.json +16 -0
- package/fixtures/openapi/DeliveryBatchInput/valid/quick-deploy.json +17 -0
- package/fixtures/openapi/DeliveryBatchInput/valid/saved-workflow.json +5 -0
- package/fixtures/openapi/DeliveryBlueprintInput/invalid/unversioned-preset.json +31 -0
- package/fixtures/openapi/DeliveryBlueprintInput/valid/service-preset.json +32 -0
- package/fixtures/openapi/DeliveryDocument/invalid/unknown-field.json +24 -0
- package/fixtures/openapi/DeliveryDocument/invalid/unknown-kind.json +23 -0
- package/fixtures/openapi/DeliveryDocument/invalid/unknown-version.json +23 -0
- package/fixtures/openapi/DeliveryDocument/valid/build-template.json +26 -0
- package/fixtures/openapi/DeliveryDocument/valid/deployment-template.json +26 -0
- package/fixtures/openapi/DeliveryDocument/valid/legacy-dag.json +39 -0
- package/fixtures/openapi/DeliveryDocument/valid/workflow-template.json +23 -0
- package/fixtures/openapi/DeliveryDocument/valid/workflow.json +20 -0
- package/fixtures/openapi/DeliveryDraftInput/invalid/short-idempotency-key.json +30 -0
- package/fixtures/openapi/DeliveryDraftInput/valid/idempotent-create.json +30 -0
- package/fixtures/openapi/DeliveryDraftService/invalid/zero-version.json +7 -0
- package/fixtures/openapi/DeliveryDraftService/valid/versioned-template.json +9 -0
- package/fixtures/openapi/DeliveryExecutionHistoryPage/invalid-kind.json +9 -0
- package/fixtures/openapi/DeliveryExecutionHistoryPage/valid-build.json +16 -0
- package/fixtures/openapi/DeliveryExecutionHistoryPage/valid-empty.json +3 -0
- package/fixtures/openapi/DeliveryPlanApprovalInput/invalid/confirm.json +1 -0
- package/fixtures/openapi/DeliveryPlanApprovalInput/valid/approve.json +1 -0
- package/fixtures/openapi/DeliveryPlanInput/invalid/draft-manifest-revision.json +1 -0
- package/fixtures/openapi/DeliveryPlanInput/valid/manifest-revision.json +1 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/deploy-without-environment.json +7 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/build-without-environment.json +6 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/invalid/duplicate-kind.json +13 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/invalid/embedded-credentials.json +16 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/invalid/empty-kinds.json +10 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/invalid/negative-generation.json +15 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/invalid/unknown-kind.json +12 -0
- package/fixtures/openapi/DeliveryTemplateSourceInput/valid/manual-source.json +15 -0
- package/fixtures/openapi/DeliveryWorkflowDefinition/invalid/unversioned-template.json +15 -0
- package/fixtures/openapi/DeliveryWorkflowDefinition/valid/cross-app.json +24 -0
- package/fixtures/openapi/DeliveryWorkflowInput/invalid/short-key.json +1 -0
- package/fixtures/openapi/DeliveryWorkflowInput/valid/idempotent-create.json +1 -0
- package/fixtures/openapi/DeploymentTemplatePreviewInput/invalid/invalid-target-override.json +7 -0
- package/fixtures/openapi/DeploymentTemplatePreviewInput/invalid/invalid-zero-version.json +6 -0
- package/fixtures/openapi/DeploymentTemplatePreviewInput/valid/valid.json +6 -0
- package/fixtures/openapi/DockerDeliveryConfiguration/invalid/empty-mappings.json +5 -0
- package/fixtures/openapi/DockerDeliveryConfiguration/valid/compose.json +7 -0
- package/fixtures/openapi/DockerDeliverySnapshot/invalid/missing-required-field.json +19 -0
- package/fixtures/openapi/DockerDeliverySnapshot/valid/frozen-project.json +20 -0
- package/fixtures/openapi/DockerOperationCallbackRequest/invalid/cancellation-acknowledged-not-boolean.json +13 -0
- package/fixtures/openapi/DockerOperationCallbackRequest/valid/cancellation-acknowledged.json +13 -0
- package/fixtures/openapi/DockerProjectInput/invalid/empty-host.json +4 -0
- package/fixtures/openapi/DockerProjectInput/valid/inline-idempotent.json +7 -0
- package/fixtures/openapi/DockerQuickCreateHostInput/invalid/capacity-type.json +1 -0
- package/fixtures/openapi/DockerQuickCreateHostInput/valid/capacity.json +1 -0
- package/fixtures/openapi/HelmDeliveryConfiguration/invalid/plain-credential.json +21 -0
- package/fixtures/openapi/HelmDeliveryConfiguration/invalid/release-name.json +20 -0
- package/fixtures/openapi/HelmDeliveryConfiguration/valid/private-oci.json +20 -0
- package/fixtures/openapi/HelmDeliverySnapshot/invalid/secret-plan.json +31 -0
- package/fixtures/openapi/HelmDeliverySnapshot/invalid/unknown-operation.json +28 -0
- package/fixtures/openapi/HelmDeliverySnapshot/valid/first-install.json +28 -0
- package/fixtures/openapi/IdentityApplication/invalid/outpost-status.json +1 -0
- package/fixtures/openapi/IdentityApplicationOnboardingInput/invalid/missing-access.json +12 -0
- package/fixtures/openapi/IdentityApplicationOnboardingInput/valid/link.json +13 -0
- package/fixtures/openapi/IdentityOutpost/invalid/missing-runtime-status.json +9 -0
- package/fixtures/openapi/IdentityOutpost/valid/healthy.json +17 -0
- package/fixtures/openapi/IdentityOutpost/valid/unregistered.json +12 -0
- package/fixtures/openapi/IdentityOutpostDeployment/invalid/missing-protocol.json +3 -0
- package/fixtures/openapi/IdentityOutpostDeployment/valid/remote.json +6 -0
- package/fixtures/openapi/IdentityOutpostInput/invalid/forged-observation.json +7 -0
- package/fixtures/openapi/IdentityOutpostInput/valid/forward-auth.json +6 -0
- package/fixtures/openapi/IdentityOutpostInput/valid/legacy.json +8 -0
- package/fixtures/openapi/IdentityProviderSetup/invalid/fake-verification.json +1 -0
- package/fixtures/openapi/IdentityProviderSetup/valid/incomplete.json +1 -0
- package/fixtures/openapi/IdentityProviderSetup/valid/remote.json +1 -0
- package/fixtures/openapi/IdentityProviderUserMetadata/invalid/attributes.json +8 -0
- package/fixtures/openapi/IdentityProviderUserMetadata/valid/attributes.json +13 -0
- package/fixtures/openapi/KubernetesCustomResource/invalid/negative-generation.json +1 -0
- package/fixtures/openapi/KubernetesCustomResource/valid/deleting-operator.json +1 -0
- package/fixtures/openapi/KubernetesResourceCreationReceipt/invalid/manifest-content.json +20 -0
- package/fixtures/openapi/KubernetesResourceCreationReceipt/valid/confirmed.json +20 -0
- package/fixtures/openapi/KubernetesResourceCreationReceipt/valid/unknown.json +19 -0
- package/fixtures/openapi/ManifestDeploymentEnvelope/invalid/negative-resource-observation.json +57 -0
- package/fixtures/openapi/ManifestDeploymentEnvelope/valid/operator-deleting.json +57 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/gitops-empty.json +7 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/valid/gitops-frozen-children.json +18 -0
- package/fixtures/openapi/ManifestKustomizeOptions/invalid/escaping-entry.json +1 -0
- package/fixtures/openapi/ManifestKustomizeOptions/invalid/mutable-image.json +1 -0
- package/fixtures/openapi/ManifestKustomizeOptions/valid/overlay-images.json +7 -0
- package/fixtures/openapi/ManifestRevisionInput/invalid/missing-version.json +1 -0
- package/fixtures/openapi/ManifestRevisionInput/valid/current-draft.json +1 -0
- package/fixtures/openapi/ManifestServiceTemplateInputs/invalid/mutable-image.json +8 -0
- package/fixtures/openapi/ManifestServiceTemplateInputs/valid/frozen-service-image.json +9 -0
- package/fixtures/openapi/NetworkVPNConnectionIntentInput/invalid/auto-with-gateway.json +6 -0
- package/fixtures/openapi/NetworkVPNConnectionIntentInput/invalid/missing-manual-gateway.json +5 -0
- package/fixtures/openapi/NetworkVPNConnectionIntentInput/invalid/mode-override.json +7 -0
- package/fixtures/openapi/NetworkVPNConnectionIntentInput/valid/auto-intent.json +5 -0
- package/fixtures/openapi/NetworkVPNConnectionIntentInput/valid/manual-intent.json +6 -0
- package/fixtures/openapi/NetworkVPNProfileInput/invalid/unknown-private-key.json +25 -0
- package/fixtures/openapi/NetworkVPNProfileInput/valid/assigned-profile.json +24 -0
- package/fixtures/openapi/NetworkVPNSelectionPolicyInput/invalid/unbounded-attempts.json +21 -0
- package/fixtures/openapi/NetworkVPNSelectionPolicyInput/valid/provider-policy.json +21 -0
- package/fixtures/openapi/ObservabilityMetricAssessmentInput/invalid/arbitrary-expression.json +10 -0
- package/fixtures/openapi/ObservabilityMetricAssessmentInput/valid/metric-window.json +10 -0
- package/fixtures/openapi/PluginManifest/valid/hermes-api.json +53 -0
- package/fixtures/openapi/ProgressiveRolloutControlInput/invalid/missing-identity.json +5 -0
- package/fixtures/openapi/ProgressiveRolloutControlInput/invalid/skip-analysis.json +5 -0
- package/fixtures/openapi/ProgressiveRolloutControlInput/valid/pause.json +5 -0
- package/fixtures/openapi/ReleaseTargetInput/invalid/missing-required-field.json +15 -0
- package/fixtures/openapi/ReleaseTargetInput/valid/docker-compose.json +16 -0
- package/fixtures/openapi/RepositoryAnalysis/invalid/moving-ref.json +11 -0
- package/fixtures/openapi/RepositoryAnalysis/valid/go.json +29 -0
- package/fixtures/openapi/RepositoryAnalysisInput/invalid/arbitrary-source-url.json +6 -0
- package/fixtures/openapi/RepositoryAnalysisInput/invalid/missing-ref.json +4 -0
- package/fixtures/openapi/RepositoryAnalysisInput/valid/nested-project.json +6 -0
- package/fixtures/openapi/ServiceDeploymentTemplateBinding/invalid/invalid-unpinned.json +4 -0
- package/fixtures/openapi/ServiceDeploymentTemplateBinding/valid/valid.json +7 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/invalid-executable-schema.json +25 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/invalid-missing-schema.json +21 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/invalid-mixed-renderers.json +40 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/valid-nested-parameters.json +41 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/valid-yaml.json +34 -0
- package/fixtures/openapi/TemplatePublishInput/invalid/missing-revision.json +1 -0
- package/fixtures/openapi/TemplatePublishInput/invalid/unknown-field.json +4 -0
- package/fixtures/openapi/TemplatePublishInput/invalid/zero-revision.json +3 -0
- package/fixtures/openapi/TemplatePublishInput/valid/revision.json +3 -0
- package/fixtures/openapi/ToolCapability/invalid/bad-idempotency.json +35 -0
- package/fixtures/openapi/ToolCapability/invalid/unversioned-check.json +47 -0
- package/fixtures/openapi/ToolCapability/valid/checks.json +48 -0
- package/fixtures/openapi/ToolCapability/valid/versioned-task.json +35 -0
- package/fixtures/openapi/ToolInvocationRequest/invalid/empty-version.json +8 -0
- package/fixtures/openapi/ToolInvocationRequest/valid/versioned.json +8 -0
- package/fixtures/openapi/ToolInvocationResult/invalid/missing-status-call.json +13 -0
- package/fixtures/openapi/ToolInvocationResult/valid/task.json +20 -0
- package/fixtures/openapi/UpdateBrandingSettingsRequest/invalid/dark-logo-type.json +9 -0
- package/fixtures/openapi/UpdateBrandingSettingsRequest/valid/dark-logos.json +10 -0
- package/fixtures/openapi/VirtualMachineCreateInput/invalid/capacity-type.json +1 -0
- package/fixtures/openapi/VirtualMachineCreateInput/valid/capacity.json +1 -0
- package/fixtures/openapi/VirtualizationCapacityInput/invalid/missing-disk.json +1 -0
- package/fixtures/openapi/VirtualizationCapacityInput/valid/running-vm.json +1 -0
- package/fixtures/openapi/VirtualizationCapacityResult/invalid/invented-status.json +1 -0
- package/fixtures/openapi/VirtualizationCapacityResult/valid/available.json +1 -0
- package/fixtures/openapi/VirtualizationWorkerCreateInput/invalid/secret-input.json +5 -0
- package/fixtures/openapi/VirtualizationWorkerCreateInput/valid/one-worker.json +4 -0
- package/fixtures/openapi/VirtualizationWorkerPoolInput/invalid/external-owner.json +27 -0
- package/fixtures/openapi/VirtualizationWorkerPoolInput/invalid/zero-budget.json +27 -0
- package/fixtures/openapi/VirtualizationWorkerPoolInput/valid/registered.json +27 -0
- package/fixtures/openapi/VirtualizationWorkerPoolListEnvelope/invalid/nested-data.json +5 -0
- package/fixtures/openapi/VirtualizationWorkerPoolListEnvelope/valid/empty.json +3 -0
- package/fixtures/openapi/WorkbenchContextSelection/invalid/missing-file-content.json +8 -0
- package/fixtures/openapi/WorkbenchContextSelection/invalid/unknown-reference-kind.json +8 -0
- package/fixtures/openapi/WorkbenchContextSelection/valid/text-and-resource.json +17 -0
- package/fixtures/openapi/WorkbenchInspectionTaskInput/invalid/trigger-without-plan.json +10 -0
- package/fixtures/openapi/WorkbenchInspectionTaskInput/invalid/unknown-trigger.json +28 -0
- package/fixtures/openapi/WorkbenchInspectionTaskInput/invalid/zero-interval.json +28 -0
- package/fixtures/openapi/WorkbenchInspectionTaskInput/valid/alert-capability.json +30 -0
- package/fixtures/openapi/WorkbenchInspectionTaskInput/valid/scheduled-capability.json +28 -0
- package/fixtures/openapi/WorkbenchInspectionTrigger/invalid/missing-rule.json +3 -0
- package/fixtures/openapi/WorkbenchInspectionTrigger/invalid/schedule-alert-selector.json +4 -0
- package/fixtures/openapi/WorkbenchInspectionTrigger/valid/schedule.json +3 -0
- package/fixtures/openapi/WorkbenchModelPreferences/invalid/unknown-effort.json +1 -0
- package/fixtures/openapi/WorkbenchModelPreferences/valid/selected.json +1 -0
- package/fixtures/openapi/WorkflowCatalogPage/invalid/unsupported-kind.json +6 -0
- package/fixtures/openapi/WorkflowCatalogPage/valid/build-without-environment.json +6 -0
- package/fixtures/openapi/WorkflowTemplateInput/invalid/zero-revision.json +6 -0
- package/fixtures/openapi/WorkflowTemplateInput/valid/draft.json +10 -0
- package/gen/go/sohaapi/client.go +11 -3
- package/gen/go/sohaapi/client_test.go +26 -3
- package/gen/go/sohaapi/types.go +8103 -863
- package/gen/go/sohaapi/types_test.go +100 -1
- package/gen/go/sohaapi/vpn_models_test.go +21 -0
- package/gen/ts/sohaapi/index.ts +20390 -13160
- package/go.mod +107 -5
- package/go.sum +379 -16
- package/helmrelease/delivery.go +283 -0
- package/helmrelease/delivery_test.go +102 -0
- package/helmrelease/frozen_chart.go +91 -0
- package/helmrelease/result.go +27 -0
- package/helmrelease/runtime/delivery.go +508 -0
- package/helmrelease/runtime/delivery_test.go +236 -0
- package/helmrelease/runtime/resources_test.go +60 -0
- package/helmrelease/values.go +99 -0
- package/knowledge/context-envelope.schema.json +1 -0
- package/mcp/gateway-manifest.schema.json +387 -72
- package/network/README.md +40 -0
- package/network/network-ingest-event.schema.json +228 -0
- package/network/network-runtime-protocol.schema.json +457 -1
- package/openapi/soha-api.json +54874 -38722
- package/openapi/soha-api.yaml +12199 -4982
- package/openapi/spec_generated.go +3 -3
- package/package.json +3 -1
- package/resource/manifest_health.go +155 -0
- package/resource/manifest_health_test.go +37 -0
- package/resource/models.go +14 -0
- package/resource/runtime/argocd.go +166 -0
- package/resource/runtime/argocd_integration_test.go +142 -0
- package/resource/runtime/argocd_observation.go +143 -0
- package/resource/runtime/argocd_operation.go +253 -0
- package/resource/runtime/argocd_resources.go +115 -0
- package/resource/runtime/argocd_resources_test.go +107 -0
- package/resource/runtime/argocd_test.go +386 -0
- package/resource/runtime/health.go +115 -0
- package/resource/runtime/health_test.go +78 -0
- package/resource/runtime/operator_integration_test.go +174 -0
- package/resource/runtime/ownership.go +178 -0
- package/resource/runtime/ownership_test.go +178 -0
- package/resource/runtime/rollout_integration_test.go +287 -0
- package/resource/runtime/rollout_observation.go +252 -0
- package/resource/runtime/rollout_operation.go +398 -0
- package/resource/runtime/rollout_resources.go +385 -0
- package/resource/runtime/rollout_task.go +165 -0
- package/resource/runtime/rollout_test.go +450 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/url"
|
|
6
|
+
"reflect"
|
|
7
|
+
"slices"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
12
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const (
|
|
16
|
+
RolloutOwnerAnnotation = "delivery.soha.io/rollout-owner"
|
|
17
|
+
RolloutOperationAnnotation = "delivery.soha.io/rollout-operation"
|
|
18
|
+
rolloutPodHash = "rollouts-pod-template-hash"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
var rollouts = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"}
|
|
22
|
+
var analysisRuns = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "analysisruns"}
|
|
23
|
+
var rolloutResourceTypes = map[string]schema.GroupVersionResource{
|
|
24
|
+
"argoproj.io/Rollout": rollouts,
|
|
25
|
+
"argoproj.io/AnalysisTemplate": {Group: "argoproj.io", Version: "v1alpha1", Resource: "analysistemplates"},
|
|
26
|
+
"/Service": {Version: "v1", Resource: "services"},
|
|
27
|
+
"traefik.io/TraefikService": {Group: "traefik.io", Version: "v1alpha1", Resource: "traefikservices"},
|
|
28
|
+
"traefik.io/IngressRoute": {Group: "traefik.io", Version: "v1alpha1", Resource: "ingressroutes"},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func IsArgoRollout(object *unstructured.Unstructured) bool {
|
|
32
|
+
return object != nil && object.GetAPIVersion() == "argoproj.io/v1alpha1" && object.GetKind() == "Rollout"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ValidateRolloutResources admits native blue/green and Traefik canaries. Every
|
|
36
|
+
// traffic and analysis reference belongs to the same frozen namespace bundle.
|
|
37
|
+
func ValidateRolloutResources(documents []*unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
|
38
|
+
objects := map[string]*unstructured.Unstructured{}
|
|
39
|
+
var root *unstructured.Unstructured
|
|
40
|
+
for _, object := range documents {
|
|
41
|
+
if object == nil {
|
|
42
|
+
return nil, fmt.Errorf("rollout document is missing")
|
|
43
|
+
}
|
|
44
|
+
gvk := object.GroupVersionKind()
|
|
45
|
+
gvr, ok := rolloutResourceTypes[gvk.Group+"/"+gvk.Kind]
|
|
46
|
+
key := object.GetKind() + "/" + object.GetName()
|
|
47
|
+
if !ok || gvk.Version != gvr.Version || object.GetName() == "" || object.GetNamespace() == "" || objects[key] != nil {
|
|
48
|
+
return nil, fmt.Errorf("unsupported or repeated rollout document")
|
|
49
|
+
}
|
|
50
|
+
if object.GetUID() != "" || object.GetResourceVersion() != "" || object.GetGenerateName() != "" || object.GetDeletionTimestamp() != nil || len(object.GetOwnerReferences()) != 0 || len(object.GetFinalizers()) != 0 || object.Object["status"] != nil {
|
|
51
|
+
return nil, fmt.Errorf("rollout documents must contain independent desired resources")
|
|
52
|
+
}
|
|
53
|
+
if IsArgoRollout(object) {
|
|
54
|
+
if root != nil {
|
|
55
|
+
return nil, fmt.Errorf("rollout bundle requires exactly one Rollout")
|
|
56
|
+
}
|
|
57
|
+
root = object
|
|
58
|
+
}
|
|
59
|
+
objects[key] = object
|
|
60
|
+
}
|
|
61
|
+
if root == nil || len(objects) > 20 {
|
|
62
|
+
return nil, fmt.Errorf("rollout bundle requires one Rollout and at most 19 dependencies")
|
|
63
|
+
}
|
|
64
|
+
for _, object := range documents {
|
|
65
|
+
if object.GetNamespace() != root.GetNamespace() {
|
|
66
|
+
return nil, fmt.Errorf("rollout dependencies must stay in the target namespace")
|
|
67
|
+
}
|
|
68
|
+
clean := object.DeepCopy()
|
|
69
|
+
clean.SetKind("DesiredResource")
|
|
70
|
+
if err := ValidateDirectManifestOwner(clean); err != nil {
|
|
71
|
+
return nil, err
|
|
72
|
+
}
|
|
73
|
+
for key := range object.GetAnnotations() {
|
|
74
|
+
if strings.HasPrefix(key, "rollout.argoproj.io/") || strings.HasPrefix(key, "rollouts.argoproj.io/") || key == RolloutOwnerAnnotation || key == RolloutOperationAnnotation {
|
|
75
|
+
return nil, fmt.Errorf("rollout controller annotations are executor-owned")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
spec, _, _ := unstructured.NestedMap(root.Object, "spec")
|
|
80
|
+
if err := rolloutFields(spec, "replicas", "revisionHistoryLimit", "selector", "template", "strategy", "progressDeadlineSeconds", "progressDeadlineAbort", "minReadySeconds"); err != nil {
|
|
81
|
+
return nil, err
|
|
82
|
+
}
|
|
83
|
+
selector, _, _ := unstructured.NestedStringMap(root.Object, "spec", "selector", "matchLabels")
|
|
84
|
+
selectorSpec, _, _ := unstructured.NestedMap(root.Object, "spec", "selector")
|
|
85
|
+
if len(selector) == 0 || len(selectorSpec) != 1 || selector[rolloutPodHash] != "" {
|
|
86
|
+
return nil, fmt.Errorf("rollout requires fixed matchLabels without a controller hash")
|
|
87
|
+
}
|
|
88
|
+
labels, _, _ := unstructured.NestedStringMap(root.Object, "spec", "template", "metadata", "labels")
|
|
89
|
+
for key, value := range selector {
|
|
90
|
+
if labels[key] != value {
|
|
91
|
+
return nil, fmt.Errorf("rollout Pod labels do not match its selector")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for _, field := range []string{"containers", "initContainers"} {
|
|
95
|
+
containers, _, err := unstructured.NestedSlice(root.Object, "spec", "template", "spec", field)
|
|
96
|
+
if err != nil || field == "containers" && len(containers) == 0 {
|
|
97
|
+
return nil, fmt.Errorf("rollout requires container images")
|
|
98
|
+
}
|
|
99
|
+
for _, raw := range containers {
|
|
100
|
+
container, _ := raw.(map[string]any)
|
|
101
|
+
image, _ := container["image"].(string)
|
|
102
|
+
if _, parsed, err := ParseArgoImageOverride(image); err != nil || parsed != image {
|
|
103
|
+
return nil, fmt.Errorf("rollout images require immutable digests")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
used := map[string]bool{"Rollout/" + root.GetName(): true}
|
|
108
|
+
strategy, _, _ := unstructured.NestedMap(root.Object, "spec", "strategy")
|
|
109
|
+
if len(strategy) != 1 {
|
|
110
|
+
return nil, fmt.Errorf("choose exactly one native rollout strategy")
|
|
111
|
+
}
|
|
112
|
+
var serviceNames []string
|
|
113
|
+
if raw, ok := strategy["blueGreen"]; ok {
|
|
114
|
+
blue, _ := raw.(map[string]any)
|
|
115
|
+
if err := rolloutFields(blue, "activeService", "previewService", "autoPromotionEnabled", "prePromotionAnalysis", "postPromotionAnalysis", "scaleDownDelaySeconds", "abortScaleDownDelaySeconds", "previewReplicaCount"); err != nil {
|
|
116
|
+
return nil, err
|
|
117
|
+
}
|
|
118
|
+
if blue["autoPromotionEnabled"] != false {
|
|
119
|
+
return nil, fmt.Errorf("blue/green requires explicit promotion")
|
|
120
|
+
}
|
|
121
|
+
serviceNames = []string{argoString(root, "spec", "strategy", "blueGreen", "activeService"), argoString(root, "spec", "strategy", "blueGreen", "previewService")}
|
|
122
|
+
if err := rolloutAnalysisReference(blue["prePromotionAnalysis"], objects, used); err != nil {
|
|
123
|
+
return nil, err
|
|
124
|
+
}
|
|
125
|
+
if raw := blue["postPromotionAnalysis"]; raw != nil {
|
|
126
|
+
if err := rolloutAnalysisReference(raw, objects, used); err != nil {
|
|
127
|
+
return nil, err
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else if raw, ok := strategy["canary"]; ok {
|
|
131
|
+
canary, _ := raw.(map[string]any)
|
|
132
|
+
if err := rolloutFields(canary, "stableService", "canaryService", "trafficRouting", "steps", "maxSurge", "maxUnavailable", "scaleDownDelaySeconds", "abortScaleDownDelaySeconds"); err != nil {
|
|
133
|
+
return nil, err
|
|
134
|
+
}
|
|
135
|
+
serviceNames = []string{argoString(root, "spec", "strategy", "canary", "stableService"), argoString(root, "spec", "strategy", "canary", "canaryService")}
|
|
136
|
+
routing, _, _ := unstructured.NestedMap(root.Object, "spec", "strategy", "canary", "trafficRouting")
|
|
137
|
+
traefik, _ := routing["traefik"].(map[string]any)
|
|
138
|
+
weighted, _ := traefik["weightedTraefikServiceName"].(string)
|
|
139
|
+
if len(routing) != 1 || len(traefik) != 1 || weighted == "" {
|
|
140
|
+
return nil, fmt.Errorf("canary requires a frozen Traefik weighted service")
|
|
141
|
+
}
|
|
142
|
+
if err := validateRolloutTraffic(objects, used, serviceNames, weighted); err != nil {
|
|
143
|
+
return nil, err
|
|
144
|
+
}
|
|
145
|
+
steps, _ := canary["steps"].([]any)
|
|
146
|
+
weight, analyses := int64(-1), 0
|
|
147
|
+
if len(steps) == 0 || len(steps) > 30 {
|
|
148
|
+
return nil, fmt.Errorf("canary requires 1 to 30 explicit steps")
|
|
149
|
+
}
|
|
150
|
+
for _, raw := range steps {
|
|
151
|
+
step, _ := raw.(map[string]any)
|
|
152
|
+
if len(step) != 1 {
|
|
153
|
+
return nil, fmt.Errorf("canary step requires one action")
|
|
154
|
+
}
|
|
155
|
+
switch {
|
|
156
|
+
case step["setWeight"] != nil:
|
|
157
|
+
next, ok := step["setWeight"].(int64)
|
|
158
|
+
if !ok || next <= weight || next < 0 || next > 100 {
|
|
159
|
+
return nil, fmt.Errorf("canary weights must increase from 0 to 100")
|
|
160
|
+
}
|
|
161
|
+
weight = next
|
|
162
|
+
case step["pause"] != nil:
|
|
163
|
+
pause, ok := step["pause"].(map[string]any)
|
|
164
|
+
if !ok || rolloutFields(pause, "duration") != nil {
|
|
165
|
+
return nil, fmt.Errorf("invalid canary pause")
|
|
166
|
+
}
|
|
167
|
+
if value := pause["duration"]; value != nil {
|
|
168
|
+
if !validRolloutDuration(value) {
|
|
169
|
+
return nil, fmt.Errorf("pause duration must be positive and at most 24 hours")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
case step["analysis"] != nil:
|
|
173
|
+
if err := rolloutAnalysisReference(step["analysis"], objects, used); err != nil {
|
|
174
|
+
return nil, err
|
|
175
|
+
}
|
|
176
|
+
analyses++
|
|
177
|
+
default:
|
|
178
|
+
return nil, fmt.Errorf("unsupported canary step")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if weight != 100 || analyses == 0 {
|
|
182
|
+
return nil, fmt.Errorf("canary requires an analysis window and a final 100 percent step")
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
return nil, fmt.Errorf("unsupported rollout strategy")
|
|
186
|
+
}
|
|
187
|
+
if serviceNames[0] == "" || serviceNames[0] == serviceNames[1] {
|
|
188
|
+
return nil, fmt.Errorf("rollout requires two distinct frozen Services")
|
|
189
|
+
}
|
|
190
|
+
for _, name := range serviceNames {
|
|
191
|
+
service := objects["Service/"+name]
|
|
192
|
+
if service == nil {
|
|
193
|
+
return nil, fmt.Errorf("rollout Service %q is not frozen", name)
|
|
194
|
+
}
|
|
195
|
+
actual, _, _ := unstructured.NestedStringMap(service.Object, "spec", "selector")
|
|
196
|
+
if !reflect.DeepEqual(selector, actual) || argoString(service, "spec", "type") != "" && argoString(service, "spec", "type") != "ClusterIP" || argoString(service, "spec", "externalName") != "" {
|
|
197
|
+
return nil, fmt.Errorf("rollout Services require the fixed Pod selector and ClusterIP routing")
|
|
198
|
+
}
|
|
199
|
+
ports, _, _ := unstructured.NestedSlice(service.Object, "spec", "ports")
|
|
200
|
+
if len(ports) == 0 {
|
|
201
|
+
return nil, fmt.Errorf("rollout Service ports are required")
|
|
202
|
+
}
|
|
203
|
+
for _, raw := range ports {
|
|
204
|
+
port, ok := raw.(map[string]any)
|
|
205
|
+
if !ok || len(port) == 0 {
|
|
206
|
+
return nil, fmt.Errorf("rollout Service port must be an object")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
used["Service/"+name] = true
|
|
210
|
+
}
|
|
211
|
+
for key, object := range objects {
|
|
212
|
+
if !used[key] {
|
|
213
|
+
return nil, fmt.Errorf("unreferenced rollout dependency %s", key)
|
|
214
|
+
}
|
|
215
|
+
if object.GetKind() == "AnalysisTemplate" {
|
|
216
|
+
if err := validateRolloutMetrics(object, serviceNames); err != nil {
|
|
217
|
+
return nil, err
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return root, nil
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func rolloutFields(object map[string]any, allowed ...string) error {
|
|
225
|
+
for key := range object {
|
|
226
|
+
if !slices.Contains(allowed, key) {
|
|
227
|
+
return fmt.Errorf("unsupported rollout field %q", key)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return nil
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func validRolloutDuration(value any) bool {
|
|
234
|
+
var duration time.Duration
|
|
235
|
+
switch value := value.(type) {
|
|
236
|
+
case int64:
|
|
237
|
+
if value > 0 && value <= 86400 {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
case string:
|
|
241
|
+
duration, _ = time.ParseDuration(value)
|
|
242
|
+
}
|
|
243
|
+
return duration > 0 && duration <= 24*time.Hour
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func rolloutAnalysisReference(raw any, objects map[string]*unstructured.Unstructured, used map[string]bool) error {
|
|
247
|
+
analysis, _ := raw.(map[string]any)
|
|
248
|
+
if len(analysis) != 1 {
|
|
249
|
+
return fmt.Errorf("rollout analysis requires only frozen namespaced templates")
|
|
250
|
+
}
|
|
251
|
+
templates, _ := analysis["templates"].([]any)
|
|
252
|
+
if len(templates) == 0 {
|
|
253
|
+
return fmt.Errorf("rollout analysis template is required")
|
|
254
|
+
}
|
|
255
|
+
for _, raw := range templates {
|
|
256
|
+
template, _ := raw.(map[string]any)
|
|
257
|
+
name, _ := template["templateName"].(string)
|
|
258
|
+
if len(template) != 1 || objects["AnalysisTemplate/"+name] == nil {
|
|
259
|
+
return fmt.Errorf("analysis must reference a frozen namespaced AnalysisTemplate")
|
|
260
|
+
}
|
|
261
|
+
used["AnalysisTemplate/"+name] = true
|
|
262
|
+
}
|
|
263
|
+
return nil
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func validateRolloutMetrics(object *unstructured.Unstructured, services []string) error {
|
|
267
|
+
spec, _, _ := unstructured.NestedMap(object.Object, "spec")
|
|
268
|
+
if len(spec) != 1 {
|
|
269
|
+
return fmt.Errorf("AnalysisTemplate supports only frozen web metrics")
|
|
270
|
+
}
|
|
271
|
+
metrics, _ := spec["metrics"].([]any)
|
|
272
|
+
if len(metrics) == 0 || len(metrics) > 10 {
|
|
273
|
+
return fmt.Errorf("analysis requires 1 to 10 metrics")
|
|
274
|
+
}
|
|
275
|
+
names := map[string]bool{}
|
|
276
|
+
for _, raw := range metrics {
|
|
277
|
+
metric, _ := raw.(map[string]any)
|
|
278
|
+
if err := rolloutFields(metric, "name", "interval", "count", "successCondition", "failureCondition", "failureLimit", "inconclusiveLimit", "consecutiveErrorLimit", "provider"); err != nil {
|
|
279
|
+
return err
|
|
280
|
+
}
|
|
281
|
+
name, _ := metric["name"].(string)
|
|
282
|
+
count, _ := metric["count"].(int64)
|
|
283
|
+
success, _ := metric["successCondition"].(string)
|
|
284
|
+
interval, _ := time.ParseDuration(fmt.Sprint(metric["interval"]))
|
|
285
|
+
if name == "" || names[name] || count < 2 || count > 1000 || interval <= 0 || interval > 24*time.Hour/time.Duration(count) || success == "" {
|
|
286
|
+
return fmt.Errorf("each metric requires a unique name, a finite observation window and a success condition")
|
|
287
|
+
}
|
|
288
|
+
names[name] = true
|
|
289
|
+
provider, _ := metric["provider"].(map[string]any)
|
|
290
|
+
web, _ := provider["web"].(map[string]any)
|
|
291
|
+
if len(provider) != 1 || len(web) == 0 || rolloutFields(web, "url", "jsonPath", "timeoutSeconds") != nil {
|
|
292
|
+
return fmt.Errorf("analysis supports read-only web metrics without embedded credentials")
|
|
293
|
+
}
|
|
294
|
+
endpoint, _ := web["url"].(string)
|
|
295
|
+
parsed, err := url.Parse(endpoint)
|
|
296
|
+
if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
297
|
+
return fmt.Errorf("metric URL must reference a frozen Service without credentials")
|
|
298
|
+
}
|
|
299
|
+
valid := false
|
|
300
|
+
for _, service := range services {
|
|
301
|
+
prefix := service + "." + object.GetNamespace() + ".svc"
|
|
302
|
+
valid = valid || parsed.Hostname() == prefix || parsed.Hostname() == prefix+".cluster.local"
|
|
303
|
+
}
|
|
304
|
+
if !valid {
|
|
305
|
+
return fmt.Errorf("metric URL must stay within the frozen rollout Services")
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return nil
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
func validateRolloutTraffic(objects map[string]*unstructured.Unstructured, used map[string]bool, services []string, name string) error {
|
|
312
|
+
if len(services) != 2 {
|
|
313
|
+
return fmt.Errorf("canary requires stable and preview Services")
|
|
314
|
+
}
|
|
315
|
+
stableService, previewService := services[0], services[1]
|
|
316
|
+
weighted := objects["TraefikService/"+name]
|
|
317
|
+
if weighted == nil {
|
|
318
|
+
return fmt.Errorf("weighted TraefikService is not frozen")
|
|
319
|
+
}
|
|
320
|
+
spec, _, _ := unstructured.NestedMap(weighted.Object, "spec")
|
|
321
|
+
config, _ := spec["weighted"].(map[string]any)
|
|
322
|
+
backends, _ := config["services"].([]any)
|
|
323
|
+
if len(spec) != 1 || len(config) != 1 || len(backends) != 2 {
|
|
324
|
+
return fmt.Errorf("canary requires two direct weighted Service backends")
|
|
325
|
+
}
|
|
326
|
+
seen := map[string]bool{}
|
|
327
|
+
for _, raw := range backends {
|
|
328
|
+
backend, _ := raw.(map[string]any)
|
|
329
|
+
if err := rolloutFields(backend, "name", "port", "weight", "kind"); err != nil {
|
|
330
|
+
return err
|
|
331
|
+
}
|
|
332
|
+
name, _ := backend["name"].(string)
|
|
333
|
+
weight, _ := backend["weight"].(int64)
|
|
334
|
+
port, _ := backend["port"].(int64)
|
|
335
|
+
if !slices.Contains(services, name) || seen[name] || port < 1 || port > 65535 || name == stableService && weight != 100 || name == previewService && weight != 0 || backend["kind"] != nil && backend["kind"] != "Service" {
|
|
336
|
+
return fmt.Errorf("weighted backends must start at stable 100 / canary 0")
|
|
337
|
+
}
|
|
338
|
+
seen[name] = true
|
|
339
|
+
service := objects["Service/"+name]
|
|
340
|
+
if service == nil {
|
|
341
|
+
return fmt.Errorf("weighted backend Service is not frozen")
|
|
342
|
+
}
|
|
343
|
+
ports, _, _ := unstructured.NestedSlice(service.Object, "spec", "ports")
|
|
344
|
+
matched := false
|
|
345
|
+
for _, raw := range ports {
|
|
346
|
+
item, _ := raw.(map[string]any)
|
|
347
|
+
matched = matched || item["port"] == port && (item["protocol"] == nil || item["protocol"] == "TCP")
|
|
348
|
+
}
|
|
349
|
+
if !matched {
|
|
350
|
+
return fmt.Errorf("weighted backend port does not match its frozen Service")
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
used["TraefikService/"+name] = true
|
|
354
|
+
routeCount := 0
|
|
355
|
+
for key, object := range objects {
|
|
356
|
+
if object.GetKind() != "IngressRoute" {
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
spec, _, _ := unstructured.NestedMap(object.Object, "spec")
|
|
360
|
+
if err := rolloutFields(spec, "entryPoints", "routes"); err != nil {
|
|
361
|
+
return err
|
|
362
|
+
}
|
|
363
|
+
routes, _ := spec["routes"].([]any)
|
|
364
|
+
if len(routes) != 1 {
|
|
365
|
+
return fmt.Errorf("canary requires one explicit HTTP route")
|
|
366
|
+
}
|
|
367
|
+
route, _ := routes[0].(map[string]any)
|
|
368
|
+
if err := rolloutFields(route, "kind", "match", "services"); err != nil {
|
|
369
|
+
return err
|
|
370
|
+
}
|
|
371
|
+
backends, _ := route["services"].([]any)
|
|
372
|
+
if route["kind"] != "Rule" || len(backends) != 1 {
|
|
373
|
+
return fmt.Errorf("canary route must use its frozen weighted service")
|
|
374
|
+
}
|
|
375
|
+
backend, _ := backends[0].(map[string]any)
|
|
376
|
+
if len(backend) != 2 || backend["name"] != name || backend["kind"] != "TraefikService" {
|
|
377
|
+
return fmt.Errorf("canary route must use its frozen weighted service")
|
|
378
|
+
}
|
|
379
|
+
used[key], routeCount = true, routeCount+1
|
|
380
|
+
}
|
|
381
|
+
if routeCount != 1 {
|
|
382
|
+
return fmt.Errorf("canary requires exactly one frozen IngressRoute")
|
|
383
|
+
}
|
|
384
|
+
return nil
|
|
385
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
14
|
+
contractresource "github.com/opensoha/soha-contracts/resource"
|
|
15
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
16
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
17
|
+
"k8s.io/client-go/dynamic"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type rolloutDriftResource = struct {
|
|
21
|
+
APIVersion string `json:"apiVersion"`
|
|
22
|
+
Fields []sohaapi.ManifestDriftField `json:"fields"`
|
|
23
|
+
Kind string `json:"kind"`
|
|
24
|
+
Name string `json:"name"`
|
|
25
|
+
Namespace string `json:"namespace"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func IsRolloutTask(payload sohaapi.ManifestExecutionTaskPayload) bool {
|
|
29
|
+
if payload.RolloutControl != nil || payload.Action == sohaapi.ManifestTaskActionRolloutControl {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
for _, document := range payload.Documents {
|
|
33
|
+
if document.APIVersion == "argoproj.io/v1alpha1" && document.Kind == "Rollout" {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ExecuteRolloutTask is shared by the control plane and Agent. It verifies the
|
|
41
|
+
// frozen wire payload before any Kubernetes write and uses native reconciliation.
|
|
42
|
+
func ExecuteRolloutTask(ctx context.Context, client dynamic.Interface, payload sohaapi.ManifestExecutionTaskPayload) (sohaapi.ManifestExecutionTaskResult, error) {
|
|
43
|
+
result := sohaapi.ManifestExecutionTaskResult{Action: payload.Action, DeploymentID: payload.DeploymentID, Generation: payload.Generation, RenderedDigest: payload.RenderedDigest, Inventory: []sohaapi.ManifestResourceInventory{}, Diagnostics: []sohaapi.ManifestDiagnostic{}}
|
|
44
|
+
if payload.BindingID == "" || payload.FieldManager != "opensoha-manifest/"+payload.BindingID || payload.Namespace == "" || payload.IdempotencyKey == "" || payload.ForceConflicts || len(payload.GitOpsDocuments) > 0 {
|
|
45
|
+
return result, fmt.Errorf("rollout requires a frozen namespace, stable binding owner and operation; force and mixed GitOps are disabled")
|
|
46
|
+
}
|
|
47
|
+
documents := make([]*unstructured.Unstructured, 0, len(payload.Documents))
|
|
48
|
+
for _, document := range payload.Documents {
|
|
49
|
+
desired := &unstructured.Unstructured{}
|
|
50
|
+
if err := desired.UnmarshalJSON([]byte(document.Content)); err != nil {
|
|
51
|
+
return result, fmt.Errorf("decode frozen rollout: %w", err)
|
|
52
|
+
}
|
|
53
|
+
sum := sha256.Sum256([]byte(document.Content))
|
|
54
|
+
if desired.GetAPIVersion() != document.APIVersion || desired.GetKind() != document.Kind || desired.GetNamespace() != document.Namespace || desired.GetName() != document.Name || document.Namespace != payload.Namespace || !strings.EqualFold(document.ContentDigest, hex.EncodeToString(sum[:])) {
|
|
55
|
+
return result, fmt.Errorf("rollout document identity, namespace or digest does not match its frozen content")
|
|
56
|
+
}
|
|
57
|
+
documents = append(documents, desired)
|
|
58
|
+
}
|
|
59
|
+
if _, err := ValidateRolloutResources(documents); err != nil {
|
|
60
|
+
return result, err
|
|
61
|
+
}
|
|
62
|
+
options := metav1.PatchOptions{FieldManager: payload.FieldManager}
|
|
63
|
+
var observation RolloutObservation
|
|
64
|
+
var err error
|
|
65
|
+
switch payload.Action {
|
|
66
|
+
case sohaapi.ManifestTaskActionPreflight:
|
|
67
|
+
options.DryRun = []string{metav1.DryRunAll}
|
|
68
|
+
_, err = ApplyRollout(ctx, client, documents, options, payload.IdempotencyKey)
|
|
69
|
+
if err != nil {
|
|
70
|
+
result.Diagnostics = append(result.Diagnostics, sohaapi.ManifestDiagnostic{Stage: "dry_run", Severity: "error", Code: "rollout_preflight", Message: err.Error()})
|
|
71
|
+
}
|
|
72
|
+
result.Preflight = &sohaapi.ManifestPreflightResult{Ready: err == nil, Capability: "available", RenderedDigest: payload.RenderedDigest, ResourceCount: len(documents), Diagnostics: result.Diagnostics}
|
|
73
|
+
return result, nil
|
|
74
|
+
case sohaapi.ManifestTaskActionApply, sohaapi.ManifestTaskActionRepair, sohaapi.ManifestTaskActionRollback:
|
|
75
|
+
_, err = ApplyRollout(ctx, client, documents, options, payload.IdempotencyKey)
|
|
76
|
+
case sohaapi.ManifestTaskActionObserve:
|
|
77
|
+
case sohaapi.ManifestTaskActionRolloutControl:
|
|
78
|
+
if payload.RolloutControl == nil {
|
|
79
|
+
return result, fmt.Errorf("rollout control is required")
|
|
80
|
+
}
|
|
81
|
+
observation, err = ControlRollout(ctx, client, documents, payload.FieldManager, payload.IdempotencyKey, *payload.RolloutControl)
|
|
82
|
+
default:
|
|
83
|
+
return result, fmt.Errorf("unsupported rollout action %q", payload.Action)
|
|
84
|
+
}
|
|
85
|
+
if payload.Action != sohaapi.ManifestTaskActionRolloutControl {
|
|
86
|
+
query, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
87
|
+
defer stop()
|
|
88
|
+
var observeErr error
|
|
89
|
+
operation := payload.IdempotencyKey
|
|
90
|
+
if payload.Action == sohaapi.ManifestTaskActionObserve {
|
|
91
|
+
operation = ""
|
|
92
|
+
}
|
|
93
|
+
observation, observeErr = ObserveRollout(query, client, documents, payload.FieldManager, operation)
|
|
94
|
+
err = errors.Join(err, observeErr)
|
|
95
|
+
}
|
|
96
|
+
if err != nil {
|
|
97
|
+
stage := sohaapi.ManifestValidationStageApply
|
|
98
|
+
if payload.Action == sohaapi.ManifestTaskActionObserve {
|
|
99
|
+
stage = sohaapi.ManifestValidationStageObserve
|
|
100
|
+
}
|
|
101
|
+
result.Diagnostics = append(result.Diagnostics, sohaapi.ManifestDiagnostic{Stage: stage, Severity: "error", Code: "rollout_runtime", Message: err.Error()})
|
|
102
|
+
}
|
|
103
|
+
if observation.State.UID == "" {
|
|
104
|
+
return result, err
|
|
105
|
+
}
|
|
106
|
+
result.Rollout = &observation.State
|
|
107
|
+
result.Drift = &sohaapi.ManifestDriftReport{Drifted: observation.Drifted, ObservedAt: time.Now().UTC(), Resources: []rolloutDriftResource{}, EvidenceRefs: []string{}}
|
|
108
|
+
for index, desired := range documents {
|
|
109
|
+
for _, live := range observation.Resources {
|
|
110
|
+
if desired.GroupVersionKind() != live.GroupVersionKind() || desired.GetName() != live.GetName() {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
document := payload.Documents[index]
|
|
114
|
+
projected := projectRolloutObject(desired.Object, live.Object)
|
|
115
|
+
encoded, _ := json.Marshal(projected)
|
|
116
|
+
sum := sha256.Sum256(encoded)
|
|
117
|
+
item := sohaapi.ManifestResourceInventory{DeploymentID: payload.DeploymentID, Generation: payload.Generation, APIVersion: document.APIVersion, Kind: document.Kind, Namespace: live.GetNamespace(), Name: live.GetName(), UID: string(live.GetUID()), ResourceVersion: live.GetResourceVersion(), ResourceGeneration: live.GetGeneration(), DesiredObjectDigest: document.ContentDigest, ObservedObjectDigest: hex.EncodeToString(sum[:]), Health: contractresource.ManifestHealth(live.Object), LastObservedAt: time.Now().UTC(), Finalizers: live.GetFinalizers()}
|
|
118
|
+
if RolloutMatchesDesired(desired, live) {
|
|
119
|
+
// The immutable policy is matched; live controller weights are
|
|
120
|
+
// separately exposed in rollout rather than treated as spec drift.
|
|
121
|
+
item.ObservedObjectDigest = document.ContentDigest
|
|
122
|
+
} else {
|
|
123
|
+
result.Drift.Resources = append(result.Drift.Resources, rolloutDriftResource{APIVersion: document.APIVersion, Kind: document.Kind, Namespace: document.Namespace, Name: document.Name, Fields: []sohaapi.ManifestDriftField{{Path: "/", DesiredValue: desired.Object, ObservedValue: projected}}})
|
|
124
|
+
}
|
|
125
|
+
if IsArgoRollout(live) {
|
|
126
|
+
item.Health, item.ObservedResourceGeneration = observation.Health, observation.State.ObservedGeneration
|
|
127
|
+
}
|
|
128
|
+
if live.GetKind() == "AnalysisTemplate" || live.GetKind() == "TraefikService" || live.GetKind() == "IngressRoute" {
|
|
129
|
+
// These configuration resources have no Ready condition. Their
|
|
130
|
+
// frozen policy and owning rollout's traffic gate determine readiness.
|
|
131
|
+
item.Health = observation.Health
|
|
132
|
+
}
|
|
133
|
+
if deleting := live.GetDeletionTimestamp(); deleting != nil {
|
|
134
|
+
item.DeletingAt = &deleting.Time
|
|
135
|
+
}
|
|
136
|
+
result.Inventory = append(result.Inventory, item)
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
result.EvidenceRefs = []string{"rollout:" + observation.State.Namespace + "/" + observation.State.Name + ":" + observation.State.UID + ":" + observation.State.OperationID}
|
|
141
|
+
return result, err
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func projectRolloutObject(desired, live any) any {
|
|
145
|
+
switch wanted := desired.(type) {
|
|
146
|
+
case map[string]any:
|
|
147
|
+
actual, _ := live.(map[string]any)
|
|
148
|
+
result := make(map[string]any, len(wanted))
|
|
149
|
+
for key, value := range wanted {
|
|
150
|
+
result[key] = projectRolloutObject(value, actual[key])
|
|
151
|
+
}
|
|
152
|
+
return result
|
|
153
|
+
case []any:
|
|
154
|
+
actual, _ := live.([]any)
|
|
155
|
+
result := make([]any, len(wanted))
|
|
156
|
+
for index, value := range wanted {
|
|
157
|
+
if index < len(actual) {
|
|
158
|
+
result[index] = projectRolloutObject(value, actual[index])
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result
|
|
162
|
+
default:
|
|
163
|
+
return live
|
|
164
|
+
}
|
|
165
|
+
}
|