@opensoha/contracts 0.1.25 → 0.1.26

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 (81) hide show
  1. package/COMPATIBILITY.md +24 -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/compat/json-schema-ecosystem-0.1.0.json +1 -1
  7. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  8. package/delivery/document.schema.json +127 -9
  9. package/examples/relations_compat_test.go +18 -0
  10. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
  11. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
  12. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
  13. package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
  14. package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
  15. package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
  16. package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
  17. package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
  18. package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
  19. package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
  20. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
  21. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
  22. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
  23. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
  24. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
  25. package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
  26. package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
  27. package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
  28. package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
  29. package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
  30. package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
  31. package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
  32. package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
  33. package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
  34. package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
  35. package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
  36. package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
  37. package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
  38. package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
  39. package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
  40. package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
  41. package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
  42. package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
  43. package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
  44. package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
  45. package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
  46. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
  47. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
  48. package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
  49. package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
  50. package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
  51. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
  52. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
  53. package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
  54. package/gen/go/sohaapi/client.go +1 -1
  55. package/gen/go/sohaapi/client_test.go +2 -2
  56. package/gen/go/sohaapi/import_source_compat_test.go +12 -0
  57. package/gen/go/sohaapi/types.go +945 -159
  58. package/gen/ts/sohaapi/index.ts +746 -4
  59. package/go.mod +2 -2
  60. package/helmrelease/delivery.go +4 -1
  61. package/helmrelease/initialization.go +10 -0
  62. package/helmrelease/runtime/delivery.go +40 -10
  63. package/helmrelease/runtime/delivery_test.go +4 -0
  64. package/helmrelease/runtime/initialization.go +186 -0
  65. package/helmrelease/runtime/initialization_test.go +203 -0
  66. package/openapi/soha-api.json +2195 -55
  67. package/openapi/soha-api.yaml +349 -9
  68. package/openapi/spec_generated.go +3 -3
  69. package/openapi/spec_test.go +42 -0
  70. package/package.json +1 -1
  71. package/resource/models.go +2 -0
  72. package/resource/relations.go +225 -0
  73. package/resource/relations_test.go +47 -0
  74. package/resource/root_cr.go +38 -0
  75. package/resource/runtime/argocd_integration_test.go +1 -1
  76. package/resource/runtime/initialization.go +66 -0
  77. package/resource/runtime/initialization_test.go +80 -0
  78. package/resource/runtime/relations.go +161 -0
  79. package/resource/runtime/relations_test.go +100 -0
  80. package/resource/runtime/root_cr.go +79 -0
  81. package/resource/runtime/root_cr_test.go +118 -0
