@opensoha/contracts 0.1.25 → 0.1.27
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 +34 -0
- package/README.md +1 -1
- package/agent/environment/export.go +1 -0
- package/agent/environment/protocol_test.go +1 -1
- package/agent/guest/protocol_test.go +2 -2
- package/auth/catalog_generated.go +2 -2
- package/auth/permission-catalog.json +481 -1
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
- package/delivery/document.schema.json +127 -9
- package/endpointcontrol/decode.go +64 -0
- package/endpointcontrol/models.go +138 -0
- package/endpointcontrol/models_test.go +51 -0
- package/endpointcontrol/protocol.schema.json +397 -0
- package/examples/relations_compat_test.go +18 -0
- package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
- package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
- package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
- package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
- package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
- package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
- package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
- package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
- package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
- package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
- package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
- package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
- package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
- package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
- package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
- package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
- package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
- package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
- package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
- package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
- package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
- package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
- package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
- package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
- package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
- package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
- package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
- package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
- package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
- package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
- package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
- package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
- package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
- package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
- package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
- package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
- package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
- package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
- package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
- package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
- package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/client_test.go +2 -2
- package/gen/go/sohaapi/import_source_compat_test.go +12 -0
- package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
- package/gen/go/sohaapi/types.go +3760 -345
- package/gen/ts/sohaapi/index.ts +5573 -181
- package/go.mod +2 -2
- package/helmrelease/delivery.go +4 -1
- package/helmrelease/initialization.go +10 -0
- package/helmrelease/runtime/delivery.go +40 -10
- package/helmrelease/runtime/delivery_test.go +4 -0
- package/helmrelease/runtime/initialization.go +186 -0
- package/helmrelease/runtime/initialization_test.go +203 -0
- package/network/README.md +34 -0
- package/network/network-radius-accounting.schema.json +4 -1
- package/network/network-runtime-protocol.schema.json +2166 -317
- package/network/proxy-runtime.schema.json +482 -67
- package/openapi/network_control_test.go +64 -0
- package/openapi/soha-api.json +53524 -43459
- package/openapi/soha-api.yaml +26431 -20491
- package/openapi/spec_generated.go +3 -3
- package/openapi/spec_test.go +42 -0
- package/package.json +6 -3
- package/proxytransport/handler.go +271 -0
- package/proxytransport/probe.go +30 -0
- package/proxytransport/scope.go +89 -0
- package/proxytransport/transport_test.go +60 -0
- package/resource/models.go +2 -0
- package/resource/relations.go +225 -0
- package/resource/relations_test.go +47 -0
- package/resource/root_cr.go +38 -0
- package/resource/runtime/argocd_integration_test.go +1 -1
- package/resource/runtime/initialization.go +66 -0
- package/resource/runtime/initialization_test.go +80 -0
- package/resource/runtime/relations.go +161 -0
- package/resource/runtime/relations_test.go +100 -0
- package/resource/runtime/root_cr.go +79 -0
- package/resource/runtime/root_cr_test.go +118 -0
package/go.mod
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module github.com/opensoha/soha-contracts
|
|
2
2
|
|
|
3
|
-
go 1.27.
|
|
3
|
+
go 1.27.3
|
|
4
4
|
|
|
5
5
|
require (
|
|
6
6
|
github.com/google/uuid v1.6.0
|
|
@@ -12,6 +12,7 @@ require (
|
|
|
12
12
|
k8s.io/apimachinery v0.37.1
|
|
13
13
|
k8s.io/cli-runtime v0.37.1
|
|
14
14
|
k8s.io/client-go v0.37.1
|
|
15
|
+
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
|
|
15
16
|
sigs.k8s.io/yaml v1.6.0
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -121,7 +122,6 @@ require (
|
|
|
121
122
|
k8s.io/klog/v2 v2.140.0 // indirect
|
|
122
123
|
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
|
123
124
|
k8s.io/kubectl v0.37.1 // indirect
|
|
124
|
-
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
|
|
125
125
|
oras.land/oras-go/v2 v2.6.2 // indirect
|
|
126
126
|
sigs.k8s.io/controller-runtime v0.25.2 // indirect
|
|
127
127
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
package/helmrelease/delivery.go
CHANGED
|
@@ -241,6 +241,9 @@ func (r chartBudgetReader) Read(p []byte) (int, error) {
|
|
|
241
241
|
// Prepare accepts an unfinished render; Execute additionally verifies its digests.
|
|
242
242
|
func ValidateTaskIdentity(input sohaapi.HelmExecutionTaskPayload) error {
|
|
243
243
|
s := input.Snapshot
|
|
244
|
+
if IsInitialization(s) && (s.Operation != sohaapi.HelmDeliverySnapshotOperationInstall || s.ExpectedRevision != 0 || s.RollbackRevision != 0 || s.Migration.Blocked || !s.Migration.RequiresApproval || s.Migration.FromVersion != 0 || s.TemplateInputs == nil || s.TemplateInputs.TemplateVersion != s.Migration.ToVersion) {
|
|
245
|
+
return errors.New("initialization requires an approved create-only first deployment snapshot")
|
|
246
|
+
}
|
|
244
247
|
for _, value := range []string{s.DeliveryPlanID, s.TargetID, s.ApplicationID, s.ApplicationEnvironmentID, s.ServiceID, s.ClusterID} {
|
|
245
248
|
if strings.TrimSpace(value) == "" || len(value) > 512 {
|
|
246
249
|
return errors.New("helm task identity is incomplete")
|
|
@@ -249,7 +252,7 @@ func ValidateTaskIdentity(input sohaapi.HelmExecutionTaskPayload) error {
|
|
|
249
252
|
if !input.Action.Valid() || !helmName.MatchString(s.Namespace) || len(s.Namespace) > 63 || !helmName.MatchString(s.ReleaseName) || len(s.ReleaseName) > 53 || s.ExpectedRevision < 0 || s.RollbackRevision < 0 || s.TimeoutSeconds < 0 || s.TimeoutSeconds > 3600 {
|
|
250
253
|
return errors.New("helm task action, namespace, revision or timeout is invalid")
|
|
251
254
|
}
|
|
252
|
-
if len(s.Resources) > 300 {
|
|
255
|
+
if len(s.Resources) > 300 || len(input.InitializationResources) > 300 {
|
|
253
256
|
return errors.New("helm task exceeds 300 resources")
|
|
254
257
|
}
|
|
255
258
|
if input.Prepared != nil {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
package helmrelease
|
|
2
|
+
|
|
3
|
+
import "github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
4
|
+
|
|
5
|
+
const InitializationCapability = "helm.initialize.v1"
|
|
6
|
+
const InitializationProviderPrefix = "helm_initialize_v1."
|
|
7
|
+
|
|
8
|
+
func IsInitialization(snapshot sohaapi.HelmDeliverySnapshot) bool {
|
|
9
|
+
return snapshot.Migration != nil && snapshot.Migration.Initialization
|
|
10
|
+
}
|
|
@@ -28,6 +28,7 @@ import (
|
|
|
28
28
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
29
29
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
30
30
|
"k8s.io/apimachinery/pkg/runtime"
|
|
31
|
+
"k8s.io/apimachinery/pkg/types"
|
|
31
32
|
"k8s.io/client-go/dynamic"
|
|
32
33
|
)
|
|
33
34
|
|
|
@@ -39,6 +40,13 @@ func Prepare(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmE
|
|
|
39
40
|
if err != nil {
|
|
40
41
|
return input, err
|
|
41
42
|
}
|
|
43
|
+
if helmrelease.IsInitialization(input.Snapshot) && helmDeliveryAlreadyApplied(current, input.Snapshot) {
|
|
44
|
+
if err := validateHelmPrepared(cfg, input); err != nil {
|
|
45
|
+
return input, err
|
|
46
|
+
}
|
|
47
|
+
_, err := observeInitializedHelm(ctx, cfg, input, current)
|
|
48
|
+
return input, err
|
|
49
|
+
}
|
|
42
50
|
input.Snapshot.ExpectedRevision = 0
|
|
43
51
|
input.Snapshot.Operation = sohaapi.HelmDeliverySnapshotOperationInstall
|
|
44
52
|
if current != nil {
|
|
@@ -105,17 +113,22 @@ func Execute(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmE
|
|
|
105
113
|
return result, err
|
|
106
114
|
}
|
|
107
115
|
if input.Action == sohaapi.Observe {
|
|
108
|
-
return
|
|
116
|
+
return observeInitializedHelm(ctx, cfg, input, current)
|
|
109
117
|
}
|
|
110
118
|
if err := validateHelmPrepared(cfg, input); err != nil {
|
|
111
119
|
return result, err
|
|
112
120
|
}
|
|
113
121
|
if helmDeliveryAlreadyApplied(current, input.Snapshot) {
|
|
114
|
-
return
|
|
122
|
+
return observeInitializedHelm(ctx, cfg, input, current)
|
|
115
123
|
}
|
|
116
124
|
if err := helmDeliveryRevisionMatches(current, input.Snapshot); err != nil {
|
|
117
125
|
return result, err
|
|
118
126
|
}
|
|
127
|
+
if helmrelease.IsInitialization(input.Snapshot) {
|
|
128
|
+
if err := requireInitialHelmResourcesAbsent(cfg, input); err != nil {
|
|
129
|
+
return result, err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
119
132
|
if input.Action == sohaapi.Preflight {
|
|
120
133
|
result.Ready, result.Status, result.Revision = true, "preflighted", input.Snapshot.ExpectedRevision
|
|
121
134
|
return result, nil
|
|
@@ -136,10 +149,21 @@ func Execute(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmE
|
|
|
136
149
|
// Helm's RunWithContext may return while its mutation goroutine is still
|
|
137
150
|
// running. Once mutation begins, native Timeout bounds it; cancellation is
|
|
138
151
|
// acknowledged only after Run has returned and the release is inspected.
|
|
152
|
+
var initialization *initializingHelmClient
|
|
153
|
+
if helmrelease.IsInitialization(input.Snapshot) {
|
|
154
|
+
initialization = &initializingHelmClient{Interface: cfg.KubeClient, created: make(map[string]types.UID)}
|
|
155
|
+
cfg = helmConfigurationWithClient(cfg, initialization)
|
|
156
|
+
}
|
|
139
157
|
_, runErr := runHelmDeliveryAction(context.Background(), cfg, input, ch, false)
|
|
140
158
|
current, err = currentHelmDelivery(cfg, input.Snapshot.ReleaseName, 0)
|
|
141
159
|
if err == nil && current != nil {
|
|
142
160
|
result, err = observeHelmDelivery(ctx, cfg, input.Snapshot, current)
|
|
161
|
+
if err == nil && initialization != nil {
|
|
162
|
+
err = initialization.validateCreated(result)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if err != nil || runErr != nil {
|
|
166
|
+
result.Ready = false
|
|
143
167
|
}
|
|
144
168
|
if runErr != nil {
|
|
145
169
|
return result, fmt.Errorf("%w: Helm execution failed; inspect the recorded revision and resource state", ErrUnavailable)
|
|
@@ -150,17 +174,23 @@ func Execute(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmE
|
|
|
150
174
|
return result, err
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
// Build a per-invocation configuration without copying Helm's internal mutex or
|
|
178
|
+
// replacing the caller's client. Dry-run template functions also remain local.
|
|
179
|
+
func helmConfigurationWithClient(original *action.Configuration, client kube.Interface) *action.Configuration {
|
|
180
|
+
cfg := &action.Configuration{
|
|
181
|
+
RESTClientGetter: original.RESTClientGetter, Releases: original.Releases,
|
|
182
|
+
KubeClient: client, RegistryClient: original.RegistryClient,
|
|
183
|
+
Capabilities: original.Capabilities, HookOutputFunc: original.HookOutputFunc,
|
|
184
|
+
CustomTemplateFuncs: maps.Clone(original.CustomTemplateFuncs),
|
|
185
|
+
}
|
|
186
|
+
cfg.SetLogger(original.Logger().Handler())
|
|
187
|
+
return cfg
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
func runHelmDeliveryAction(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmExecutionTaskPayload, ch chart.Charter, dryRun bool) (*helmreleasev1.Release, error) {
|
|
154
191
|
if dryRun {
|
|
155
192
|
// Discover real cluster capabilities, but keep template lookup disabled.
|
|
156
|
-
|
|
157
|
-
cfg = &action.Configuration{
|
|
158
|
-
RESTClientGetter: original.RESTClientGetter, Releases: original.Releases,
|
|
159
|
-
KubeClient: original.KubeClient, RegistryClient: original.RegistryClient,
|
|
160
|
-
Capabilities: original.Capabilities, HookOutputFunc: original.HookOutputFunc,
|
|
161
|
-
CustomTemplateFuncs: maps.Clone(original.CustomTemplateFuncs),
|
|
162
|
-
}
|
|
163
|
-
cfg.SetLogger(original.Logger().Handler())
|
|
193
|
+
cfg = helmConfigurationWithClient(cfg, cfg.KubeClient)
|
|
164
194
|
if cfg.CustomTemplateFuncs == nil {
|
|
165
195
|
cfg.CustomTemplateFuncs = make(map[string]any)
|
|
166
196
|
}
|
|
@@ -39,6 +39,7 @@ type observedHelmKubeClient struct {
|
|
|
39
39
|
missing bool
|
|
40
40
|
namespace string
|
|
41
41
|
reads int
|
|
42
|
+
uid string
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
func (c *observedHelmKubeClient) Build(io.Reader, bool) (kube.ResourceList, error) {
|
|
@@ -49,6 +50,9 @@ func (c *observedHelmKubeClient) Build(io.Reader, bool) (kube.ResourceList, erro
|
|
|
49
50
|
return &http.Response{StatusCode: http.StatusNotFound, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"apiVersion":"v1","kind":"Status","status":"Failure","reason":"NotFound","code":404}`))}, nil
|
|
50
51
|
}
|
|
51
52
|
body := object
|
|
53
|
+
if c.uid != "" {
|
|
54
|
+
body = strings.Replace(body, `"name":"app"`, `"name":"app","uid":"`+c.uid+`"`, 1)
|
|
55
|
+
}
|
|
52
56
|
if c.deleting {
|
|
53
57
|
body = strings.Replace(body, `"name":"app"`, `"name":"app","deletionTimestamp":"2026-09-12T00:00:00Z"`, 1)
|
|
54
58
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
"sync"
|
|
8
|
+
|
|
9
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
10
|
+
"github.com/opensoha/soha-contracts/helmrelease"
|
|
11
|
+
"helm.sh/helm/v4/pkg/action"
|
|
12
|
+
"helm.sh/helm/v4/pkg/kube"
|
|
13
|
+
helmreleasev1 "helm.sh/helm/v4/pkg/release/v1"
|
|
14
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
15
|
+
"k8s.io/apimachinery/pkg/api/meta"
|
|
16
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
17
|
+
"k8s.io/apimachinery/pkg/types"
|
|
18
|
+
kuberesource "k8s.io/cli-runtime/pkg/resource"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
func requireInitialHelmResourcesAbsent(cfg *action.Configuration, input sohaapi.HelmExecutionTaskPayload) error {
|
|
22
|
+
if input.Snapshot.Operation != sohaapi.HelmDeliverySnapshotOperationInstall || input.Snapshot.ExpectedRevision != 0 || input.Snapshot.RollbackRevision != 0 || input.Prepared == nil {
|
|
23
|
+
return fmt.Errorf("%w: initialization requires a new install", ErrConflict)
|
|
24
|
+
}
|
|
25
|
+
for _, document := range append([]string{input.Prepared.Manifest}, input.Prepared.Hooks...) {
|
|
26
|
+
resources, err := cfg.KubeClient.Build(strings.NewReader(document), false)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
if err := requireHelmResourcesAbsent(resources); err != nil {
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func requireHelmResourcesAbsent(resources kube.ResourceList) error {
|
|
38
|
+
for _, resource := range resources {
|
|
39
|
+
err := resource.Get()
|
|
40
|
+
if !apierrors.IsNotFound(err) {
|
|
41
|
+
return fmt.Errorf("%w: initialization resource exists or its absence cannot be verified", ErrConflict)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Helm's ordinary install may adopt correctly labelled existing objects. This
|
|
48
|
+
// client refuses that update path and forces native POSTs, including for hooks.
|
|
49
|
+
// Hook cleanup can delete only UIDs created by this exact native invocation.
|
|
50
|
+
type initializingHelmClient struct {
|
|
51
|
+
kube.Interface
|
|
52
|
+
mu sync.Mutex
|
|
53
|
+
created map[string]types.UID
|
|
54
|
+
hooks map[string]bool
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (c *initializingHelmClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) {
|
|
58
|
+
if err := requireHelmResourcesAbsent(resources); err != nil {
|
|
59
|
+
return nil, err
|
|
60
|
+
}
|
|
61
|
+
result, err := c.Interface.Create(resources, append(options, kube.ClientCreateOptionServerSideApply(false, false))...)
|
|
62
|
+
if result != nil {
|
|
63
|
+
c.mu.Lock()
|
|
64
|
+
defer c.mu.Unlock()
|
|
65
|
+
for _, resource := range result.Created {
|
|
66
|
+
object, accessErr := meta.Accessor(resource.Object)
|
|
67
|
+
if accessErr == nil && object.GetUID() != "" {
|
|
68
|
+
key := initialHelmResourceKey(resource)
|
|
69
|
+
c.created[key] = object.GetUID()
|
|
70
|
+
if object.GetAnnotations()["helm.sh/hook"] != "" {
|
|
71
|
+
if c.hooks == nil {
|
|
72
|
+
c.hooks = make(map[string]bool)
|
|
73
|
+
}
|
|
74
|
+
c.hooks[key] = true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result, err
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (c *initializingHelmClient) Update(kube.ResourceList, kube.ResourceList, ...kube.ClientUpdateOption) (*kube.Result, error) {
|
|
83
|
+
return nil, fmt.Errorf("%w: initialization cannot update or adopt existing resources", ErrConflict)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (c *initializingHelmClient) Delete(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) {
|
|
87
|
+
result := &kube.Result{}
|
|
88
|
+
for _, resource := range resources {
|
|
89
|
+
if err := c.deleteCreatedHook(resource, policy); err != nil {
|
|
90
|
+
return result, []error{err}
|
|
91
|
+
}
|
|
92
|
+
result.Deleted = append(result.Deleted, resource)
|
|
93
|
+
}
|
|
94
|
+
return result, nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (c *initializingHelmClient) deleteCreatedHook(resource *kuberesource.Info, policy metav1.DeletionPropagation) error {
|
|
98
|
+
if err := resource.Get(); err != nil {
|
|
99
|
+
if apierrors.IsNotFound(err) {
|
|
100
|
+
return nil
|
|
101
|
+
}
|
|
102
|
+
return fmt.Errorf("%w: hook identity cannot be verified", ErrConflict)
|
|
103
|
+
}
|
|
104
|
+
object, err := meta.Accessor(resource.Object)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
c.mu.Lock()
|
|
109
|
+
uid := c.created[initialHelmResourceKey(resource)]
|
|
110
|
+
hook := c.hooks[initialHelmResourceKey(resource)]
|
|
111
|
+
c.mu.Unlock()
|
|
112
|
+
if !hook || uid == "" || object.GetUID() != uid || object.GetResourceVersion() == "" {
|
|
113
|
+
return fmt.Errorf("%w: initialization cannot delete a pre-existing or replaced hook", ErrConflict)
|
|
114
|
+
}
|
|
115
|
+
version := object.GetResourceVersion()
|
|
116
|
+
_, err = kuberesource.NewHelper(resource.Client, resource.Mapping).DeleteWithOptions(resource.Namespace, resource.Name, &metav1.DeleteOptions{PropagationPolicy: &policy, Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &version}})
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func initialInventoryKey(item sohaapi.ManifestResourceInventory) string {
|
|
121
|
+
group := ""
|
|
122
|
+
if parts := strings.SplitN(item.APIVersion, "/", 2); len(parts) == 2 {
|
|
123
|
+
group = parts[0]
|
|
124
|
+
}
|
|
125
|
+
return group + "/" + item.Kind + "/" + item.Namespace + "/" + item.Name
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ValidateInitializationResources binds a fresh observation to the durable
|
|
129
|
+
// original apply receipt, never to UIDs first read during recovery.
|
|
130
|
+
func ValidateInitializationResources(observed, original []sohaapi.ManifestResourceInventory) error {
|
|
131
|
+
if len(original) == 0 || len(observed) != len(original) {
|
|
132
|
+
return fmt.Errorf("%w: initialization creation receipt is missing or incomplete", ErrConflict)
|
|
133
|
+
}
|
|
134
|
+
uids := make(map[string]string, len(original))
|
|
135
|
+
for _, item := range original {
|
|
136
|
+
key := initialInventoryKey(item)
|
|
137
|
+
if item.UID == "" || uids[key] != "" {
|
|
138
|
+
return fmt.Errorf("%w: invalid initialization receipt", ErrConflict)
|
|
139
|
+
}
|
|
140
|
+
uids[key] = item.UID
|
|
141
|
+
}
|
|
142
|
+
for _, item := range observed {
|
|
143
|
+
key := initialInventoryKey(item)
|
|
144
|
+
if item.UID == "" || uids[key] != item.UID {
|
|
145
|
+
return fmt.Errorf("%w: initialized resource was replaced", ErrConflict)
|
|
146
|
+
}
|
|
147
|
+
delete(uids, key)
|
|
148
|
+
}
|
|
149
|
+
return nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func (c *initializingHelmClient) validateCreated(result sohaapi.HelmExecutionTaskResult) error {
|
|
153
|
+
c.mu.Lock()
|
|
154
|
+
defer c.mu.Unlock()
|
|
155
|
+
if len(result.Resources) == 0 {
|
|
156
|
+
return fmt.Errorf("%w: initialization has no observable resource receipt", ErrConflict)
|
|
157
|
+
}
|
|
158
|
+
for _, item := range result.Resources {
|
|
159
|
+
if item.UID == "" || string(c.created[initialInventoryKey(item)]) != item.UID {
|
|
160
|
+
return fmt.Errorf("%w: initialized resource creation UID cannot be verified", ErrConflict)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return nil
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func observeInitializedHelm(ctx context.Context, cfg *action.Configuration, input sohaapi.HelmExecutionTaskPayload, current *helmreleasev1.Release) (sohaapi.HelmExecutionTaskResult, error) {
|
|
167
|
+
result, err := observeHelmDelivery(ctx, cfg, input.Snapshot, current)
|
|
168
|
+
if err == nil && helmrelease.IsInitialization(input.Snapshot) {
|
|
169
|
+
err = ValidateInitializationResources(result.Resources, input.InitializationResources)
|
|
170
|
+
}
|
|
171
|
+
if err != nil {
|
|
172
|
+
result.Ready = false
|
|
173
|
+
}
|
|
174
|
+
return result, err
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func initialHelmResourceKey(resource *kuberesource.Info) string {
|
|
178
|
+
return resource.Mapping.GroupVersionKind.Group + "/" + resource.Mapping.GroupVersionKind.Kind + "/" + resource.Namespace + "/" + resource.Name
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func (c *initializingHelmClient) GetWaiterWithOptions(strategy kube.WaitStrategy, options ...kube.WaitOption) (kube.Waiter, error) {
|
|
182
|
+
if client, ok := c.Interface.(kube.InterfaceWaitOptions); ok {
|
|
183
|
+
return client.GetWaiterWithOptions(strategy, options...)
|
|
184
|
+
}
|
|
185
|
+
return c.GetWaiter(strategy)
|
|
186
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"io"
|
|
8
|
+
"log/slog"
|
|
9
|
+
"net/http"
|
|
10
|
+
"reflect"
|
|
11
|
+
"slices"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
|
|
15
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
16
|
+
"github.com/opensoha/soha-contracts/helmrelease"
|
|
17
|
+
"helm.sh/helm/v4/pkg/action"
|
|
18
|
+
"helm.sh/helm/v4/pkg/chart/common"
|
|
19
|
+
"helm.sh/helm/v4/pkg/kube"
|
|
20
|
+
releasecommon "helm.sh/helm/v4/pkg/release/common"
|
|
21
|
+
releasev1 "helm.sh/helm/v4/pkg/release/v1"
|
|
22
|
+
"helm.sh/helm/v4/pkg/storage"
|
|
23
|
+
"helm.sh/helm/v4/pkg/storage/driver"
|
|
24
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
25
|
+
"k8s.io/apimachinery/pkg/api/meta"
|
|
26
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
27
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
28
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
29
|
+
"k8s.io/apimachinery/pkg/types"
|
|
30
|
+
kuberesource "k8s.io/cli-runtime/pkg/resource"
|
|
31
|
+
restfake "k8s.io/client-go/rest/fake"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
func TestHelmInitializationLeavesCallerConfigurationUntouched(t *testing.T) {
|
|
35
|
+
client := &observedHelmKubeClient{}
|
|
36
|
+
original := &action.Configuration{KubeClient: client, Releases: storage.Init(driver.NewMemory()), CustomTemplateFuncs: map[string]any{"caller": "preserved"}}
|
|
37
|
+
protected := &initializingHelmClient{Interface: client, created: make(map[string]types.UID)}
|
|
38
|
+
local := helmConfigurationWithClient(original, protected)
|
|
39
|
+
local.CustomTemplateFuncs["caller"] = "changed"
|
|
40
|
+
if original.KubeClient != client || original.CustomTemplateFuncs["caller"] != "preserved" {
|
|
41
|
+
t.Fatal("initialization changed the caller's Kubernetes client or template functions")
|
|
42
|
+
}
|
|
43
|
+
if local.KubeClient != protected || local.Releases != original.Releases {
|
|
44
|
+
t.Fatal("per-invocation configuration lost the protected client or release storage")
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestHelmInitializationObserveAndPrepareRecoveryRequireOriginalReceipt(t *testing.T) {
|
|
49
|
+
client := &observedHelmKubeClient{missing: true}
|
|
50
|
+
cfg := &action.Configuration{KubeClient: client, Releases: storage.Init(driver.NewMemory()), Capabilities: common.DefaultCapabilities}
|
|
51
|
+
archive := helmSourceTestArchive(t)
|
|
52
|
+
input := sohaapi.HelmExecutionTaskPayload{Action: sohaapi.Preflight, Snapshot: sohaapi.HelmDeliverySnapshot{DeliveryPlanID: "first", TargetID: "target", ApplicationID: "app", ApplicationEnvironmentID: "dev", ServiceID: "svc", ClusterID: "cluster", Namespace: "dev", ReleaseName: "app", ChartDigest: helmrelease.SHA256(archive)}, Prepared: &sohaapi.HelmPreparedRelease{ChartArchive: base64.StdEncoding.EncodeToString(archive)}}
|
|
53
|
+
input, err := Prepare(context.Background(), cfg, input)
|
|
54
|
+
if err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
input.Snapshot.TemplateInputs = &sohaapi.TemplateVersionReference{TemplateID: "template", TemplateVersion: 3, TemplateDigest: "immutable"}
|
|
58
|
+
input.Snapshot.Migration = &sohaapi.TemplateMigrationAssessment{Initialization: true, FromVersion: 0, ToVersion: 3, RequiresApproval: true}
|
|
59
|
+
release := &releasev1.Release{Name: "app", Namespace: "dev", Version: 1, Manifest: input.Prepared.Manifest, Labels: helmDeliveryLabels(input.Snapshot), Info: &releasev1.Info{Status: releasecommon.StatusDeployed}}
|
|
60
|
+
if err := cfg.Releases.Create(release); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
client.missing = false
|
|
64
|
+
client.uid = "created"
|
|
65
|
+
for _, mode := range []string{"missing", "original", "replaced"} {
|
|
66
|
+
t.Run(mode, func(t *testing.T) {
|
|
67
|
+
candidate := input
|
|
68
|
+
if mode != "missing" {
|
|
69
|
+
candidate.InitializationResources = []sohaapi.ManifestResourceInventory{{APIVersion: "v1", Kind: "ConfigMap", Namespace: "dev", Name: "app", UID: "created"}}
|
|
70
|
+
}
|
|
71
|
+
if mode == "replaced" {
|
|
72
|
+
client.uid = "replacement"
|
|
73
|
+
} else {
|
|
74
|
+
client.uid = "created"
|
|
75
|
+
}
|
|
76
|
+
candidate.Action = sohaapi.Observe
|
|
77
|
+
result, err := Execute(context.Background(), cfg, candidate)
|
|
78
|
+
if (err == nil) != (mode == "original") || result.Ready != (mode == "original") {
|
|
79
|
+
t.Fatalf("observation %s: %+v %v", mode, result, err)
|
|
80
|
+
}
|
|
81
|
+
candidate.Action = sohaapi.Preflight
|
|
82
|
+
prepared, err := Prepare(context.Background(), cfg, candidate)
|
|
83
|
+
if (err == nil) != (mode == "original") {
|
|
84
|
+
t.Fatalf("recovery %s: %v", mode, err)
|
|
85
|
+
}
|
|
86
|
+
if err == nil && (prepared.Snapshot.ExpectedRevision != 0 || prepared.Snapshot.Operation != sohaapi.HelmDeliverySnapshotOperationInstall) {
|
|
87
|
+
t.Fatal("recovery turned initialization into upgrade")
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestHelmInitializationReceiptRejectsMissingReplacedAndDuplicateUID(t *testing.T) {
|
|
94
|
+
original := []sohaapi.ManifestResourceInventory{{APIVersion: "v1", Kind: "ConfigMap", Namespace: "dev", Name: "app", UID: "created"}}
|
|
95
|
+
if err := ValidateInitializationResources(original, original); err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
for _, mode := range []string{"missing", "replaced", "empty", "duplicate"} {
|
|
99
|
+
observed, receipt := slices.Clone(original), slices.Clone(original)
|
|
100
|
+
switch mode {
|
|
101
|
+
case "missing":
|
|
102
|
+
receipt = nil
|
|
103
|
+
case "replaced":
|
|
104
|
+
observed[0].UID = "replacement"
|
|
105
|
+
case "empty":
|
|
106
|
+
receipt[0].UID = ""
|
|
107
|
+
case "duplicate":
|
|
108
|
+
receipt = append(receipt, receipt[0])
|
|
109
|
+
observed = append(observed, observed[0])
|
|
110
|
+
}
|
|
111
|
+
if ValidateInitializationResources(observed, receipt) == nil {
|
|
112
|
+
t.Fatalf("accepted %s receipt", mode)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
guard := &initializingHelmClient{created: map[string]types.UID{"/ConfigMap/dev/app": "created"}}
|
|
116
|
+
if err := guard.validateCreated(sohaapi.HelmExecutionTaskResult{Resources: original}); err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
original[0].UID = "replacement"
|
|
120
|
+
if guard.validateCreated(sohaapi.HelmExecutionTaskResult{Resources: original}) == nil {
|
|
121
|
+
t.Fatal("replacement registered as original creation")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestHelmInitializationPreflightRequiresAbsentResources(t *testing.T) {
|
|
126
|
+
input := sohaapi.HelmExecutionTaskPayload{Snapshot: sohaapi.HelmDeliverySnapshot{Operation: sohaapi.HelmDeliverySnapshotOperationInstall}, Prepared: &sohaapi.HelmPreparedRelease{Manifest: "manifest"}}
|
|
127
|
+
for _, missing := range []bool{false, true} {
|
|
128
|
+
cfg := &action.Configuration{KubeClient: &observedHelmKubeClient{missing: missing}}
|
|
129
|
+
err := requireInitialHelmResourcesAbsent(cfg, input)
|
|
130
|
+
if (err == nil) != missing {
|
|
131
|
+
t.Fatalf("absence=%v: %v", missing, err)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
input.Snapshot.ExpectedRevision = 1
|
|
135
|
+
if requireInitialHelmResourcesAbsent(&action.Configuration{}, input) == nil {
|
|
136
|
+
t.Fatal("existing revision accepted as initialization")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func TestHelmInitializationForcesAtomicCreateAndUIDBoundHookCleanup(t *testing.T) {
|
|
141
|
+
for _, race := range []bool{false, true} {
|
|
142
|
+
t.Run(map[bool]string{false: "new", true: "create-race"}[race], func(t *testing.T) {
|
|
143
|
+
uid := ""
|
|
144
|
+
methods := []string{}
|
|
145
|
+
object := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"app","namespace":"dev","uid":"created","resourceVersion":"7","annotations":{"helm.sh/hook":"post-install"}}}`
|
|
146
|
+
client := &restfake.RESTClient{GroupVersion: schema.GroupVersion{Version: "v1"}, NegotiatedSerializer: kuberesource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, Client: restfake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
|
|
147
|
+
methods = append(methods, req.Method)
|
|
148
|
+
code, body := http.StatusOK, object
|
|
149
|
+
switch req.Method {
|
|
150
|
+
case http.MethodGet:
|
|
151
|
+
if uid == "" {
|
|
152
|
+
code, body = http.StatusNotFound, `{"apiVersion":"v1","kind":"Status","status":"Failure","reason":"NotFound","code":404}`
|
|
153
|
+
} else {
|
|
154
|
+
body = strings.ReplaceAll(object, "created", uid)
|
|
155
|
+
}
|
|
156
|
+
case http.MethodPost:
|
|
157
|
+
if race {
|
|
158
|
+
uid = "foreign"
|
|
159
|
+
code, body = http.StatusConflict, `{"apiVersion":"v1","kind":"Status","status":"Failure","reason":"AlreadyExists","code":409}`
|
|
160
|
+
} else {
|
|
161
|
+
uid = "created"
|
|
162
|
+
}
|
|
163
|
+
case http.MethodDelete:
|
|
164
|
+
var options metav1.DeleteOptions
|
|
165
|
+
if err := json.NewDecoder(req.Body).Decode(&options); err != nil || options.Preconditions == nil || options.Preconditions.UID == nil || *options.Preconditions.UID != "created" || options.Preconditions.ResourceVersion == nil || *options.Preconditions.ResourceVersion != "7" {
|
|
166
|
+
t.Fatal("hook cleanup lost UID/resourceVersion preconditions", err)
|
|
167
|
+
}
|
|
168
|
+
uid = ""
|
|
169
|
+
default:
|
|
170
|
+
t.Fatalf("initialization issued unsafe HTTP %s", req.Method)
|
|
171
|
+
}
|
|
172
|
+
return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
|
173
|
+
})}
|
|
174
|
+
var desired unstructured.Unstructured
|
|
175
|
+
if err := json.Unmarshal([]byte(object), &desired); err != nil {
|
|
176
|
+
t.Fatal(err)
|
|
177
|
+
}
|
|
178
|
+
desired.SetUID("")
|
|
179
|
+
desired.SetResourceVersion("")
|
|
180
|
+
resources := kube.ResourceList{&kuberesource.Info{Client: client, Namespace: "dev", Name: "app", Object: &desired, Mapping: &meta.RESTMapping{Resource: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, GroupVersionKind: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, Scope: meta.RESTScopeNamespace}}}
|
|
181
|
+
native := &kube.Client{}
|
|
182
|
+
native.SetLogger(slog.NewTextHandler(io.Discard, nil))
|
|
183
|
+
guard := &initializingHelmClient{Interface: native, created: map[string]types.UID{}}
|
|
184
|
+
_, err := guard.Create(resources, kube.ClientCreateOptionServerSideApply(true, true))
|
|
185
|
+
if race && !apierrors.IsAlreadyExists(err) || !race && err != nil {
|
|
186
|
+
t.Fatalf("atomic create race=%v: %v", race, err)
|
|
187
|
+
}
|
|
188
|
+
if !reflect.DeepEqual(methods, []string{http.MethodGet, http.MethodGet, http.MethodPost}) {
|
|
189
|
+
t.Fatalf("native initialization did not use GET/POST only: %v", methods)
|
|
190
|
+
}
|
|
191
|
+
if _, err := guard.Update(nil, resources); err == nil {
|
|
192
|
+
t.Fatal("initialization adopted existing resources through Update")
|
|
193
|
+
}
|
|
194
|
+
_, errs := guard.Delete(resources, metav1.DeletePropagationBackground)
|
|
195
|
+
if (len(errs) == 0) == race {
|
|
196
|
+
t.Fatalf("hook ownership race=%v: %v", race, errs)
|
|
197
|
+
}
|
|
198
|
+
if race && methods[len(methods)-1] != http.MethodGet || !race && methods[len(methods)-1] != http.MethodDelete {
|
|
199
|
+
t.Fatalf("hook deletion ignored ownership: %v", methods)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
package/network/README.md
CHANGED
|
@@ -57,6 +57,11 @@ for authorization, routing, firewall configuration or accounting.
|
|
|
57
57
|
reply attribute and require NAS accounting to echo it as `sessionId` when
|
|
58
58
|
available. Session commands repeat the bound `subjectId` and `deviceId` so
|
|
59
59
|
the adapter can target CoA/Disconnect without querying management APIs.
|
|
60
|
+
- Capability `nas-session-accounting-v1` enables the optional command target
|
|
61
|
+
`stationId`, `accountingSessionId` and `nasPortId`. For EAP-TLS, station identity
|
|
62
|
+
comes from saved authorization/accounting, never the opaque Soha device ID.
|
|
63
|
+
A runtime that cannot receive the required target binding must report
|
|
64
|
+
unsupported rather than send a command to a guessed station.
|
|
60
65
|
- If a requested CoA is unsupported, issue a disconnect command when the NAS
|
|
61
66
|
binding supports it; otherwise fail closed and record the unsupported
|
|
62
67
|
enforcement result.
|
|
@@ -166,3 +171,32 @@ for authorization, routing, firewall configuration or accounting.
|
|
|
166
171
|
Consumers must cover these rules with deterministic tests. Failure is
|
|
167
172
|
fail-closed for control messages and bounded reject/drop-with-counter behavior
|
|
168
173
|
for telemetry.
|
|
174
|
+
|
|
175
|
+
## Endpoint presence and RADIUS response
|
|
176
|
+
|
|
177
|
+
- `endpoint.presence.request` goes to
|
|
178
|
+
`POST /api/network-control/v1/runtimes/{runtimeId}/endpoint:presence` using the
|
|
179
|
+
enrolled endpoint's control identity and `endpoint-presence-v1` capability.
|
|
180
|
+
`bootId` identifies the protected service incarnation; a new incarnation
|
|
181
|
+
starts a new sequence and retires the previous one. A duplicate must not
|
|
182
|
+
refresh server-side liveness. UI presence never implies a healthy service.
|
|
183
|
+
- `POST /api/network-control/v1/runtimes/{runtimeId}/nas:accounting` uses
|
|
184
|
+
the enrolled NAS control identity and the existing RADIUS Accounting schema.
|
|
185
|
+
The control plane checks NAS, subject, station and accounting-session binding
|
|
186
|
+
against the saved authorization before updating session evidence. The
|
|
187
|
+
separate ingest submission remains telemetry and cannot change admission.
|
|
188
|
+
- A Stop remains terminal for that Core session. A reused accounting ID,
|
|
189
|
+
conflicting binding, restart after Start, or missing Start remains unknown.
|
|
190
|
+
Received time alone cannot refresh an old observation. Accounting evidence
|
|
191
|
+
cannot authorize access or prove that a VLAN/ACL was applied.
|
|
192
|
+
- Persist response restrictions before delivering NAS commands. A new
|
|
193
|
+
authentication must observe every active restriction. Policy retirement
|
|
194
|
+
never removes an existing restriction, and restoration requires current
|
|
195
|
+
health plus authorization rather than replaying a previous allow decision.
|
|
196
|
+
|
|
197
|
+
## Dedicated EAP certificates and native profiles
|
|
198
|
+
|
|
199
|
+
- `eap.certificate.request` uses `POST /api/network-control/v1/runtimes/{runtimeId}/eap:certificate`; `eap.certificate.activate.request` uses the same prefix with `eap:activate`. Both require the endpoint control identity and `eap-profile-v1` plus `radius-eap-tls-v1`. CSR private keys stay on the endpoint. Core binds the subject and device from enrollment and ignores CSR identity extensions.
|
|
200
|
+
- A dedicated EAP issuer signs only client authentication certificates with a `network-eap` URI. EAP certificates cannot authenticate control or ingest requests. Without an issuer, issuance is unavailable. Existing runtime certificates are accepted for legacy EAP only when enrollment explicitly declares `radius-eap-tls-v1`; ordinary control credentials do not imply EAP permission.
|
|
201
|
+
- Issuance is idempotent per runtime/request ID and CSR. Certificates start pending and cannot grant network access. After native installation and readback, activation atomically replaces the old EAP certificate under the parent runtime credential lock. A retired or expired parent invalidates every child. Replaying an older activation cannot revive it.
|
|
202
|
+
- `admission.profiles` is delivered only to `eap-profile-v1` endpoints, with exact EAP server names, CA certificates, subject and the dedicated active EAP certificate fingerprint. Configuration export, installation, 802.1X connection and actual NAS enforcement are separate results.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://contracts.opensoha.dev/network/network-radius-accounting.schema.json",
|
|
4
4
|
"title": "OpenSoha FreeRADIUS Accounting Adapter Request",
|
|
5
|
-
"description": "A strict credential-free request converted by soha-ingest into
|
|
5
|
+
"description": "A strict credential-free request converted by soha-ingest into telemetry. When submitted to network-control with an enrolled NAS identity, its NAS and session binding are independently verified before updating admission evidence.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": [
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"accountingSessionId": { "$ref": "#/$defs/Identifier" },
|
|
21
21
|
"sessionId": { "$ref": "#/$defs/Identifier" },
|
|
22
22
|
"nasId": { "$ref": "#/$defs/Identifier" },
|
|
23
|
+
"accountingDelaySeconds": { "type": "integer", "minimum": 0, "maximum": 86400 },
|
|
24
|
+
"observedVlanId": { "type": "integer", "minimum": 1, "maximum": 4094 },
|
|
25
|
+
"observedFilterId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
23
26
|
"nasPortId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
24
27
|
"userId": { "$ref": "#/$defs/Identifier" },
|
|
25
28
|
"deviceId": { "$ref": "#/$defs/Identifier" },
|