@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.
Files changed (200) hide show
  1. package/COMPATIBILITY.md +34 -0
  2. package/README.md +1 -1
  3. package/agent/environment/export.go +1 -0
  4. package/agent/environment/protocol_test.go +1 -1
  5. package/agent/guest/protocol_test.go +2 -2
  6. package/auth/catalog_generated.go +2 -2
  7. package/auth/permission-catalog.json +481 -1
  8. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  9. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  10. package/delivery/document.schema.json +127 -9
  11. package/endpointcontrol/decode.go +64 -0
  12. package/endpointcontrol/models.go +138 -0
  13. package/endpointcontrol/models_test.go +51 -0
  14. package/endpointcontrol/protocol.schema.json +397 -0
  15. package/examples/relations_compat_test.go +18 -0
  16. package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
  17. package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
  18. package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
  19. package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
  20. package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
  21. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
  22. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
  23. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
  24. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
  25. package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
  26. package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
  27. package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
  28. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
  29. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
  30. package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
  31. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
  32. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
  33. package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
  34. package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
  35. package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
  36. package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
  37. package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
  38. package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
  39. package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
  40. package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
  41. package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
  42. package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
  43. package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
  44. package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
  45. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
  46. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
  47. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
  48. package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
  49. package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
  50. package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
  51. package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
  52. package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
  53. package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
  54. package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
  55. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
  56. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
  57. package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
  58. package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
  59. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
  60. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
  61. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
  62. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
  63. package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
  64. package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
  65. package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
  66. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
  67. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
  68. package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
  69. package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
  70. package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
  71. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
  72. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
  73. package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
  74. package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
  75. package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
  76. package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
  77. package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
  78. package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
  79. package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
  80. package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
  81. package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
  82. package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
  83. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
  84. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
  85. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
  86. package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
  87. package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
  88. package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
  89. package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
  90. package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
  91. package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
  92. package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
  93. package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
  94. package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
  95. package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
  96. package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
  97. package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
  98. package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
  99. package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
  100. package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
  101. package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
  102. package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
  103. package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
  104. package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
  105. package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
  106. package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
  107. package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
  108. package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
  109. package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
  110. package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
  111. package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
  112. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
  113. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
  114. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
  115. package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
  116. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
  117. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
  118. package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
  119. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
  120. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
  121. package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
  122. package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
  123. package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
  124. package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
  125. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
  126. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
  127. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
  128. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
  129. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
  130. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
  131. package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
  132. package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
  133. package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
  134. package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
  135. package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
  136. package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
  137. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
  138. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
  139. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
  140. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
  141. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
  142. package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
  143. package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
  144. package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
  145. package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
  146. package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
  147. package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
  148. package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
  149. package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
  150. package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
  151. package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
  152. package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
  153. package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
  154. package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
  155. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
  156. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
  157. package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
  158. package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
  159. package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
  160. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
  161. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
  162. package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
  163. package/gen/go/sohaapi/client.go +1 -1
  164. package/gen/go/sohaapi/client_test.go +2 -2
  165. package/gen/go/sohaapi/import_source_compat_test.go +12 -0
  166. package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
  167. package/gen/go/sohaapi/types.go +3760 -345
  168. package/gen/ts/sohaapi/index.ts +5573 -181
  169. package/go.mod +2 -2
  170. package/helmrelease/delivery.go +4 -1
  171. package/helmrelease/initialization.go +10 -0
  172. package/helmrelease/runtime/delivery.go +40 -10
  173. package/helmrelease/runtime/delivery_test.go +4 -0
  174. package/helmrelease/runtime/initialization.go +186 -0
  175. package/helmrelease/runtime/initialization_test.go +203 -0
  176. package/network/README.md +34 -0
  177. package/network/network-radius-accounting.schema.json +4 -1
  178. package/network/network-runtime-protocol.schema.json +2166 -317
  179. package/network/proxy-runtime.schema.json +482 -67
  180. package/openapi/network_control_test.go +64 -0
  181. package/openapi/soha-api.json +53524 -43459
  182. package/openapi/soha-api.yaml +26431 -20491
  183. package/openapi/spec_generated.go +3 -3
  184. package/openapi/spec_test.go +42 -0
  185. package/package.json +6 -3
  186. package/proxytransport/handler.go +271 -0
  187. package/proxytransport/probe.go +30 -0
  188. package/proxytransport/scope.go +89 -0
  189. package/proxytransport/transport_test.go +60 -0
  190. package/resource/models.go +2 -0
  191. package/resource/relations.go +225 -0
  192. package/resource/relations_test.go +47 -0
  193. package/resource/root_cr.go +38 -0
  194. package/resource/runtime/argocd_integration_test.go +1 -1
  195. package/resource/runtime/initialization.go +66 -0
  196. package/resource/runtime/initialization_test.go +80 -0
  197. package/resource/runtime/relations.go +161 -0
  198. package/resource/runtime/relations_test.go +100 -0
  199. package/resource/runtime/root_cr.go +79 -0
  200. package/resource/runtime/root_cr_test.go +118 -0