@@ -0,0 +1,161 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "sort"
10
+ "strings"
11
+ "time"
12
+
13
+ resource "github.com/opensoha/soha-contracts/resource"
14
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15
+ "k8s.io/client-go/rest"
16
+ )
17
+
18
+ // ReadRelationSnapshot reads metadata only. Its caller must authorize every
19
+ // collection and namespace; Kubernetes RBAC still applies to the actual reads.
20
+ func ReadRelationSnapshot(ctx context.Context, client rest.Interface, clusterID string, input resource.RelationSnapshotRequest) (resource.RelationSnapshot, error) {
21
+ if err := resource.ValidateRelationSnapshotRequest(input); err != nil {
22
+ return resource.RelationSnapshot{}, err
23
+ }
24
+ if client == nil {
25
+ return resource.RelationSnapshot{}, fmt.Errorf("resource metadata client unavailable")
26
+ }
27
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
28
+ defer cancel()
29
+ out := resource.RelationSnapshot{Version: resource.RelationSnapshotFeature, Sources: []resource.RelationSourceResult{}}
30
+ budget := &topologyReadBudget{remaining: 8 << 20}
31
+ for _, query := range input.Queries {
32
+ out.Sources = append(out.Sources, readRelationSource(ctx, client, clusterID, query, budget))
33
+ }
34
+ return out, nil
35
+ }
36
+
37
+ func readRelationSource(ctx context.Context, client rest.Interface, clusterID string, q resource.RelationQuery, budget *topologyReadBudget) resource.RelationSourceResult {
38
+ out := resource.RelationSourceResult{Coverage: resource.RelationCoverage{QueryID: q.ID, State: "available"}, Objects: []resource.RelationObject{}}
39
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
40
+ defer cancel()
41
+ group, version := relationGroupVersion(q.Source.APIVersion)
42
+ base := topologyAPIBase(group, version)
43
+ data, err := boundedTopologyGET(ctx, client, base, false, budget)
44
+ if err == nil {
45
+ err = validateRelationDiscovery(data, q.Source)
46
+ }
47
+ if err != nil {
48
+ out.Coverage.State, out.Coverage.ReasonCode = relationReadError(err)
49
+ return out
50
+ }
51
+ path := base + "/namespaces/" + q.Source.Namespace + "/" + q.Source.Resource
52
+ if q.Name != "" {
53
+ path += "/" + q.Name
54
+ }
55
+ request := client.Get().AbsPath(path)
56
+ if q.Name == "" {
57
+ request = request.Param("limit", "200").SetHeader("Accept", "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1")
58
+ } else {
59
+ request = request.SetHeader("Accept", "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1")
60
+ }
61
+ if len(q.Selector) > 0 {
62
+ terms := []string{}
63
+ for k, v := range q.Selector {
64
+ terms = append(terms, k+"="+v)
65
+ }
66
+ sort.Strings(terms)
67
+ request = request.Param("labelSelector", strings.Join(terms, ","))
68
+ }
69
+ stream, err := request.Stream(ctx)
70
+ if err != nil {
71
+ out.Coverage.State, out.Coverage.ReasonCode = relationReadError(err)
72
+ return out
73
+ }
74
+ defer func() { _ = stream.Close() }()
75
+ data, err = io.ReadAll(io.LimitReader(&topologyBudgetReader{reader: stream, budget: budget}, (512<<10)+1))
76
+ if err != nil || len(data) > 512<<10 {
77
+ out.Coverage.State = "partial"
78
+ out.Coverage.ReasonCode = "byte_budget"
79
+ out.Coverage.Truncated = true
80
+ return out
81
+ }
82
+ return relationMetadataResult(out, clusterID, q, data)
83
+ }
84
+
85
+ func relationMetadataResult(out resource.RelationSourceResult, clusterID string, q resource.RelationQuery, data []byte) resource.RelationSourceResult {
86
+ var objects []metav1.PartialObjectMetadata
87
+ if q.Name != "" {
88
+ var object metav1.PartialObjectMetadata
89
+ if json.Unmarshal(data, &object) != nil {
90
+ out.Coverage.State = "error"
91
+ out.Coverage.ReasonCode = "invalid_response"
92
+ return out
93
+ }
94
+ objects = append(objects, object)
95
+ } else {
96
+ var list metav1.PartialObjectMetadataList
97
+ if json.Unmarshal(data, &list) != nil {
98
+ out.Coverage.State = "error"
99
+ out.Coverage.ReasonCode = "invalid_response"
100
+ return out
101
+ }
102
+ objects = list.Items
103
+ if list.Continue != "" || len(objects) > resource.RelationMaxItems {
104
+ out.Coverage.State = "partial"
105
+ out.Coverage.ReasonCode = "item_budget"
106
+ out.Coverage.Truncated = true
107
+ }
108
+ }
109
+ for i, object := range objects {
110
+ if i >= resource.RelationMaxItems {
111
+ break
112
+ }
113
+ if object.Name == "" || object.UID == "" || object.Namespace != q.Source.Namespace || (q.Name != "" && q.Name != object.Name) || len(object.OwnerReferences) > 32 {
114
+ out.Coverage.State = "partial"
115
+ out.Coverage.ReasonCode = "invalid_identity"
116
+ continue
117
+ }
118
+ item := resource.RelationObject{Resource: resource.ResourceRef{ClusterID: clusterID, APIVersion: q.Source.APIVersion, Kind: q.Source.Kind, Namespace: object.Namespace, Name: object.Name, UID: string(object.UID), ResourceVersion: object.ResourceVersion, ScopeMode: resource.ResourceScopeModeNamespace}}
119
+ for _, owner := range object.OwnerReferences {
120
+ item.Owners = append(item.Owners, resource.RelationOwner{APIVersion: owner.APIVersion, Kind: owner.Kind, Name: owner.Name, UID: string(owner.UID), Controller: owner.Controller != nil && *owner.Controller})
121
+ }
122
+ out.Objects = append(out.Objects, item)
123
+ }
124
+ return out
125
+ }
126
+
127
+ func relationGroupVersion(apiVersion string) (string, string) {
128
+ parts := strings.SplitN(apiVersion, "/", 2)
129
+ if len(parts) == 1 {
130
+ return "", parts[0]
131
+ }
132
+ return parts[0], parts[1]
133
+ }
134
+
135
+ func validateRelationDiscovery(data []byte, source resource.RelationSource) error {
136
+ var list metav1.APIResourceList
137
+ if json.Unmarshal(data, &list) != nil {
138
+ return fmt.Errorf("invalid discovery response")
139
+ }
140
+ for _, item := range list.APIResources {
141
+ if item.Name == source.Resource && item.Kind == source.Kind && item.Namespaced {
142
+ return nil
143
+ }
144
+ }
145
+ return errRelationSourceNotServed
146
+ }
147
+
148
+ var errRelationSourceNotServed = errors.New("resource definition not served")
149
+
150
+ func relationReadError(err error) (string, string) {
151
+ if errors.Is(err, context.DeadlineExceeded) {
152
+ return "error", "timeout"
153
+ }
154
+ if errors.Is(err, context.Canceled) {
155
+ return "error", "cancelled"
156
+ }
157
+ if errors.Is(err, errRelationSourceNotServed) {
158
+ return "not_installed", "source_not_served"
159
+ }
160
+ return topologyReadError(err)
161
+ }
@@ -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
+ }