@opensoha/contracts 0.1.24 → 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 (137) 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/auth/catalog_generated.go +2 -2
  7. package/auth/gateway_permissions_test.go +48 -0
  8. package/auth/permission-catalog.json +300 -1
  9. package/compat/consumer-revisions.json +1 -1
  10. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  11. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  12. package/delivery/document.schema.json +127 -9
  13. package/examples/relations_compat_test.go +18 -0
  14. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
  15. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
  16. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
  17. package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
  18. package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
  19. package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
  20. package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
  21. package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
  22. package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
  23. package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
  24. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
  25. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
  26. package/fixtures/openapi/KubernetesAgentActionCheck/invalid/unproven-state.json +6 -0
  27. package/fixtures/openapi/KubernetesAgentActionCheck/valid/configured-denied.json +9 -0
  28. package/fixtures/openapi/KubernetesAgentActionProfile/invalid/wildcard.json +1 -0
  29. package/fixtures/openapi/KubernetesAgentActionProfile/valid/workbench.json +1 -0
  30. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/action-profile.json +3 -0
  31. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/action-profile.json +5 -0
  32. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
  33. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
  34. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
  35. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/condition-status-boolean.json +17 -0
  36. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/taint-effect-missing.json +16 -0
  37. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/legacy-fields-absent.json +11 -0
  38. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/no-taints.json +13 -0
  39. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/ready-cordoned-tainted.json +28 -0
  40. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/unknown-fields-null.json +13 -0
  41. package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
  42. package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
  43. package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
  44. package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
  45. package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
  46. package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
  47. package/fixtures/openapi/KubernetesTopologyCoverage/invalid/empty-as-success.json +5 -0
  48. package/fixtures/openapi/KubernetesTopologyCoverage/valid/not-installed.json +6 -0
  49. package/fixtures/openapi/KubernetesTopologyGraphEdge/invalid/negative-weight.json +8 -0
  50. package/fixtures/openapi/KubernetesTopologyGraphEdge/valid/zero-weight.json +20 -0
  51. package/fixtures/openapi/KubernetesTopologyGraphNode/invalid/fake-flow-kind.json +42 -0
  52. package/fixtures/openapi/KubernetesTopologyGraphNode/valid/rule.json +42 -0
  53. package/fixtures/openapi/KubernetesTopologySnapshot/invalid/raw-source-field.json +33 -0
  54. package/fixtures/openapi/KubernetesTopologySnapshot/valid/projected-source.json +32 -0
  55. package/fixtures/openapi/KubernetesTopologySnapshotRequest/invalid/arbitrary-gvr.json +8 -0
  56. package/fixtures/openapi/KubernetesTopologySnapshotRequest/valid/bounded-sources.json +11 -0
  57. package/fixtures/openapi/KubernetesTopologySourceQuery/invalid/cluster-namespace.json +4 -0
  58. package/fixtures/openapi/KubernetesTopologySourceQuery/valid/all-namespaces.json +4 -0
  59. package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
  60. package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
  61. package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
  62. package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
  63. package/fixtures/openapi/ResourceMonitoringPanel/invalid/unknown-quality.json +7 -0
  64. package/fixtures/openapi/ResourceMonitoringPanel/valid/not-collected.json +7 -0
  65. package/fixtures/openapi/ResourceMonitoringPoint/invalid/string-zero.json +4 -0
  66. package/fixtures/openapi/ResourceMonitoringPoint/valid/missing.json +4 -0
  67. package/fixtures/openapi/ResourceMonitoringPoint/valid/zero.json +4 -0
  68. package/fixtures/openapi/ResourceMonitoringView/invalid/raw-query.json +12 -0
  69. package/fixtures/openapi/ResourceMonitoringView/valid/unconfigured.json +12 -0
  70. package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
  71. package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
  72. package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
  73. package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
  74. package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
  75. package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
  76. package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
  77. package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
  78. package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
  79. package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
  80. package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
  81. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
  82. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
  83. package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
  84. package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
  85. package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
  86. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
  87. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
  88. package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
  89. package/gen/go/sohaapi/client.go +1 -1
  90. package/gen/go/sohaapi/client_test.go +2 -2
  91. package/gen/go/sohaapi/import_source_compat_test.go +12 -0
  92. package/gen/go/sohaapi/kubernetes_monitoring_test.go +67 -0
  93. package/gen/go/sohaapi/types.go +2210 -387
  94. package/gen/ts/sohaapi/index.ts +1862 -5
  95. package/go.mod +2 -2
  96. package/helmrelease/delivery.go +4 -1
  97. package/helmrelease/initialization.go +10 -0
  98. package/helmrelease/runtime/delivery.go +40 -10
  99. package/helmrelease/runtime/delivery_test.go +4 -0
  100. package/helmrelease/runtime/initialization.go +186 -0
  101. package/helmrelease/runtime/initialization_test.go +203 -0
  102. package/openapi/soha-api.json +5514 -1179
  103. package/openapi/soha-api.yaml +1080 -11
  104. package/openapi/spec_generated.go +3 -3
  105. package/openapi/spec_test.go +76 -0
  106. package/package.json +1 -1
  107. package/resource/agent_access.go +12 -0
  108. package/resource/agent_profiles.go +62 -0
  109. package/resource/agent_profiles_test.go +36 -0
  110. package/resource/kubernetes_kinds.go +73 -0
  111. package/resource/kubernetes_kinds_test.go +38 -0
  112. package/resource/models.go +25 -14
  113. package/resource/network_topology.go +166 -0
  114. package/resource/network_topology_projection.go +208 -0
  115. package/resource/network_topology_projection_test.go +53 -0
  116. package/resource/network_topology_test.go +51 -0
  117. package/resource/network_topology_validation.go +29 -0
  118. package/resource/node_drain.go +34 -0
  119. package/resource/relations.go +225 -0
  120. package/resource/relations_test.go +47 -0
  121. package/resource/resource_monitoring.go +99 -0
  122. package/resource/root_cr.go +38 -0
  123. package/resource/runtime/argocd_integration_test.go +1 -1
  124. package/resource/runtime/initialization.go +66 -0
  125. package/resource/runtime/initialization_test.go +80 -0
  126. package/resource/runtime/network_topology.go +232 -0
  127. package/resource/runtime/network_topology_test.go +62 -0
  128. package/resource/runtime/node_drain.go +124 -0
  129. package/resource/runtime/node_drain_test.go +95 -0
  130. package/resource/runtime/relations.go +161 -0
  131. package/resource/runtime/relations_test.go +100 -0
  132. package/resource/runtime/resource_edit.go +128 -0
  133. package/resource/runtime/resource_edit_test.go +183 -0
  134. package/resource/runtime/resource_monitoring.go +243 -0
  135. package/resource/runtime/resource_monitoring_test.go +88 -0
  136. package/resource/runtime/root_cr.go +79 -0
  137. package/resource/runtime/root_cr_test.go +118 -0