@@ -0,0 +1,100 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "testing"
12
+
13
+ cr "github.com/opensoha/soha-contracts/resource"
14
+ "k8s.io/client-go/kubernetes"
15
+ "k8s.io/client-go/rest"
16
+ )
17
+
18
+ func TestRelationMetadataProjectionAndPartialCoverage(t *testing.T) {
19
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20
+ w.Header().Set("Content-Type", "application/json")
21
+ if r.URL.Path == "/apis/widgets.test/v1" {
22
+ _, _ = w.Write([]byte(`{"resources":[{"name":"widgets","kind":"Widget","namespaced":true}]}`))
23
+ return
24
+ }
25
+ if strings.Contains(r.Header.Get("Accept"), "q=0.9") {
26
+ t.Error("full JSON fallback must not be negotiated")
27
+ }
28
+ if r.URL.Query().Get("limit") != "200" || r.URL.Query().Get("labelSelector") != "app=demo" {
29
+ t.Errorf("unbounded or different query: %s", r.URL.String())
30
+ }
31
+ _, _ = w.Write([]byte(`{"metadata":{"continue":"next"},"items":[{"metadata":{"name":"child","namespace":"team","uid":"child-uid","annotations":{"password":"synthetic-private"},"labels":{"token":"synthetic-private"},"ownerReferences":[{"apiVersion":"widgets.test/v1","kind":"Widget","name":"root","uid":"root-uid"}]},"spec":{"password":"synthetic-private"},"status":{"secret":"synthetic-private"}}]}`))
32
+ }))
33
+ defer server.Close()
34
+ client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ q := cr.RelationQuery{ID: "unknown", Source: cr.RelationSource{APIVersion: "widgets.test/v1", Resource: "widgets", Kind: "Widget", Namespace: "team"}, Selector: map[string]string{"app": "demo"}}
39
+ view, err := ReadRelationSnapshot(context.Background(), client.CoreV1().RESTClient(), "cluster", cr.RelationSnapshotRequest{Queries: []cr.RelationQuery{q}})
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if len(view.Sources) != 1 || view.Sources[0].Coverage.State != "partial" || !view.Sources[0].Coverage.Truncated || len(view.Sources[0].Objects) != 1 {
44
+ t.Fatalf("incorrect result: %+v", view)
45
+ }
46
+ data, _ := json.Marshal(view)
47
+ if strings.Contains(string(data), "synthetic-private") {
48
+ t.Fatal("configuration value leaked")
49
+ }
50
+ object := view.Sources[0].Objects[0]
51
+ if object.Resource.UID != "child-uid" || object.Owners[0].UID != "root-uid" {
52
+ t.Fatal("UID evidence lost")
53
+ }
54
+ q.Source.Kind = "DifferentKind"
55
+ view, err = ReadRelationSnapshot(context.Background(), client.CoreV1().RESTClient(), "cluster", cr.RelationSnapshotRequest{Queries: []cr.RelationQuery{q}})
56
+ if err != nil || len(view.Sources[0].Objects) != 0 || view.Sources[0].Coverage.State == "available" {
57
+ t.Fatal("unverified GVR/kind accepted")
58
+ }
59
+ }
60
+
61
+ func TestRelationMetadataNegotiationFailsClosed(t *testing.T) {
62
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63
+ w.Header().Set("Content-Type", "application/json")
64
+ if r.URL.Path == "/api/v1" {
65
+ _, _ = w.Write([]byte(`{"resources":[{"name":"secrets","kind":"Secret","namespaced":true}]}`))
66
+ return
67
+ }
68
+ if strings.Contains(r.Header.Get("Accept"), ",application/json") {
69
+ t.Error("full Secret requested")
70
+ }
71
+ w.WriteHeader(http.StatusNotAcceptable)
72
+ }))
73
+ defer server.Close()
74
+ client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ query := cr.RelationQuery{ID: "secrets", Source: cr.RelationSource{APIVersion: "v1", Resource: "secrets", Kind: "Secret", Namespace: "team"}}
79
+ snapshot, err := ReadRelationSnapshot(context.Background(), client.CoreV1().RESTClient(), "cluster", cr.RelationSnapshotRequest{Queries: []cr.RelationQuery{query}})
80
+ if err != nil || len(snapshot.Sources[0].Objects) != 0 || snapshot.Sources[0].Coverage.State == "available" {
81
+ t.Fatalf("metadata refusal not closed: %+v %v", snapshot, err)
82
+ }
83
+ }
84
+
85
+ func TestRelationReadErrorsRetainCoverageReasons(t *testing.T) {
86
+ for _, tc := range []struct {
87
+ err error
88
+ state, reason string
89
+ }{
90
+ {fmt.Errorf("request: %w", context.DeadlineExceeded), "error", "timeout"},
91
+ {context.Canceled, "error", "cancelled"},
92
+ {errRelationSourceNotServed, "not_installed", "source_not_served"},
93
+ {errors.New("opaque server response"), "error", "source_unavailable"},
94
+ } {
95
+ state, reason := relationReadError(tc.err)
96
+ if state != tc.state || reason != tc.reason {
97
+ t.Fatalf("%v became %s/%s", tc.err, state, reason)
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,79 @@
1
+ package runtime
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ contractresource "github.com/opensoha/soha-contracts/resource"
9
+ "strings"
10
+
11
+ "github.com/opensoha/soha-contracts/gen/go/sohaapi"
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
+ "k8s.io/client-go/util/retry"
16
+ )
17
+
18
+ const RootCRAgentCapability = "manifest.root-cr.v1"
19
+ const RootCRAgentProviderPrefix = "manifest_root_cr_v1."
20
+
21
+ // ApplyRootCR updates only spec after an explicit publication handoff. It reuses
22
+ // the workbench optimistic update semantics and preserves runtime metadata/status.
23
+ // A retry may acknowledge the same desired spec already applied to the same UID.
24
+ func ApplyRootCR(ctx context.Context, client dynamic.ResourceInterface, desired *unstructured.Unstructured, root sohaapi.ManifestRootResource, options metav1.PatchOptions) (*unstructured.Unstructured, error) {
25
+ if err := contractresource.ValidateRootCRDocument(desired.Object, root); err != nil {
26
+ return nil, err
27
+ }
28
+ if options.Force != nil && *options.Force {
29
+ return nil, fmt.Errorf("root CR cannot force field ownership")
30
+ }
31
+ var applied *unstructured.Unstructured
32
+ err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
33
+ live, err := client.Get(ctx, root.Name, metav1.GetOptions{})
34
+ if err != nil {
35
+ return err
36
+ }
37
+ if string(live.GetUID()) != root.UID {
38
+ return fmt.Errorf("root CR UID changed; create a new plan")
39
+ }
40
+ if err := validateRootCROwner(live); err != nil {
41
+ return err
42
+ }
43
+ sameSpec := rootSpecEqual(live.Object["spec"], desired.Object["spec"])
44
+ if live.GetGeneration() != root.Generation && !sameSpec {
45
+ return fmt.Errorf("root CR configuration changed; create a new plan")
46
+ }
47
+ if sameSpec {
48
+ applied = live
49
+ return nil
50
+ }
51
+ write := live.DeepCopy()
52
+ write.Object["spec"] = desired.DeepCopy().Object["spec"]
53
+ applied, err = client.Update(ctx, write, metav1.UpdateOptions{FieldManager: options.FieldManager, DryRun: options.DryRun, FieldValidation: options.FieldValidation})
54
+ return err
55
+ })
56
+ return applied, err
57
+ }
58
+
59
+ func validateRootCROwner(live *unstructured.Unstructured) error {
60
+ if err := ValidateDirectManifestOwner(live); err != nil {
61
+ return err
62
+ }
63
+ manager := strings.ToLower(live.GetLabels()["app.kubernetes.io/managed-by"])
64
+ if len(live.GetOwnerReferences()) > 0 || (manager != "" && manager != "soha") {
65
+ return fmt.Errorf("%w: root CR has an external owner", ErrResourceOwnership)
66
+ }
67
+ for key := range live.GetAnnotations() {
68
+ if strings.HasPrefix(key, "meta.helm.sh/") || strings.HasPrefix(key, "argocd.argoproj.io/") {
69
+ return fmt.Errorf("%w: root CR has an external publisher", ErrResourceOwnership)
70
+ }
71
+ }
72
+ return nil
73
+ }
74
+
75
+ func rootSpecEqual(left, right any) bool {
76
+ a, aErr := json.Marshal(left)
77
+ b, bErr := json.Marshal(right)
78
+ return aErr == nil && bErr == nil && bytes.Equal(a, b)
79
+ }
@@ -0,0 +1,118 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "reflect"
6
+ "testing"
7
+
8
+ "github.com/opensoha/soha-contracts/gen/go/sohaapi"
9
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
10
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12
+ "k8s.io/apimachinery/pkg/runtime"
13
+ "k8s.io/apimachinery/pkg/runtime/schema"
14
+ "k8s.io/client-go/dynamic/fake"
15
+ ktesting "k8s.io/client-go/testing"
16
+ )
17
+
18
+ func TestRootCRPublicationFences(t *testing.T) {
19
+ for _, mode := range []string{"apply", "dry-run", "retry", "replaced", "changed", "deleted", "deleting", "helm", "git", "controller", "status", "finalizer", "force", "conflict-replaced"} {
20
+ t.Run(mode, func(t *testing.T) {
21
+ root, live, desired, options := rootPublicationFixture(mode)
22
+ client := fake.NewSimpleDynamicClient(runtime.NewScheme(), live)
23
+ gvr := schema.GroupVersionResource{Group: "unknown.example", Version: "v1", Resource: "databases"}
24
+ resources := client.Resource(gvr).Namespace("team")
25
+ if mode == "deleted" {
26
+ if err := resources.Delete(context.Background(), root.Name, metav1.DeleteOptions{}); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ client.ClearActions()
30
+ }
31
+ writes := rootPublicationUpdates(t, client, root, live, mode, gvr)
32
+ _, err := ApplyRootCR(context.Background(), resources, desired, root, options)
33
+ valid := mode == "apply" || mode == "dry-run" || mode == "retry"
34
+ if valid && err != nil || !valid && err == nil {
35
+ t.Fatalf("error=%v valid=%v", err, valid)
36
+ }
37
+ expected := 0
38
+ if mode == "apply" || mode == "dry-run" || mode == "conflict-replaced" {
39
+ expected = 1
40
+ }
41
+ if *writes != expected {
42
+ t.Fatalf("writes=%d want=%d", *writes, expected)
43
+ }
44
+ for _, action := range client.Actions() {
45
+ if action.GetVerb() != "get" && action.GetVerb() != "update" {
46
+ t.Fatalf("unexpected mutation: %s", action.GetVerb())
47
+ }
48
+ }
49
+ })
50
+ }
51
+ }
52
+
53
+ func rootPublicationFixture(mode string) (sohaapi.ManifestRootResource, *unstructured.Unstructured, *unstructured.Unstructured, metav1.PatchOptions) {
54
+ root := sohaapi.ManifestRootResource{APIVersion: "unknown.example/v1", Kind: "Database", Namespace: "team", Name: "db", UID: "uid-1", Generation: 2, CrdName: "databases.unknown.example"}
55
+ live := &unstructured.Unstructured{Object: map[string]any{"apiVersion": root.APIVersion, "kind": root.Kind, "metadata": map[string]any{"namespace": root.Namespace, "name": root.Name, "uid": root.UID, "resourceVersion": "12", "generation": int64(2)}, "spec": map[string]any{"size": int64(1)}}}
56
+ desired := &unstructured.Unstructured{Object: map[string]any{"apiVersion": root.APIVersion, "kind": root.Kind, "metadata": map[string]any{"namespace": root.Namespace, "name": root.Name}, "spec": map[string]any{"size": int64(2)}}}
57
+ force := mode == "force"
58
+ options := metav1.PatchOptions{FieldManager: "opensoha-manifest/binding", Force: &force}
59
+ switch mode {
60
+ case "dry-run":
61
+ options.DryRun = []string{metav1.DryRunAll}
62
+ case "retry":
63
+ live.Object["spec"] = desired.Object["spec"]
64
+ live.SetGeneration(3)
65
+ case "replaced":
66
+ live.SetUID("uid-2")
67
+ case "changed":
68
+ live.SetGeneration(3)
69
+ case "deleting":
70
+ now := metav1.Now()
71
+ live.SetDeletionTimestamp(&now)
72
+ case "helm":
73
+ live.SetLabels(map[string]string{"app.kubernetes.io/managed-by": "Helm"})
74
+ case "git":
75
+ live.SetManagedFields([]metav1.ManagedFieldsEntry{{Manager: "argocd-controller"}})
76
+ case "controller":
77
+ yes := true
78
+ live.SetOwnerReferences([]metav1.OwnerReference{{UID: "parent", Controller: &yes}})
79
+ case "status":
80
+ desired.Object["status"] = map[string]any{"ready": true}
81
+ case "finalizer":
82
+ desired.SetFinalizers([]string{"operator/hold"})
83
+ }
84
+ return root, live, desired, options
85
+ }
86
+
87
+ func rootPublicationUpdates(t *testing.T, client *fake.FakeDynamicClient, root sohaapi.ManifestRootResource, live *unstructured.Unstructured, mode string, gvr schema.GroupVersionResource) *int {
88
+ t.Helper()
89
+ writes := new(int)
90
+ client.PrependReactor("update", "databases", func(action ktesting.Action) (bool, runtime.Object, error) {
91
+ *writes++
92
+ update, ok := action.(ktesting.UpdateActionImpl)
93
+ if !ok {
94
+ t.Fatalf("expected update action, got %T", action)
95
+ }
96
+ object, ok := update.GetObject().(*unstructured.Unstructured)
97
+ if !ok {
98
+ t.Fatalf("expected unstructured root, got %T", update.GetObject())
99
+ }
100
+ if mode == "dry-run" && len(update.GetUpdateOptions().DryRun) != 1 {
101
+ t.Fatal("preflight performed a real update")
102
+ }
103
+ body := object.Object
104
+ if string(object.GetUID()) != root.UID || object.GetResourceVersion() != "12" || !reflect.DeepEqual(object.Object["status"], live.Object["status"]) {
105
+ t.Fatalf("unsafe patch: %v", body)
106
+ }
107
+ if mode == "conflict-replaced" {
108
+ replaced := live.DeepCopy()
109
+ replaced.SetUID("uid-2")
110
+ if err := client.Tracker().Update(gvr, replaced, "team"); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ return true, nil, apierrors.NewConflict(gvr.GroupResource(), root.Name, nil)
114
+ }
115
+ return true, object, nil
116
+ })
117
+ return writes
118
+ }