@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.
- 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/auth/catalog_generated.go +2 -2
- package/auth/gateway_permissions_test.go +48 -0
- package/auth/permission-catalog.json +300 -1
- package/compat/consumer-revisions.json +1 -1
- 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/KubernetesAgentActionCheck/invalid/unproven-state.json +6 -0
- package/fixtures/openapi/KubernetesAgentActionCheck/valid/configured-denied.json +9 -0
- package/fixtures/openapi/KubernetesAgentActionProfile/invalid/wildcard.json +1 -0
- package/fixtures/openapi/KubernetesAgentActionProfile/valid/workbench.json +1 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/action-profile.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/action-profile.json +5 -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/KubernetesNodeListEnvelope/invalid/condition-status-boolean.json +17 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/taint-effect-missing.json +16 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/legacy-fields-absent.json +11 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/no-taints.json +13 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/ready-cordoned-tainted.json +28 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/unknown-fields-null.json +13 -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/KubernetesTopologyCoverage/invalid/empty-as-success.json +5 -0
- package/fixtures/openapi/KubernetesTopologyCoverage/valid/not-installed.json +6 -0
- package/fixtures/openapi/KubernetesTopologyGraphEdge/invalid/negative-weight.json +8 -0
- package/fixtures/openapi/KubernetesTopologyGraphEdge/valid/zero-weight.json +20 -0
- package/fixtures/openapi/KubernetesTopologyGraphNode/invalid/fake-flow-kind.json +42 -0
- package/fixtures/openapi/KubernetesTopologyGraphNode/valid/rule.json +42 -0
- package/fixtures/openapi/KubernetesTopologySnapshot/invalid/raw-source-field.json +33 -0
- package/fixtures/openapi/KubernetesTopologySnapshot/valid/projected-source.json +32 -0
- package/fixtures/openapi/KubernetesTopologySnapshotRequest/invalid/arbitrary-gvr.json +8 -0
- package/fixtures/openapi/KubernetesTopologySnapshotRequest/valid/bounded-sources.json +11 -0
- package/fixtures/openapi/KubernetesTopologySourceQuery/invalid/cluster-namespace.json +4 -0
- package/fixtures/openapi/KubernetesTopologySourceQuery/valid/all-namespaces.json +4 -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/ResourceMonitoringPanel/invalid/unknown-quality.json +7 -0
- package/fixtures/openapi/ResourceMonitoringPanel/valid/not-collected.json +7 -0
- package/fixtures/openapi/ResourceMonitoringPoint/invalid/string-zero.json +4 -0
- package/fixtures/openapi/ResourceMonitoringPoint/valid/missing.json +4 -0
- package/fixtures/openapi/ResourceMonitoringPoint/valid/zero.json +4 -0
- package/fixtures/openapi/ResourceMonitoringView/invalid/raw-query.json +12 -0
- package/fixtures/openapi/ResourceMonitoringView/valid/unconfigured.json +12 -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/kubernetes_monitoring_test.go +67 -0
- package/gen/go/sohaapi/types.go +2210 -387
- package/gen/ts/sohaapi/index.ts +1862 -5
- 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 +5514 -1179
- package/openapi/soha-api.yaml +1080 -11
- package/openapi/spec_generated.go +3 -3
- package/openapi/spec_test.go +76 -0
- package/package.json +1 -1
- package/resource/agent_access.go +12 -0
- package/resource/agent_profiles.go +62 -0
- package/resource/agent_profiles_test.go +36 -0
- package/resource/kubernetes_kinds.go +73 -0
- package/resource/kubernetes_kinds_test.go +38 -0
- package/resource/models.go +25 -14
- package/resource/network_topology.go +166 -0
- package/resource/network_topology_projection.go +208 -0
- package/resource/network_topology_projection_test.go +53 -0
- package/resource/network_topology_test.go +51 -0
- package/resource/network_topology_validation.go +29 -0
- package/resource/node_drain.go +34 -0
- package/resource/relations.go +225 -0
- package/resource/relations_test.go +47 -0
- package/resource/resource_monitoring.go +99 -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/network_topology.go +232 -0
- package/resource/runtime/network_topology_test.go +62 -0
- package/resource/runtime/node_drain.go +124 -0
- package/resource/runtime/node_drain_test.go +95 -0
- package/resource/runtime/relations.go +161 -0
- package/resource/runtime/relations_test.go +100 -0
- package/resource/runtime/resource_edit.go +128 -0
- package/resource/runtime/resource_edit_test.go +183 -0
- package/resource/runtime/resource_monitoring.go +243 -0
- package/resource/runtime/resource_monitoring_test.go +88 -0
- package/resource/runtime/root_cr.go +79 -0
- 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
|
+
}
|