@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.
- package/COMPATIBILITY.md +24 -0
- package/README.md +1 -1
- package/agent/environment/export.go +1 -0
- package/agent/environment/protocol_test.go +1 -1
- package/agent/guest/protocol_test.go +2 -2
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
- package/delivery/document.schema.json +127 -9
- package/examples/relations_compat_test.go +18 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
- package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
- package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
- package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
- package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
- package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
- package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
- package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
- package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/client_test.go +2 -2
- package/gen/go/sohaapi/import_source_compat_test.go +12 -0
- package/gen/go/sohaapi/types.go +945 -159
- package/gen/ts/sohaapi/index.ts +746 -4
- package/go.mod +2 -2
- package/helmrelease/delivery.go +4 -1
- package/helmrelease/initialization.go +10 -0
- package/helmrelease/runtime/delivery.go +40 -10
- package/helmrelease/runtime/delivery_test.go +4 -0
- package/helmrelease/runtime/initialization.go +186 -0
- package/helmrelease/runtime/initialization_test.go +203 -0
- package/openapi/soha-api.json +2195 -55
- package/openapi/soha-api.yaml +349 -9
- package/openapi/spec_generated.go +3 -3
- package/openapi/spec_test.go +42 -0
- package/package.json +1 -1
- package/resource/models.go +2 -0
- package/resource/relations.go +225 -0
- package/resource/relations_test.go +47 -0
- package/resource/root_cr.go +38 -0
- package/resource/runtime/argocd_integration_test.go +1 -1
- package/resource/runtime/initialization.go +66 -0
- package/resource/runtime/initialization_test.go +80 -0
- package/resource/runtime/relations.go +161 -0
- package/resource/runtime/relations_test.go +100 -0
- package/resource/runtime/root_cr.go +79 -0
- package/resource/runtime/root_cr_test.go +118 -0
|
@@ -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
|
+
}
|