@@ -0,0 +1,124 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "sync"
7
+
8
+ "github.com/opensoha/soha-contracts/resource"
9
+ policyv1 "k8s.io/api/policy/v1"
10
+ policyv1beta1 "k8s.io/api/policy/v1beta1"
11
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
12
+ "k8s.io/client-go/kubernetes"
13
+ policyclient "k8s.io/client-go/kubernetes/typed/policy/v1"
14
+ policybetaclient "k8s.io/client-go/kubernetes/typed/policy/v1beta1"
15
+ )
16
+
17
+ // DrainClient observes eviction failures while leaving kubectl drain semantics intact.
18
+ type DrainClient struct {
19
+ kubernetes.Interface
20
+ failures sync.Map
21
+ }
22
+
23
+ func NewDrainClient(client kubernetes.Interface) *DrainClient {
24
+ return &DrainClient{Interface: client}
25
+ }
26
+
27
+ func (c *DrainClient) PolicyV1() policyclient.PolicyV1Interface {
28
+ return drainPolicy{PolicyV1Interface: c.Interface.PolicyV1(), observer: c}
29
+ }
30
+
31
+ func (c *DrainClient) PolicyV1beta1() policybetaclient.PolicyV1beta1Interface {
32
+ return drainBetaPolicy{PolicyV1beta1Interface: c.Interface.PolicyV1beta1(), observer: c}
33
+ }
34
+
35
+ func (c *DrainClient) observe(namespace, name string, err error) error {
36
+ key := namespace + "/" + name
37
+ if err == nil || apierrors.IsNotFound(err) {
38
+ c.failures.Delete(key)
39
+ } else {
40
+ c.failures.Store(key, err)
41
+ }
42
+ return err
43
+ }
44
+
45
+ func (c *DrainClient) Failure(ctx context.Context, err error) error {
46
+ if err == nil {
47
+ return nil
48
+ }
49
+ code := "node_drain_failed"
50
+ var denied error
51
+ c.failures.Range(func(_, value any) bool {
52
+ failure, ok := value.(error)
53
+ if !ok {
54
+ return true
55
+ }
56
+ switch {
57
+ case apierrors.IsForbidden(failure) || apierrors.IsUnauthorized(failure):
58
+ denied = failure
59
+ case apierrors.HasStatusCause(failure, policyv1.DisruptionBudgetCause):
60
+ code = "node_drain_pdb_blocked"
61
+ case apierrors.IsTooManyRequests(failure) && code != "node_drain_pdb_blocked":
62
+ code = "node_drain_eviction_blocked"
63
+ }
64
+ return true
65
+ })
66
+ if denied != nil {
67
+ return denied
68
+ }
69
+ if errors.Is(ctx.Err(), context.Canceled) {
70
+ code = "node_drain_canceled"
71
+ } else if code == "node_drain_failed" && errors.Is(ctx.Err(), context.DeadlineExceeded) {
72
+ code = "node_drain_timeout"
73
+ }
74
+ return &resource.NodeDrainError{Code: code, Cause: err}
75
+ }
76
+
77
+ type drainPolicy struct {
78
+ policyclient.PolicyV1Interface
79
+ observer *DrainClient
80
+ }
81
+
82
+ func (c *DrainClient) PodSelectionFailure(ctx context.Context, failures []error) error {
83
+ for _, err := range failures {
84
+ if apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) || apierrors.IsNotFound(err) {
85
+ return err
86
+ }
87
+ }
88
+ err := errors.Join(failures...)
89
+ if ctx.Err() != nil {
90
+ return c.Failure(ctx, err)
91
+ }
92
+ return &resource.NodeDrainError{Code: "node_drain_pod_selection", Cause: err}
93
+ }
94
+
95
+ func (c drainPolicy) Evictions(namespace string) policyclient.EvictionInterface {
96
+ return drainEvictions{EvictionInterface: c.PolicyV1Interface.Evictions(namespace), observer: c.observer}
97
+ }
98
+
99
+ type drainEvictions struct {
100
+ policyclient.EvictionInterface
101
+ observer *DrainClient
102
+ }
103
+
104
+ func (c drainEvictions) Evict(ctx context.Context, eviction *policyv1.Eviction) error {
105
+ return c.observer.observe(eviction.Namespace, eviction.Name, c.EvictionInterface.Evict(ctx, eviction))
106
+ }
107
+
108
+ type drainBetaPolicy struct {
109
+ policybetaclient.PolicyV1beta1Interface
110
+ observer *DrainClient
111
+ }
112
+
113
+ func (c drainBetaPolicy) Evictions(namespace string) policybetaclient.EvictionInterface {
114
+ return drainBetaEvictions{EvictionInterface: c.PolicyV1beta1Interface.Evictions(namespace), observer: c.observer}
115
+ }
116
+
117
+ type drainBetaEvictions struct {
118
+ policybetaclient.EvictionInterface
119
+ observer *DrainClient
120
+ }
121
+
122
+ func (c drainBetaEvictions) Evict(ctx context.Context, eviction *policyv1beta1.Eviction) error {
123
+ return c.observer.observe(eviction.Namespace, eviction.Name, c.EvictionInterface.Evict(ctx, eviction))
124
+ }
@@ -0,0 +1,95 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/opensoha/soha-contracts/resource"
11
+ policyv1 "k8s.io/api/policy/v1"
12
+ policyv1beta1 "k8s.io/api/policy/v1beta1"
13
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
14
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15
+ "k8s.io/apimachinery/pkg/runtime"
16
+ "k8s.io/apimachinery/pkg/runtime/schema"
17
+ "k8s.io/client-go/kubernetes/fake"
18
+ ktesting "k8s.io/client-go/testing"
19
+ )
20
+
21
+ func TestDrainFeedbackClassifiesTypedEvictionCausesAndClearsRecoveredBlockers(t *testing.T) {
22
+ pdb := &apierrors.StatusError{ErrStatus: metav1.Status{Code: 429, Reason: metav1.StatusReasonTooManyRequests, Message: "private provider message", Details: &metav1.StatusDetails{Causes: []metav1.StatusCause{{Type: policyv1.DisruptionBudgetCause}}}}}
23
+ for _, beta := range []bool{false, true} {
24
+ t.Run(map[bool]string{false: "v1", true: "v1beta1"}[beta], func(t *testing.T) {
25
+ client := fake.NewClientset()
26
+ var evictionError error = pdb
27
+ client.PrependReactor("create", "pods", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, evictionError })
28
+ observed := NewDrainClient(client)
29
+ evict := func() error {
30
+ meta := metav1.ObjectMeta{Name: "pod", Namespace: "team"}
31
+ if beta {
32
+ return observed.PolicyV1beta1().Evictions("team").Evict(context.Background(), &policyv1beta1.Eviction{ObjectMeta: meta})
33
+ }
34
+ return observed.PolicyV1().Evictions("team").Evict(context.Background(), &policyv1.Eviction{ObjectMeta: meta})
35
+ }
36
+ _ = evict()
37
+ assertDrainCode(t, observed.Failure(context.Background(), errors.New("private token")), "node_drain_pdb_blocked")
38
+ evictionError = apierrors.NewTooManyRequests("private throttle", 1)
39
+ _ = evict()
40
+ assertDrainCode(t, observed.Failure(context.Background(), errors.New("private")), "node_drain_eviction_blocked")
41
+ evictionError = nil
42
+ _ = evict()
43
+ ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
44
+ defer cancel()
45
+ assertDrainCode(t, observed.Failure(ctx, errors.New("private")), "node_drain_timeout")
46
+ evictionError = apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "pod", errors.New("private"))
47
+ _ = evict()
48
+ if !apierrors.IsForbidden(observed.Failure(context.Background(), errors.New("private"))) {
49
+ t.Fatal("eviction RBAC denial must retain forbidden semantics")
50
+ }
51
+ })
52
+ }
53
+ }
54
+
55
+ func TestDrainFeedbackKeepsCancellationAndSuccessDistinct(t *testing.T) {
56
+ client := NewDrainClient(fake.NewClientset())
57
+ ctx, cancel := context.WithCancel(context.Background())
58
+ cancel()
59
+ assertDrainCode(t, client.Failure(ctx, ctx.Err()), "node_drain_canceled")
60
+ if err := client.Failure(ctx, nil); err != nil {
61
+ t.Fatalf("success changed: %v", err)
62
+ }
63
+ if resource.IsNodeDrainErrorCode("provider_arbitrary_error") {
64
+ t.Fatal("untrusted error code accepted")
65
+ }
66
+ }
67
+
68
+ func TestDrainFeedbackDoesNotHidePermissionDenialBehindPDB(t *testing.T) {
69
+ client := NewDrainClient(fake.NewClientset())
70
+ client.failures.Store("invalid", struct{}{})
71
+ assertDrainCode(t, client.Failure(context.Background(), errors.New("private")), "node_drain_failed")
72
+ blocked := &apierrors.StatusError{ErrStatus: metav1.Status{Code: 429, Details: &metav1.StatusDetails{Causes: []metav1.StatusCause{{Type: policyv1.DisruptionBudgetCause}}}}}
73
+ _ = client.observe("team", "blocked", blocked)
74
+ canceled, cancel := context.WithCancel(context.Background())
75
+ cancel()
76
+ assertDrainCode(t, client.Failure(canceled, canceled.Err()), "node_drain_canceled")
77
+ _ = client.observe("team", "denied", apierrors.NewForbidden(schema.GroupResource{Resource: "pods/eviction"}, "denied", errors.New("private")))
78
+ if err := client.Failure(canceled, canceled.Err()); !apierrors.IsForbidden(err) {
79
+ t.Fatalf("permission denial hidden: %v", err)
80
+ }
81
+ if err := client.PodSelectionFailure(context.Background(), []error{apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "", errors.New("private"))}); !apierrors.IsForbidden(err) {
82
+ t.Fatalf("pod list permission hidden: %v", err)
83
+ }
84
+ }
85
+
86
+ func assertDrainCode(t *testing.T, err error, want string) {
87
+ t.Helper()
88
+ var failure *resource.NodeDrainError
89
+ if !errors.As(err, &failure) || failure.Code != want {
90
+ t.Fatalf("got %v; want %s", err, want)
91
+ }
92
+ if strings.Contains(err.Error(), "private") {
93
+ t.Fatal("provider text leaked")
94
+ }
95
+ }
@@ -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,128 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+
10
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
11
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13
+ "k8s.io/apimachinery/pkg/types"
14
+ "k8s.io/apimachinery/pkg/util/sets"
15
+ "k8s.io/client-go/dynamic"
16
+ "k8s.io/client-go/util/csaupgrade"
17
+ "k8s.io/utils/ptr"
18
+ )
19
+
20
+ // ApplyResourceEdit preserves SSA conflicts while migrating this editor's own Create/Update fields.
21
+ // Dry-run migration validates a complete manifest with Update; it never changes live ownership.
22
+ func ApplyResourceEdit(ctx context.Context, resource dynamic.ResourceInterface, current, desired *unstructured.Unstructured, manager string, dryRun bool) (*unstructured.Unstructured, error) {
23
+ patch, err := json.Marshal(desired.Object)
24
+ if err != nil {
25
+ return nil, err
26
+ }
27
+ options := metav1.PatchOptions{FieldManager: manager, Force: ptr.To(false)}
28
+ if dryRun {
29
+ options.DryRun = []string{metav1.DryRunAll}
30
+ }
31
+ migration, err := resourceEditMigrationPatch(current, manager)
32
+ if err != nil {
33
+ return nil, err
34
+ }
35
+ if len(migration) == 0 {
36
+ return resource.Patch(ctx, desired.GetName(), types.ApplyPatchType, patch, options)
37
+ }
38
+ if dryRun {
39
+ // Probe foreign ownership first; an initial list merge can also fail validation
40
+ // until this creator's Update ownership is migrated.
41
+ probed, probeErr := resource.Patch(ctx, desired.GetName(), types.ApplyPatchType, patch, options)
42
+ if probeErr == nil {
43
+ return probed, nil
44
+ }
45
+ if !resourceEditPortMergeInvalid(current, probeErr) && !onlyResourceEditConflicts(current, probeErr, manager) {
46
+ return nil, probeErr
47
+ }
48
+ return resource.Update(ctx, desired, metav1.UpdateOptions{FieldManager: manager, DryRun: []string{metav1.DryRunAll}})
49
+ }
50
+ migrated, err := resource.Patch(ctx, desired.GetName(), types.JSONPatchType, migration, metav1.PatchOptions{})
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ desired = desired.DeepCopy()
55
+ desired.SetResourceVersion(migrated.GetResourceVersion())
56
+ patch, err = json.Marshal(desired.Object)
57
+ if err != nil {
58
+ return nil, err
59
+ }
60
+ return resource.Patch(ctx, desired.GetName(), types.ApplyPatchType, patch, options)
61
+ }
62
+
63
+ func onlyResourceEditConflicts(current *unstructured.Unstructured, conflict error, manager string) bool {
64
+ var status apierrors.APIStatus
65
+ if !apierrors.IsConflict(conflict) || !errors.As(conflict, &status) {
66
+ return false
67
+ }
68
+ details := status.Status().Details
69
+ if details == nil || len(details.Causes) == 0 {
70
+ return false
71
+ }
72
+ prefix := fmt.Sprintf("conflict with %q using %s", manager, current.GetAPIVersion())
73
+ for _, cause := range details.Causes {
74
+ if cause.Type != metav1.CauseTypeFieldManagerConflict || cause.Field == "" || (cause.Message != prefix && !strings.HasPrefix(cause.Message, prefix+" at ")) {
75
+ return false
76
+ }
77
+ }
78
+ return true
79
+ }
80
+
81
+ func resourceEditMigrationPatch(current *unstructured.Unstructured, manager string) ([]byte, error) {
82
+ if manager == "" || current.GetUID() == "" || current.GetResourceVersion() == "" {
83
+ return nil, nil
84
+ }
85
+ updates := 0
86
+ for _, entry := range current.GetManagedFields() {
87
+ if entry.Manager != manager || entry.Subresource != "" {
88
+ continue
89
+ }
90
+ // client-go's upgrader discards other Update versions; fail closed instead.
91
+ if entry.APIVersion != current.GetAPIVersion() {
92
+ return nil, nil
93
+ }
94
+ if entry.Operation == metav1.ManagedFieldsOperationUpdate {
95
+ updates++
96
+ }
97
+ }
98
+ if updates != 1 {
99
+ return nil, nil
100
+ }
101
+ patch, err := csaupgrade.UpgradeManagedFieldsPatch(current, sets.New(manager), manager)
102
+ if err != nil || len(patch) == 0 {
103
+ return patch, err
104
+ }
105
+ var operations []map[string]any
106
+ if err := json.Unmarshal(patch, &operations); err != nil {
107
+ return nil, err
108
+ }
109
+ operations = append([]map[string]any{{"op": "test", "path": "/metadata/uid", "value": string(current.GetUID())}}, operations...)
110
+ return json.Marshal(operations)
111
+ }
112
+
113
+ func resourceEditPortMergeInvalid(current *unstructured.Unstructured, err error) bool {
114
+ var status apierrors.APIStatus
115
+ if current.GetKind() != "Service" || !apierrors.IsInvalid(err) || !errors.As(err, &status) {
116
+ return false
117
+ }
118
+ details := status.Status().Details
119
+ if details == nil || len(details.Causes) == 0 {
120
+ return false
121
+ }
122
+ for _, cause := range details.Causes {
123
+ if cause.Type != metav1.CauseTypeFieldValueRequired || !strings.HasPrefix(cause.Field, "spec.ports[") || !strings.HasSuffix(cause.Field, "].name") {
124
+ return false
125
+ }
126
+ }
127
+ return true
128
+ }