@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
package/go.mod CHANGED
@@ -1,6 +1,6 @@
1
1
  module github.com/opensoha/soha-contracts
2
2
 
3
- go 1.27.2
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
@@ -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 observeHelmDelivery(ctx, cfg, input.Snapshot, current)
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 observeHelmDelivery(ctx, cfg, input.Snapshot, current)
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
- original := cfg
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
+ }