@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,143 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"reflect"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
contractresource "github.com/opensoha/soha-contracts/resource"
|
|
11
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
12
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
13
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
14
|
+
"k8s.io/client-go/dynamic"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// ArgoObservation retains live child inventory while the operation progresses.
|
|
18
|
+
// Cached controller health cannot override missing, unowned or drifted children.
|
|
19
|
+
type ArgoObservation struct {
|
|
20
|
+
OperationID string
|
|
21
|
+
Phase string
|
|
22
|
+
Revision string
|
|
23
|
+
Health string
|
|
24
|
+
Drifted bool
|
|
25
|
+
Resources []*unstructured.Unstructured
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func ObserveArgoApplication(ctx context.Context, client dynamic.Interface, desired, object *unstructured.Unstructured, children []*unstructured.Unstructured, owner string) (ArgoObservation, error) {
|
|
29
|
+
observation := ArgoObservation{Health: "unknown"}
|
|
30
|
+
if err := ValidateArgoResources(desired, children); err != nil {
|
|
31
|
+
return observation, err
|
|
32
|
+
}
|
|
33
|
+
if err := validateArgoOwner(object, owner); err != nil {
|
|
34
|
+
return observation, err
|
|
35
|
+
}
|
|
36
|
+
observation.Health = "progressing"
|
|
37
|
+
observation.OperationID = object.GetAnnotations()[ArgoOperationAnnotation]
|
|
38
|
+
observation.Phase = argoString(object, "status", "operationState", "phase")
|
|
39
|
+
observation.Revision = argoString(object, "status", "sync", "revision")
|
|
40
|
+
observation.Drifted = !argoMatchesDesired(desired.Object, object.Object)
|
|
41
|
+
expected := map[string]bool{}
|
|
42
|
+
for _, child := range children {
|
|
43
|
+
expected[argoResourceKey(child)] = true
|
|
44
|
+
}
|
|
45
|
+
reported, _, err := unstructured.NestedSlice(object.Object, "status", "resources")
|
|
46
|
+
if err != nil || len(reported) > 49 {
|
|
47
|
+
return observation, fmt.Errorf("invalid GitOps resource observations")
|
|
48
|
+
}
|
|
49
|
+
seen := map[string]bool{}
|
|
50
|
+
for _, raw := range reported {
|
|
51
|
+
item, ok := raw.(map[string]any)
|
|
52
|
+
if !ok {
|
|
53
|
+
return observation, fmt.Errorf("invalid GitOps resource observation")
|
|
54
|
+
}
|
|
55
|
+
group, _ := item["group"].(string)
|
|
56
|
+
kind, _ := item["kind"].(string)
|
|
57
|
+
name, _ := item["name"].(string)
|
|
58
|
+
key := group + "/" + kind + "/" + name
|
|
59
|
+
if !expected[key] || seen[key] || item["namespace"] != desired.GetNamespace() {
|
|
60
|
+
return observation, fmt.Errorf("GitOps observed a resource outside its frozen inventory")
|
|
61
|
+
}
|
|
62
|
+
seen[key] = true
|
|
63
|
+
}
|
|
64
|
+
queryCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
65
|
+
defer cancel()
|
|
66
|
+
healthy, degraded := len(seen) == len(expected), false
|
|
67
|
+
for _, child := range children {
|
|
68
|
+
gvk := child.GroupVersionKind()
|
|
69
|
+
live, err := client.Resource(argoResourceTypes[gvk.Group+"/"+gvk.Kind]).Namespace(child.GetNamespace()).Get(queryCtx, child.GetName(), metav1.GetOptions{})
|
|
70
|
+
if apierrors.IsNotFound(err) {
|
|
71
|
+
healthy = false
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if err != nil {
|
|
75
|
+
return observation, err
|
|
76
|
+
}
|
|
77
|
+
if live.GetUID() == "" || !argoTracksResource(desired, live) {
|
|
78
|
+
return observation, fmt.Errorf("GitOps child ownership does not match its Application")
|
|
79
|
+
}
|
|
80
|
+
observation.Resources = append(observation.Resources, live)
|
|
81
|
+
health := contractresource.ManifestHealth(live.Object)
|
|
82
|
+
healthy = healthy && health == "healthy"
|
|
83
|
+
degraded = degraded || health == "degraded"
|
|
84
|
+
observation.Drifted = observation.Drifted || !argoMatchesDesired(child.Object, live.Object)
|
|
85
|
+
for _, field := range []string{"containers", "initContainers"} {
|
|
86
|
+
wanted, _, _ := unstructured.NestedSlice(child.Object, "spec", "template", "spec", field)
|
|
87
|
+
actual, _, _ := unstructured.NestedSlice(live.Object, "spec", "template", "spec", field)
|
|
88
|
+
observation.Drifted = observation.Drifted || len(wanted) != len(actual)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
current := observation.OperationID != "" && argoOperationID(object, "status", "operationState", "operation") == observation.OperationID
|
|
92
|
+
if observation.Drifted || degraded || current && (observation.Phase == "Failed" || observation.Phase == "Error") {
|
|
93
|
+
observation.Health = "degraded"
|
|
94
|
+
return observation, nil
|
|
95
|
+
}
|
|
96
|
+
source, _, _ := unstructured.NestedMap(desired.Object, "spec", "source")
|
|
97
|
+
compared, _, _ := unstructured.NestedMap(object.Object, "status", "sync", "comparedTo", "source")
|
|
98
|
+
if healthy && current && observation.Phase == "Succeeded" && argoString(object, "status", "operationState", "finishedAt") != "" && object.Object["operation"] == nil && reflect.DeepEqual(source, compared) && observation.Revision == argoString(desired, "spec", "source", "targetRevision") && argoString(object, "status", "operationState", "syncResult", "revision") == observation.Revision && argoString(object, "status", "sync", "status") == "Synced" && argoString(object, "status", "health", "status") == "Healthy" {
|
|
99
|
+
observation.Health = "healthy"
|
|
100
|
+
}
|
|
101
|
+
return observation, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func argoResourceKey(object *unstructured.Unstructured) string {
|
|
105
|
+
gvk := object.GroupVersionKind()
|
|
106
|
+
return gvk.Group + "/" + gvk.Kind + "/" + object.GetName()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// API defaults may add object fields, but lists must retain their frozen shape.
|
|
110
|
+
func argoMatchesDesired(desired, observed any) bool {
|
|
111
|
+
switch value := desired.(type) {
|
|
112
|
+
case map[string]any:
|
|
113
|
+
live, ok := observed.(map[string]any)
|
|
114
|
+
if !ok {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
for key, child := range value {
|
|
118
|
+
if !argoMatchesDesired(child, live[key]) {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
case []any:
|
|
124
|
+
// Kubernetes omits empty optional lists such as container env/args.
|
|
125
|
+
if len(value) == 0 && observed == nil {
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
live, ok := observed.([]any)
|
|
129
|
+
if !ok || len(value) != len(live) {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
for i, child := range value {
|
|
133
|
+
if !argoMatchesDesired(child, live[i]) {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
default:
|
|
139
|
+
wanted, _ := json.Marshal(desired)
|
|
140
|
+
actual, _ := json.Marshal(observed)
|
|
141
|
+
return string(wanted) == string(actual)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"reflect"
|
|
8
|
+
"slices"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
13
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
14
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
15
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
16
|
+
"k8s.io/client-go/dynamic"
|
|
17
|
+
"k8s.io/client-go/util/retry"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
var ErrArgoStopUnconfirmed = errors.New("argo CD operation stop is not confirmed")
|
|
21
|
+
|
|
22
|
+
// ApplyArgoApplication writes only the Application root. A persisted operation
|
|
23
|
+
// key makes an ambiguous response or worker restart resume the same sync.
|
|
24
|
+
func ApplyArgoApplication(ctx context.Context, client dynamic.Interface, desired *unstructured.Unstructured, children []*unstructured.Unstructured, options metav1.PatchOptions, operationID string) (*unstructured.Unstructured, error) {
|
|
25
|
+
if err := ValidateArgoResources(desired, children); err != nil {
|
|
26
|
+
return nil, err
|
|
27
|
+
}
|
|
28
|
+
if operationID == "" || options.FieldManager == "" || options.Force != nil && *options.Force {
|
|
29
|
+
return nil, fmt.Errorf("GitOps requires a stable operation key and a non-forced owner")
|
|
30
|
+
}
|
|
31
|
+
if desired.Object["operation"] != nil {
|
|
32
|
+
return nil, fmt.Errorf("GitOps operation must be generated by the executor")
|
|
33
|
+
}
|
|
34
|
+
if err := validateArgoProject(ctx, client, desired); err != nil {
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
resources := client.Resource(argoApplications).Namespace(desired.GetNamespace())
|
|
38
|
+
var applied, attempted *unstructured.Unstructured
|
|
39
|
+
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
40
|
+
if err := CheckArgoResourceOwners(ctx, client, desired, children); err != nil {
|
|
41
|
+
return err
|
|
42
|
+
}
|
|
43
|
+
live, err := resources.Get(ctx, desired.GetName(), metav1.GetOptions{})
|
|
44
|
+
if apierrors.IsNotFound(err) {
|
|
45
|
+
write := desired.DeepCopy()
|
|
46
|
+
setArgoOperation(write, options.FieldManager, operationID)
|
|
47
|
+
attempted = write.DeepCopy()
|
|
48
|
+
applied, err = resources.Create(ctx, write, metav1.CreateOptions{FieldManager: options.FieldManager, DryRun: options.DryRun})
|
|
49
|
+
return err
|
|
50
|
+
}
|
|
51
|
+
if err != nil {
|
|
52
|
+
return err
|
|
53
|
+
}
|
|
54
|
+
if err := validateArgoOwner(live, options.FieldManager); err != nil {
|
|
55
|
+
return err
|
|
56
|
+
}
|
|
57
|
+
if live.GetAnnotations()[ArgoOperationAnnotation] == operationID {
|
|
58
|
+
if !reflect.DeepEqual(live.Object["spec"], desired.Object["spec"]) {
|
|
59
|
+
return fmt.Errorf("GitOps operation key refers to different desired inputs")
|
|
60
|
+
}
|
|
61
|
+
applied = live
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
if argoOperationActive(live) {
|
|
65
|
+
return fmt.Errorf("GitOps Application has an unfinished operation")
|
|
66
|
+
}
|
|
67
|
+
write := live.DeepCopy()
|
|
68
|
+
write.Object["spec"] = desired.DeepCopy().Object["spec"]
|
|
69
|
+
write.SetLabels(desired.GetLabels())
|
|
70
|
+
write.SetAnnotations(desired.GetAnnotations())
|
|
71
|
+
setArgoOperation(write, options.FieldManager, operationID)
|
|
72
|
+
attempted = write.DeepCopy()
|
|
73
|
+
applied, err = resources.Update(ctx, write, metav1.UpdateOptions{FieldManager: options.FieldManager, DryRun: options.DryRun})
|
|
74
|
+
return err
|
|
75
|
+
})
|
|
76
|
+
if err != nil && len(options.DryRun) == 0 && attempted != nil && !apierrors.IsInvalid(err) && !apierrors.IsForbidden(err) && !apierrors.IsUnauthorized(err) && !apierrors.IsBadRequest(err) && !apierrors.IsConflict(err) && !apierrors.IsAlreadyExists(err) {
|
|
77
|
+
recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
|
78
|
+
live, readErr := resources.Get(recoveryCtx, desired.GetName(), metav1.GetOptions{})
|
|
79
|
+
cancel()
|
|
80
|
+
if readErr != nil || validateArgoOwner(live, options.FieldManager) != nil || live.GetAnnotations()[ArgoOperationAnnotation] != operationID || attempted.GetUID() != "" && attempted.GetUID() != live.GetUID() {
|
|
81
|
+
return live, fmt.Errorf("%w: sync write outcome could not be confirmed", ErrArgoStopUnconfirmed)
|
|
82
|
+
}
|
|
83
|
+
return stopArgoAfterError(ctx, resources, live, operationID, err)
|
|
84
|
+
}
|
|
85
|
+
if err != nil || len(options.DryRun) != 0 {
|
|
86
|
+
return applied, err
|
|
87
|
+
}
|
|
88
|
+
return waitArgoOperation(ctx, resources, applied, operationID)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func setArgoOperation(object *unstructured.Unstructured, owner, operationID string) {
|
|
92
|
+
annotations := object.GetAnnotations()
|
|
93
|
+
if annotations == nil {
|
|
94
|
+
annotations = map[string]string{}
|
|
95
|
+
}
|
|
96
|
+
annotations[ArgoOwnerAnnotation], annotations[ArgoOperationAnnotation] = owner, operationID
|
|
97
|
+
object.SetAnnotations(annotations)
|
|
98
|
+
object.Object["operation"] = map[string]any{
|
|
99
|
+
"initiatedBy": map[string]any{"username": "soha", "automated": false},
|
|
100
|
+
"info": []any{map[string]any{"name": ArgoOperationAnnotation, "value": operationID}},
|
|
101
|
+
"sync": map[string]any{"revision": argoString(object, "spec", "source", "targetRevision"), "prune": false, "syncOptions": []any{"FailOnSharedResource=true"}},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func validateArgoOwner(object *unstructured.Unstructured, owner string) error {
|
|
106
|
+
if err := ValidateArgoApplication(object); err != nil {
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
if owner == "" || object.GetAnnotations()[ArgoOwnerAnnotation] != owner || object.GetUID() == "" {
|
|
110
|
+
return fmt.Errorf("GitOps Application belongs to another writer; implicit adoption is disabled")
|
|
111
|
+
}
|
|
112
|
+
if object.GetLabels()["argocd.argoproj.io/instance"] != "" || object.GetLabels()["app.kubernetes.io/managed-by"] == "Helm" {
|
|
113
|
+
return fmt.Errorf("GitOps Application is externally managed")
|
|
114
|
+
}
|
|
115
|
+
return nil
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func argoOperationActive(object *unstructured.Unstructured) bool {
|
|
119
|
+
phase := argoString(object, "status", "operationState", "phase")
|
|
120
|
+
return object.Object["operation"] != nil || phase == "Running" || phase == "Terminating"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func waitArgoOperation(ctx context.Context, client dynamic.ResourceInterface, initial *unstructured.Unstructured, operationID string) (*unstructured.Unstructured, error) {
|
|
124
|
+
ticker := time.NewTicker(time.Second)
|
|
125
|
+
defer ticker.Stop()
|
|
126
|
+
for {
|
|
127
|
+
live, err := client.Get(ctx, initial.GetName(), metav1.GetOptions{})
|
|
128
|
+
if ctx.Err() != nil {
|
|
129
|
+
return stopArgoAfterError(ctx, client, initial, operationID, ctx.Err())
|
|
130
|
+
}
|
|
131
|
+
if err != nil {
|
|
132
|
+
return stopArgoAfterError(ctx, client, initial, operationID, err)
|
|
133
|
+
}
|
|
134
|
+
if live.GetUID() != initial.GetUID() || live.GetAnnotations()[ArgoOperationAnnotation] != operationID {
|
|
135
|
+
return live, fmt.Errorf("GitOps operation ownership changed while observing")
|
|
136
|
+
}
|
|
137
|
+
if !argoOperationActive(live) && argoOperationID(live, "status", "operationState", "operation") == operationID {
|
|
138
|
+
if argoString(live, "status", "operationState", "phase") == "Succeeded" {
|
|
139
|
+
return live, nil
|
|
140
|
+
}
|
|
141
|
+
return live, fmt.Errorf("argo CD sync did not succeed")
|
|
142
|
+
}
|
|
143
|
+
select {
|
|
144
|
+
case <-ctx.Done():
|
|
145
|
+
case <-ticker.C:
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func stopArgoAfterError(ctx context.Context, client dynamic.ResourceInterface, initial *unstructured.Unstructured, operationID string, cause error) (*unstructured.Unstructured, error) {
|
|
151
|
+
stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
152
|
+
defer cancel()
|
|
153
|
+
stopped, err := terminateArgoOperation(stopCtx, client, initial, operationID)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return stopped, fmt.Errorf("%w: %v", ErrArgoStopUnconfirmed, err)
|
|
156
|
+
}
|
|
157
|
+
if ctx.Err() != nil {
|
|
158
|
+
cause = ctx.Err()
|
|
159
|
+
}
|
|
160
|
+
return stopped, cause
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The native Argo CD TerminateOperation implementation updates the root status
|
|
164
|
+
// phase to Terminating. UID/RV and operation identity fence every request here;
|
|
165
|
+
// only a controller-written terminal observation confirms the stop.
|
|
166
|
+
func terminateArgoOperation(ctx context.Context, client dynamic.ResourceInterface, initial *unstructured.Unstructured, operationID string) (*unstructured.Unstructured, error) {
|
|
167
|
+
ticker := time.NewTicker(time.Second)
|
|
168
|
+
defer ticker.Stop()
|
|
169
|
+
for {
|
|
170
|
+
live, err := client.Get(ctx, initial.GetName(), metav1.GetOptions{})
|
|
171
|
+
if err != nil {
|
|
172
|
+
return live, err
|
|
173
|
+
}
|
|
174
|
+
if live.GetUID() != initial.GetUID() || live.GetAnnotations()[ArgoOperationAnnotation] != operationID {
|
|
175
|
+
return live, fmt.Errorf("GitOps operation identity changed before stop confirmation")
|
|
176
|
+
}
|
|
177
|
+
stateID := argoOperationID(live, "status", "operationState", "operation")
|
|
178
|
+
phase := argoString(live, "status", "operationState", "phase")
|
|
179
|
+
if !argoOperationActive(live) && stateID == operationID {
|
|
180
|
+
if phase == "Succeeded" || phase == "Failed" {
|
|
181
|
+
return live, nil
|
|
182
|
+
}
|
|
183
|
+
return live, fmt.Errorf("argo CD termination reported an error")
|
|
184
|
+
}
|
|
185
|
+
if live.Object["operation"] != nil && argoOperationID(live, "operation") != operationID {
|
|
186
|
+
return live, fmt.Errorf("another GitOps operation is active")
|
|
187
|
+
}
|
|
188
|
+
if stateID == operationID && phase == "Running" {
|
|
189
|
+
_ = unstructured.SetNestedField(live.Object, "Terminating", "status", "operationState", "phase")
|
|
190
|
+
_, err = client.Update(ctx, live, metav1.UpdateOptions{})
|
|
191
|
+
if err != nil && !apierrors.IsConflict(err) {
|
|
192
|
+
return live, err
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
select {
|
|
196
|
+
case <-ctx.Done():
|
|
197
|
+
return live, ctx.Err()
|
|
198
|
+
case <-ticker.C:
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func validateArgoProject(ctx context.Context, client dynamic.Interface, application *unstructured.Unstructured) error {
|
|
204
|
+
namespace := application.GetAnnotations()["delivery.soha.io/argocd-namespace"]
|
|
205
|
+
if namespace == "" {
|
|
206
|
+
namespace = application.GetNamespace()
|
|
207
|
+
}
|
|
208
|
+
project, err := client.Resource(schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "appprojects"}).Namespace(namespace).Get(ctx, argoString(application, "spec", "project"), metav1.GetOptions{})
|
|
209
|
+
if err != nil {
|
|
210
|
+
return fmt.Errorf("read restricted Argo CD project: %w", err)
|
|
211
|
+
}
|
|
212
|
+
destinations, _, err := unstructured.NestedSlice(project.Object, "spec", "destinations")
|
|
213
|
+
if err != nil || len(destinations) != 1 {
|
|
214
|
+
return fmt.Errorf("GitOps project must authorize only the selected namespace and cluster")
|
|
215
|
+
}
|
|
216
|
+
destination, _ := destinations[0].(map[string]any)
|
|
217
|
+
if destination["namespace"] != application.GetNamespace() || destination["server"] != "https://kubernetes.default.svc" {
|
|
218
|
+
return fmt.Errorf("GitOps project destination is outside the delivery target")
|
|
219
|
+
}
|
|
220
|
+
clusterKinds, _, err := unstructured.NestedSlice(project.Object, "spec", "clusterResourceWhitelist")
|
|
221
|
+
if err != nil || len(clusterKinds) != 0 {
|
|
222
|
+
return fmt.Errorf("GitOps project must deny cluster-scoped resources")
|
|
223
|
+
}
|
|
224
|
+
allowed, _, err := unstructured.NestedSlice(project.Object, "spec", "namespaceResourceWhitelist")
|
|
225
|
+
if err != nil || len(allowed) == 0 {
|
|
226
|
+
return fmt.Errorf("GitOps project requires an explicit supported resource allowlist")
|
|
227
|
+
}
|
|
228
|
+
for _, raw := range allowed {
|
|
229
|
+
item, _ := raw.(map[string]any)
|
|
230
|
+
group, _ := item["group"].(string)
|
|
231
|
+
kind, _ := item["kind"].(string)
|
|
232
|
+
if _, ok := argoResourceTypes[group+"/"+kind]; !ok {
|
|
233
|
+
return fmt.Errorf("GitOps project permits unsupported resource kinds")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
repositories, _, err := unstructured.NestedStringSlice(project.Object, "spec", "sourceRepos")
|
|
237
|
+
if err != nil || !slices.Contains(repositories, argoString(application, "spec", "source", "repoURL")) {
|
|
238
|
+
return fmt.Errorf("GitOps repository is not explicitly authorized by its project")
|
|
239
|
+
}
|
|
240
|
+
if namespace != application.GetNamespace() {
|
|
241
|
+
sources, _, err := unstructured.NestedStringSlice(project.Object, "spec", "sourceNamespaces")
|
|
242
|
+
if err != nil || !slices.Contains(sources, application.GetNamespace()) {
|
|
243
|
+
return fmt.Errorf("GitOps project does not authorize this Application namespace")
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Wildcard source access is not a sufficient substitute for an explicit grant.
|
|
247
|
+
for _, repository := range repositories {
|
|
248
|
+
if strings.ContainsAny(repository, "*?") {
|
|
249
|
+
return fmt.Errorf("GitOps project repository grants must be explicit")
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return nil
|
|
253
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
9
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
10
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
11
|
+
"k8s.io/client-go/dynamic"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// ValidateArgoResources bounds the controller's writes to the resources rendered
|
|
15
|
+
// from the same immutable source used by the delivery plan.
|
|
16
|
+
func ValidateArgoResources(application *unstructured.Unstructured, resources []*unstructured.Unstructured) error {
|
|
17
|
+
if err := ValidateArgoApplication(application); err != nil {
|
|
18
|
+
return err
|
|
19
|
+
}
|
|
20
|
+
if len(resources) == 0 || len(resources) > 49 {
|
|
21
|
+
return fmt.Errorf("GitOps requires 1 to 49 frozen child resources")
|
|
22
|
+
}
|
|
23
|
+
images, _, _ := unstructured.NestedStringSlice(application.Object, "spec", "source", "kustomize", "images")
|
|
24
|
+
expectedImages := map[string]bool{}
|
|
25
|
+
for _, image := range images {
|
|
26
|
+
_, image, _ = ParseArgoImageOverride(image)
|
|
27
|
+
expectedImages[image] = false
|
|
28
|
+
}
|
|
29
|
+
seen := map[string]bool{}
|
|
30
|
+
for _, resource := range resources {
|
|
31
|
+
if resource == nil {
|
|
32
|
+
return fmt.Errorf("GitOps child resource is missing")
|
|
33
|
+
}
|
|
34
|
+
gvk := resource.GroupVersionKind()
|
|
35
|
+
gvr, supported := argoResourceTypes[gvk.Group+"/"+gvk.Kind]
|
|
36
|
+
key := gvk.Group + "/" + gvk.Kind + "/" + resource.GetName()
|
|
37
|
+
if !supported || gvk.Version != gvr.Version || resource.GetNamespace() != application.GetNamespace() || resource.GetName() == "" || seen[key] {
|
|
38
|
+
return fmt.Errorf("GitOps child resource is unsupported, repeated or outside the target namespace")
|
|
39
|
+
}
|
|
40
|
+
seen[key] = true
|
|
41
|
+
if resource.GetUID() != "" || resource.GetResourceVersion() != "" || resource.GetGenerateName() != "" || resource.GetDeletionTimestamp() != nil || len(resource.GetOwnerReferences()) != 0 || len(resource.GetFinalizers()) != 0 || resource.Object["status"] != nil {
|
|
42
|
+
return fmt.Errorf("GitOps child resources must be independent desired objects")
|
|
43
|
+
}
|
|
44
|
+
if err := ValidateDirectManifestOwner(resource); err != nil {
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
for key := range resource.GetAnnotations() {
|
|
48
|
+
if strings.HasPrefix(key, "argocd.argoproj.io/") && key != "argocd.argoproj.io/sync-wave" {
|
|
49
|
+
return fmt.Errorf("GitOps child hooks and controller overrides are unsupported")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if resource.GetKind() == "Secret" && (resource.Object["data"] != nil || resource.Object["stringData"] != nil) {
|
|
53
|
+
return fmt.Errorf("GitOps secret values must use administrator-managed references")
|
|
54
|
+
}
|
|
55
|
+
for _, field := range []string{"containers", "initContainers"} {
|
|
56
|
+
containers, _, err := unstructured.NestedSlice(resource.Object, "spec", "template", "spec", field)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return err
|
|
59
|
+
}
|
|
60
|
+
for _, raw := range containers {
|
|
61
|
+
container, _ := raw.(map[string]any)
|
|
62
|
+
image, _ := container["image"].(string)
|
|
63
|
+
if _, ok := expectedImages[image]; !ok {
|
|
64
|
+
return fmt.Errorf("GitOps workload image is outside the frozen digest set")
|
|
65
|
+
}
|
|
66
|
+
expectedImages[image] = true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for _, used := range expectedImages {
|
|
71
|
+
if !used {
|
|
72
|
+
return fmt.Errorf("GitOps image override is not used by a workload")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// CheckArgoResourceOwners refuses implicit adoption, including unlabelled live
|
|
79
|
+
// objects. It is read-only and runs again immediately before controller sync.
|
|
80
|
+
func CheckArgoResourceOwners(ctx context.Context, client dynamic.Interface, application *unstructured.Unstructured, resources []*unstructured.Unstructured) error {
|
|
81
|
+
if err := ValidateArgoResources(application, resources); err != nil {
|
|
82
|
+
return err
|
|
83
|
+
}
|
|
84
|
+
for _, resource := range resources {
|
|
85
|
+
gvk := resource.GroupVersionKind()
|
|
86
|
+
gvr := argoResourceTypes[gvk.Group+"/"+gvk.Kind]
|
|
87
|
+
live, err := client.Resource(gvr).Namespace(resource.GetNamespace()).Get(ctx, resource.GetName(), metav1.GetOptions{})
|
|
88
|
+
if apierrors.IsNotFound(err) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if err != nil {
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
if live.GetUID() == "" || live.GetDeletionTimestamp() != nil || !argoTracksResource(application, live) || metav1.GetControllerOf(live) != nil {
|
|
95
|
+
return fmt.Errorf("GitOps child %s/%s belongs to another writer or is being deleted", resource.GetKind(), resource.GetName())
|
|
96
|
+
}
|
|
97
|
+
labels, annotations := live.GetLabels(), live.GetAnnotations()
|
|
98
|
+
if labels["app.kubernetes.io/managed-by"] == "Helm" || annotations["meta.helm.sh/release-name"] != "" || labels["kustomize.toolkit.fluxcd.io/name"] != "" || labels["helm.toolkit.fluxcd.io/name"] != "" {
|
|
99
|
+
return fmt.Errorf("GitOps child has another delivery owner")
|
|
100
|
+
}
|
|
101
|
+
for _, field := range live.GetManagedFields() {
|
|
102
|
+
if strings.HasPrefix(field.Manager, "opensoha-") || field.Manager == "helm" || field.Manager == "kustomize-controller" || field.Manager == "helm-controller" {
|
|
103
|
+
return fmt.Errorf("GitOps child has fields owned by another delivery writer")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func argoTracksResource(application, resource *unstructured.Unstructured) bool {
|
|
111
|
+
gvk := resource.GroupVersionKind()
|
|
112
|
+
suffix := ":" + gvk.Group + "/" + gvk.Kind + ":" + resource.GetNamespace() + "/" + resource.GetName()
|
|
113
|
+
tracking := resource.GetAnnotations()["argocd.argoproj.io/tracking-id"]
|
|
114
|
+
return tracking == application.GetName()+suffix || tracking == application.GetNamespace()+"_"+application.GetName()+suffix
|
|
115
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
7
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
8
|
+
"k8s.io/apimachinery/pkg/runtime"
|
|
9
|
+
"k8s.io/client-go/dynamic/fake"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestArgoFrozenResourcesRejectUnsafeWrites(t *testing.T) {
|
|
13
|
+
for _, mode := range []string{"valid", "outside", "duplicate", "hook", "image", "init image", "external", "status", "missing image"} {
|
|
14
|
+
t.Run(mode, func(t *testing.T) {
|
|
15
|
+
app, live := argoFixture(t)
|
|
16
|
+
desired := live.DeepCopy()
|
|
17
|
+
desired.SetUID("")
|
|
18
|
+
desired.SetAnnotations(nil)
|
|
19
|
+
desired.SetGeneration(0)
|
|
20
|
+
delete(desired.Object, "status")
|
|
21
|
+
resources := []*unstructured.Unstructured{desired}
|
|
22
|
+
switch mode {
|
|
23
|
+
case "outside":
|
|
24
|
+
desired.SetNamespace("other")
|
|
25
|
+
case "duplicate":
|
|
26
|
+
resources = append(resources, desired.DeepCopy())
|
|
27
|
+
case "hook":
|
|
28
|
+
desired.SetAnnotations(map[string]string{"argocd.argoproj.io/hook": "PreSync"})
|
|
29
|
+
case "image":
|
|
30
|
+
_ = unstructured.SetNestedSlice(desired.Object, []any{map[string]any{"name": "app", "image": "mutable:latest"}}, "spec", "template", "spec", "containers")
|
|
31
|
+
case "init image":
|
|
32
|
+
_ = unstructured.SetNestedSlice(desired.Object, []any{map[string]any{"name": "init", "image": "mutable:latest"}}, "spec", "template", "spec", "initContainers")
|
|
33
|
+
case "external":
|
|
34
|
+
desired.SetLabels(map[string]string{"app.kubernetes.io/managed-by": "Helm"})
|
|
35
|
+
case "status":
|
|
36
|
+
desired.Object["status"] = map[string]any{"observedGeneration": int64(1)}
|
|
37
|
+
case "missing image":
|
|
38
|
+
unstructured.RemoveNestedField(desired.Object, "spec", "template", "spec", "containers")
|
|
39
|
+
}
|
|
40
|
+
if err := ValidateArgoResources(app, resources); (err == nil) != (mode == "valid") {
|
|
41
|
+
t.Fatalf("%s: %v", mode, err)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestArgoResourceOwnerCheckIsReadOnlyAndRejectsAdoption(t *testing.T) {
|
|
48
|
+
for _, mode := range []string{"owned", "absent", "unowned", "other app", "direct", "helm"} {
|
|
49
|
+
t.Run(mode, func(t *testing.T) {
|
|
50
|
+
app, live := argoFixture(t)
|
|
51
|
+
desired := live.DeepCopy()
|
|
52
|
+
desired.SetUID("")
|
|
53
|
+
desired.SetAnnotations(nil)
|
|
54
|
+
desired.SetGeneration(0)
|
|
55
|
+
delete(desired.Object, "status")
|
|
56
|
+
switch mode {
|
|
57
|
+
case "unowned":
|
|
58
|
+
live.SetAnnotations(nil)
|
|
59
|
+
case "other app":
|
|
60
|
+
live.SetAnnotations(map[string]string{"argocd.argoproj.io/tracking-id": "other:apps/Deployment:demo/web"})
|
|
61
|
+
case "direct":
|
|
62
|
+
live.SetManagedFields([]metav1.ManagedFieldsEntry{{Manager: "opensoha-manifest/other"}})
|
|
63
|
+
case "helm":
|
|
64
|
+
live.SetLabels(map[string]string{"app.kubernetes.io/managed-by": "Helm"})
|
|
65
|
+
}
|
|
66
|
+
objects := []runtime.Object{live}
|
|
67
|
+
if mode == "absent" {
|
|
68
|
+
objects = nil
|
|
69
|
+
}
|
|
70
|
+
client := fake.NewSimpleDynamicClient(runtime.NewScheme(), objects...)
|
|
71
|
+
err := CheckArgoResourceOwners(t.Context(), client, app, []*unstructured.Unstructured{desired})
|
|
72
|
+
if (err == nil) != (mode == "owned" || mode == "absent") {
|
|
73
|
+
t.Fatalf("%s: %v", mode, err)
|
|
74
|
+
}
|
|
75
|
+
for _, action := range client.Actions() {
|
|
76
|
+
if action.GetVerb() != "get" {
|
|
77
|
+
t.Fatal("ownership check mutated a resource")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func TestArgoApplicationRejectsDirectWriters(t *testing.T) {
|
|
85
|
+
app, _ := argoFixture(t)
|
|
86
|
+
for _, action := range []string{"apply", "update", "delete"} {
|
|
87
|
+
client := fake.NewSimpleDynamicClient(runtime.NewScheme(), app.DeepCopy())
|
|
88
|
+
resource := client.Resource(argoApplications).Namespace(app.GetNamespace())
|
|
89
|
+
var err error
|
|
90
|
+
switch action {
|
|
91
|
+
case "apply":
|
|
92
|
+
_, err = ApplyManifest(t.Context(), resource, argoDesiredFixture(app), metav1.PatchOptions{FieldManager: "direct"})
|
|
93
|
+
case "update":
|
|
94
|
+
_, err = UpdateManifest(t.Context(), resource, app.DeepCopy())
|
|
95
|
+
case "delete":
|
|
96
|
+
err = DeleteManifest(t.Context(), resource, app.GetName(), string(app.GetUID()))
|
|
97
|
+
}
|
|
98
|
+
if err == nil {
|
|
99
|
+
t.Fatalf("direct %s accepted GitOps root", action)
|
|
100
|
+
}
|
|
101
|
+
for _, call := range client.Actions() {
|
|
102
|
+
if call.GetVerb() != "get" {
|
|
103
|
+
t.Fatal("direct writer mutated GitOps root")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|