@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,99 @@
|
|
|
1
|
+
package resource
|
|
2
|
+
|
|
3
|
+
const ResourceMonitoringSnapshotFeature = "resource.monitoring.snapshot.v1"
|
|
4
|
+
const ResourceMonitoringMaxNodes = 500
|
|
5
|
+
const ResourceMonitoringMaxPoints = 1440
|
|
6
|
+
const ResourceMonitoringMaxTotalPoints = 60000
|
|
7
|
+
|
|
8
|
+
type ResourceMonitoringQuery struct {
|
|
9
|
+
View string `json:"view"`
|
|
10
|
+
Node string `json:"node,omitempty"`
|
|
11
|
+
From string `json:"from,omitempty"`
|
|
12
|
+
To string `json:"to,omitempty"`
|
|
13
|
+
RangeMinutes int `json:"rangeMinutes,omitempty"`
|
|
14
|
+
StepSeconds int `json:"stepSeconds,omitempty"`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ResourceMonitoringQuantity struct {
|
|
18
|
+
CPUCores *float64 `json:"cpuCores"`
|
|
19
|
+
MemoryBytes *float64 `json:"memoryBytes"`
|
|
20
|
+
PodSlots *float64 `json:"podSlots"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ResourceMonitoringNode struct {
|
|
24
|
+
NodeRef ResourceRef `json:"nodeRef"`
|
|
25
|
+
InternalIP string `json:"internalIP,omitempty"`
|
|
26
|
+
OS string `json:"os,omitempty"`
|
|
27
|
+
CreatedAt string `json:"createdAt,omitempty"`
|
|
28
|
+
Ready string `json:"ready"`
|
|
29
|
+
Cordoned bool `json:"cordoned"`
|
|
30
|
+
MemoryPressure string `json:"memoryPressure"`
|
|
31
|
+
DiskPressure string `json:"diskPressure"`
|
|
32
|
+
PIDPressure string `json:"pidPressure"`
|
|
33
|
+
Capacity ResourceMonitoringQuantity `json:"capacity"`
|
|
34
|
+
Allocatable ResourceMonitoringQuantity `json:"allocatable"`
|
|
35
|
+
Requests ResourceMonitoringQuantity `json:"requests"`
|
|
36
|
+
Limits ResourceMonitoringQuantity `json:"limits"`
|
|
37
|
+
CPULimitsComplete bool `json:"cpuLimitsComplete"`
|
|
38
|
+
MemoryLimitsComplete bool `json:"memoryLimitsComplete"`
|
|
39
|
+
BoundPods *float64 `json:"boundPods"`
|
|
40
|
+
PendingPods *float64 `json:"pendingPods"`
|
|
41
|
+
RemainingPodSlots *float64 `json:"remainingPodSlots"`
|
|
42
|
+
OverflowPodSlots *float64 `json:"overflowPodSlots"`
|
|
43
|
+
Quality string `json:"quality"`
|
|
44
|
+
ObservedAt string `json:"observedAt,omitempty"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ResourceMonitoringInventory struct {
|
|
48
|
+
Nodes []ResourceMonitoringNode `json:"nodes"`
|
|
49
|
+
UnscheduledPendingPods *float64 `json:"unscheduledPendingPods"`
|
|
50
|
+
Quality string `json:"quality"`
|
|
51
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
52
|
+
ObservedAt string `json:"observedAt"`
|
|
53
|
+
Truncated bool `json:"truncated,omitempty"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ResourceMonitoringPoint struct {
|
|
57
|
+
Timestamp string `json:"timestamp"`
|
|
58
|
+
Value *float64 `json:"value"`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ResourceMonitoringSeries struct {
|
|
62
|
+
NodeRef ResourceRef `json:"nodeRef"`
|
|
63
|
+
MetricKey string `json:"metricKey"`
|
|
64
|
+
Unit string `json:"unit"`
|
|
65
|
+
Quality string `json:"quality"`
|
|
66
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
67
|
+
Source string `json:"source"`
|
|
68
|
+
SampledAt string `json:"sampledAt,omitempty"`
|
|
69
|
+
Latest *float64 `json:"latest"`
|
|
70
|
+
Points []ResourceMonitoringPoint `json:"points"`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ResourceMonitoringPanel struct {
|
|
74
|
+
Key string `json:"key"`
|
|
75
|
+
Label string `json:"label"`
|
|
76
|
+
Unit string `json:"unit"`
|
|
77
|
+
Quality string `json:"quality"`
|
|
78
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
79
|
+
Estimated bool `json:"estimated,omitempty"`
|
|
80
|
+
WindowSeconds int `json:"windowSeconds,omitempty"`
|
|
81
|
+
Series []ResourceMonitoringSeries `json:"series"`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ResourceMonitoringView struct {
|
|
85
|
+
NodeOptions []ResourceRef `json:"nodeOptions,omitempty"`
|
|
86
|
+
ClusterID string `json:"clusterId"`
|
|
87
|
+
View string `json:"view"`
|
|
88
|
+
Node string `json:"node,omitempty"`
|
|
89
|
+
From string `json:"from"`
|
|
90
|
+
To string `json:"to"`
|
|
91
|
+
StepSeconds int `json:"stepSeconds"`
|
|
92
|
+
GeneratedAt string `json:"generatedAt"`
|
|
93
|
+
SnapshotObservedAt string `json:"snapshotObservedAt,omitempty"`
|
|
94
|
+
SnapshotQuality string `json:"snapshotQuality"`
|
|
95
|
+
UnscheduledPendingPods *float64 `json:"unscheduledPendingPods"`
|
|
96
|
+
Nodes []ResourceMonitoringNode `json:"nodes"`
|
|
97
|
+
Panels []ResourceMonitoringPanel `json:"panels"`
|
|
98
|
+
Truncated bool `json:"truncated,omitempty"`
|
|
99
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package resource
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// ValidateRootCRDocument prevents a root publication from changing runtime metadata
|
|
10
|
+
// or writing generated children. Operator reconciliation remains independent.
|
|
11
|
+
func ValidateRootCRDocument(object map[string]any, root sohaapi.ManifestRootResource) error {
|
|
12
|
+
metadata, ok := object["metadata"].(map[string]any)
|
|
13
|
+
if !ok {
|
|
14
|
+
return fmt.Errorf("root CR metadata is required")
|
|
15
|
+
}
|
|
16
|
+
if root.UID == "" || root.Generation < 1 || root.CrdName == "" || !strings.Contains(root.APIVersion, "/") || root.Namespace == "" {
|
|
17
|
+
return fmt.Errorf("root CR requires a frozen namespaced identity")
|
|
18
|
+
}
|
|
19
|
+
if object["apiVersion"] != root.APIVersion || object["kind"] != root.Kind || metadata["namespace"] != root.Namespace || metadata["name"] != root.Name {
|
|
20
|
+
return fmt.Errorf("root CR identity differs from the approved resource")
|
|
21
|
+
}
|
|
22
|
+
for key := range object {
|
|
23
|
+
switch key {
|
|
24
|
+
case "apiVersion", "kind", "metadata", "spec":
|
|
25
|
+
default:
|
|
26
|
+
return fmt.Errorf("root CR updates only support spec")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for key := range metadata {
|
|
30
|
+
if key != "name" && key != "namespace" {
|
|
31
|
+
return fmt.Errorf("root CR runtime metadata cannot be published")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if _, ok := object["spec"].(map[string]any); !ok {
|
|
35
|
+
return fmt.Errorf("root CR spec must be an object")
|
|
36
|
+
}
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
@@ -37,7 +37,7 @@ func TestArgoCDWithKubernetes(t *testing.T) {
|
|
|
37
37
|
operatorRequire(t, err)
|
|
38
38
|
kube, err := kubernetes.NewForConfig(config)
|
|
39
39
|
operatorRequire(t, err)
|
|
40
|
-
data, err := os.ReadFile(os.Getenv("SOHA_ARGOCD_TEST_STATE"))
|
|
40
|
+
data, err := os.ReadFile(os.Getenv("SOHA_ARGOCD_TEST_STATE")) // #nosec G703 -- Opt-in integration test reads the operator-selected local fixture path; no server request controls it.
|
|
41
41
|
operatorRequire(t, err)
|
|
42
42
|
var state map[string]json.RawMessage
|
|
43
43
|
operatorRequire(t, json.Unmarshal(data, &state))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
9
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
10
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
11
|
+
"k8s.io/client-go/dynamic"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const ManifestInitializationCapability = "manifest.initialize.v1"
|
|
15
|
+
const ManifestInitializationProviderPrefix = "manifest_initialize_v1."
|
|
16
|
+
|
|
17
|
+
// ValidateManifestInitialization fences the new mode from legacy/controller writes.
|
|
18
|
+
// Callers still own current authorization, immutable Plan validation and admission.
|
|
19
|
+
func ValidateManifestInitialization(p sohaapi.ManifestExecutionTaskPayload) error {
|
|
20
|
+
if p.InitializationPlanID == "" {
|
|
21
|
+
return nil
|
|
22
|
+
}
|
|
23
|
+
if strings.TrimSpace(p.InitializationPlanID) != p.InitializationPlanID || len(p.InitializationPlanID) > 128 || p.ForceConflicts || p.RootResource != nil || p.RolloutControl != nil || len(p.GitOpsDocuments) != 0 {
|
|
24
|
+
return fmt.Errorf("%w: invalid initialization boundary", ErrResourceOwnership)
|
|
25
|
+
}
|
|
26
|
+
if p.Action != sohaapi.ManifestTaskActionPreflight && p.Action != sohaapi.ManifestTaskActionApply && p.Action != sohaapi.ManifestTaskActionObserve {
|
|
27
|
+
return fmt.Errorf("%w: initialization requires preflight, apply or observation", ErrResourceOwnership)
|
|
28
|
+
}
|
|
29
|
+
if IsRolloutTask(p) {
|
|
30
|
+
return fmt.Errorf("%w: controller delivery cannot guarantee create-only initialization", ErrResourceOwnership)
|
|
31
|
+
}
|
|
32
|
+
for _, document := range p.Documents {
|
|
33
|
+
if initializationController(document.APIVersion, document.Kind) {
|
|
34
|
+
return fmt.Errorf("%w: controller delivery cannot guarantee create-only initialization", ErrResourceOwnership)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func initializationController(apiVersion, kind string) bool {
|
|
41
|
+
return strings.HasPrefix(apiVersion, "kustomize.toolkit.fluxcd.io/") || strings.HasPrefix(apiVersion, "helm.toolkit.fluxcd.io/") || strings.HasPrefix(apiVersion, "argoproj.io/") && (kind == "Application" || kind == "Rollout")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CreateManifest never falls back to SSA or adopts a concurrent object. Kubernetes
|
|
45
|
+
// validates non-existence atomically for both dry-run and the actual POST.
|
|
46
|
+
func CreateManifest(ctx context.Context, client dynamic.ResourceInterface, desired *unstructured.Unstructured, options metav1.CreateOptions) (*unstructured.Unstructured, error) {
|
|
47
|
+
if desired.GetUID() != "" || desired.GetResourceVersion() != "" || desired.GetGenerateName() != "" {
|
|
48
|
+
return nil, fmt.Errorf("%w: initialization requires a new named resource", ErrResourceOwnership)
|
|
49
|
+
}
|
|
50
|
+
if initializationController(desired.GetAPIVersion(), desired.GetKind()) {
|
|
51
|
+
return nil, fmt.Errorf("%w: controller delivery requires its existing publication path", ErrResourceOwnership)
|
|
52
|
+
}
|
|
53
|
+
if err := ValidateDirectManifestOwner(desired); err != nil {
|
|
54
|
+
return nil, err
|
|
55
|
+
}
|
|
56
|
+
return client.Create(ctx, desired, options)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ValidateInitializedUID prevents a healthy same-name replacement from confirming
|
|
60
|
+
// the baseline of a different object created by the approved initialization.
|
|
61
|
+
func ValidateInitializedUID(live metav1.Object, expectedUID string) error {
|
|
62
|
+
if live == nil || expectedUID == "" || live.GetUID() == "" || string(live.GetUID()) != expectedUID {
|
|
63
|
+
return fmt.Errorf("%w: initialized resource identity is missing or changed", ErrResourceOwnership)
|
|
64
|
+
}
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/opensoha/soha-contracts/gen/go/sohaapi"
|
|
8
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
9
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
10
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
11
|
+
"k8s.io/apimachinery/pkg/runtime"
|
|
12
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
13
|
+
"k8s.io/client-go/dynamic/fake"
|
|
14
|
+
ktesting "k8s.io/client-go/testing"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestManifestInitializationCreatesWithoutAdoption(t *testing.T) {
|
|
18
|
+
object := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "settings", "namespace": "test"}}}
|
|
19
|
+
gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}
|
|
20
|
+
for _, existing := range []bool{false, true} {
|
|
21
|
+
t.Run(map[bool]string{false: "new", true: "concurrently-created"}[existing], func(t *testing.T) {
|
|
22
|
+
client := fake.NewSimpleDynamicClient(runtime.NewScheme())
|
|
23
|
+
if existing {
|
|
24
|
+
if err := client.Tracker().Create(gvr, object.DeepCopy(), "test"); err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
_, err := CreateManifest(t.Context(), client.Resource(gvr).Namespace("test"), object.DeepCopy(), metav1.CreateOptions{})
|
|
29
|
+
if existing && !apierrors.IsAlreadyExists(err) || !existing && err != nil {
|
|
30
|
+
t.Fatalf("create existing=%v: %v", existing, err)
|
|
31
|
+
}
|
|
32
|
+
for _, action := range client.Actions() {
|
|
33
|
+
if action.GetVerb() != "create" {
|
|
34
|
+
t.Fatalf("initialization used %s instead of atomic create", action.GetVerb())
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
client := fake.NewSimpleDynamicClient(runtime.NewScheme())
|
|
40
|
+
client.PrependReactor("create", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) {
|
|
41
|
+
typed, ok := action.(interface{ GetCreateOptions() metav1.CreateOptions })
|
|
42
|
+
if !ok {
|
|
43
|
+
t.Fatalf("missing create options: %T", action)
|
|
44
|
+
}
|
|
45
|
+
if options := typed.GetCreateOptions(); len(options.DryRun) != 1 || options.DryRun[0] != metav1.DryRunAll {
|
|
46
|
+
t.Fatal("preflight lost server dry-run")
|
|
47
|
+
}
|
|
48
|
+
return true, object.DeepCopy(), nil
|
|
49
|
+
})
|
|
50
|
+
if _, err := CreateManifest(context.Background(), client.Resource(gvr).Namespace("test"), object, metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}}); err != nil {
|
|
51
|
+
t.Fatal(err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestManifestInitializationRejectsUnsafeModesAndReplacedUID(t *testing.T) {
|
|
56
|
+
p := sohaapi.ManifestExecutionTaskPayload{InitializationPlanID: "plan", Action: sohaapi.ManifestTaskActionApply}
|
|
57
|
+
if err := ValidateManifestInitialization(p); err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
for _, action := range []sohaapi.ManifestTaskAction{sohaapi.ManifestTaskActionRepair, sohaapi.ManifestTaskActionRollback, sohaapi.ManifestTaskActionAdopt} {
|
|
61
|
+
p.Action = action
|
|
62
|
+
if err := ValidateManifestInitialization(p); err == nil {
|
|
63
|
+
t.Fatalf("accepted initialization %s", action)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
p.Action, p.ForceConflicts = sohaapi.ManifestTaskActionApply, true
|
|
67
|
+
if ValidateManifestInitialization(p) == nil {
|
|
68
|
+
t.Fatal("initialization accepted forced conflicts")
|
|
69
|
+
}
|
|
70
|
+
live := &unstructured.Unstructured{}
|
|
71
|
+
live.SetUID("created-uid")
|
|
72
|
+
if err := ValidateInitializedUID(live, "created-uid"); err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
for _, expected := range []string{"", "replaced-uid"} {
|
|
76
|
+
if ValidateInitializedUID(live, expected) == nil {
|
|
77
|
+
t.Fatal("unproven initialization UID was accepted")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
resource "github.com/opensoha/soha-contracts/resource"
|
|
14
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
15
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
16
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
17
|
+
"k8s.io/client-go/rest"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// ReadNetworkTopologySnapshot performs only fixed, discovery-verified, bounded reads.
|
|
21
|
+
// The caller authenticates and authorizes each query before invoking this reader.
|
|
22
|
+
func ReadNetworkTopologySnapshot(ctx context.Context, client rest.Interface, clusterID string, input resource.NetworkTopologySnapshotRequest) (resource.NetworkTopologySnapshot, error) {
|
|
23
|
+
if err := resource.ValidateNetworkTopologySnapshotRequest(input); err != nil {
|
|
24
|
+
return resource.NetworkTopologySnapshot{}, err
|
|
25
|
+
}
|
|
26
|
+
if client == nil {
|
|
27
|
+
return resource.NetworkTopologySnapshot{}, fmt.Errorf("kubernetes REST client unavailable")
|
|
28
|
+
}
|
|
29
|
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
30
|
+
defer cancel()
|
|
31
|
+
output := resource.NetworkTopologySnapshot{Version: resource.NetworkTopologySnapshotFeature, Sources: make([]resource.NetworkTopologySourceResult, len(input.Queries))}
|
|
32
|
+
budget := &topologyReadBudget{remaining: resource.NetworkTopologyMaxTotalBytes}
|
|
33
|
+
sem := make(chan struct{}, 4)
|
|
34
|
+
var wg sync.WaitGroup
|
|
35
|
+
for index, query := range input.Queries {
|
|
36
|
+
wg.Add(1)
|
|
37
|
+
go func(index int, query resource.NetworkTopologySourceQuery) {
|
|
38
|
+
defer wg.Done()
|
|
39
|
+
select {
|
|
40
|
+
case sem <- struct{}{}:
|
|
41
|
+
defer func() { <-sem }()
|
|
42
|
+
case <-ctx.Done():
|
|
43
|
+
output.Sources[index] = topologySourceFailure(query, "error", "timeout")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
output.Sources[index] = readTopologySource(ctx, client, clusterID, query, budget)
|
|
47
|
+
}(index, query)
|
|
48
|
+
}
|
|
49
|
+
wg.Wait()
|
|
50
|
+
total := 0
|
|
51
|
+
for i := range output.Sources {
|
|
52
|
+
source := &output.Sources[i]
|
|
53
|
+
kept := source.Objects[:0]
|
|
54
|
+
for _, object := range source.Objects {
|
|
55
|
+
size := len(object.Content)
|
|
56
|
+
if total+size > resource.NetworkTopologyMaxTotalBytes {
|
|
57
|
+
source.Coverage.State = "partial"
|
|
58
|
+
source.Coverage.Truncated = true
|
|
59
|
+
source.Coverage.ReasonCode = "total_budget"
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
total += size
|
|
63
|
+
kept = append(kept, object)
|
|
64
|
+
}
|
|
65
|
+
source.Objects = kept
|
|
66
|
+
}
|
|
67
|
+
return output, nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func topologySourceFailure(query resource.NetworkTopologySourceQuery, state, reason string) resource.NetworkTopologySourceResult {
|
|
71
|
+
definition, _ := resource.LookupNetworkTopologySource(query.SourceID)
|
|
72
|
+
return resource.NetworkTopologySourceResult{Coverage: resource.NetworkTopologyCoverage{Provider: definition.Provider, SourceID: query.SourceID, Namespace: query.Namespace, State: state, ReasonCode: reason, CheckedAt: time.Now().UTC().Format(time.RFC3339), SupportedKinds: []string{definition.Kind}}, Objects: []resource.NetworkTopologySourceObject{}}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func readTopologySource(ctx context.Context, client rest.Interface, clusterID string, query resource.NetworkTopologySourceQuery, budget *topologyReadBudget) resource.NetworkTopologySourceResult {
|
|
76
|
+
definition, _ := resource.LookupNetworkTopologySource(query.SourceID)
|
|
77
|
+
result := topologySourceFailure(query, "available", "")
|
|
78
|
+
version, err := discoverTopologySource(ctx, client, definition, budget)
|
|
79
|
+
if err != nil {
|
|
80
|
+
result.Coverage.State, result.Coverage.ReasonCode = topologyReadError(err)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
base := topologyAPIBase(definition.Group, version)
|
|
84
|
+
if query.Namespace != "" {
|
|
85
|
+
base += "/namespaces/" + query.Namespace
|
|
86
|
+
}
|
|
87
|
+
raw, err := boundedTopologyGET(ctx, client, base+"/"+definition.Resource, true, budget)
|
|
88
|
+
if err != nil {
|
|
89
|
+
result.Coverage.State, result.Coverage.ReasonCode = topologyReadError(err)
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
var list struct {
|
|
93
|
+
Metadata struct {
|
|
94
|
+
Continue string `json:"continue"`
|
|
95
|
+
} `json:"metadata"`
|
|
96
|
+
Items []map[string]any `json:"items"`
|
|
97
|
+
}
|
|
98
|
+
if json.Unmarshal(raw, &list) != nil {
|
|
99
|
+
result.Coverage.State = "error"
|
|
100
|
+
result.Coverage.ReasonCode = "invalid_response"
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
result.Coverage.ObservedAt = time.Now().UTC().Format(time.RFC3339)
|
|
104
|
+
if len(list.Items) > resource.NetworkTopologyMaxSourceItems || list.Metadata.Continue != "" {
|
|
105
|
+
result.Coverage.Truncated = true
|
|
106
|
+
result.Coverage.State = "partial"
|
|
107
|
+
result.Coverage.ReasonCode = "item_budget"
|
|
108
|
+
}
|
|
109
|
+
for index, object := range list.Items {
|
|
110
|
+
if index >= resource.NetworkTopologyMaxSourceItems {
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
projected, err := resource.ProjectNetworkTopologyObject(query.SourceID, object)
|
|
114
|
+
if err != nil {
|
|
115
|
+
result.Coverage.Truncated = true
|
|
116
|
+
result.Coverage.State = "partial"
|
|
117
|
+
result.Coverage.ReasonCode = "object_budget"
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
metadata, _ := projected["metadata"].(map[string]any)
|
|
121
|
+
name, _ := metadata["name"].(string)
|
|
122
|
+
namespace, _ := metadata["namespace"].(string)
|
|
123
|
+
if name == "" || (query.Namespace != "" && namespace != query.Namespace) {
|
|
124
|
+
result.Coverage.State = "partial"
|
|
125
|
+
result.Coverage.ReasonCode = "invalid_identity"
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
uid, _ := metadata["uid"].(string)
|
|
129
|
+
rv, _ := metadata["resourceVersion"].(string)
|
|
130
|
+
generation, _ := metadata["generation"].(float64)
|
|
131
|
+
content, _ := json.Marshal(projected)
|
|
132
|
+
result.Objects = append(result.Objects, resource.NetworkTopologySourceObject{Resource: resource.ResourceRef{ClusterID: clusterID, APIVersion: strings.TrimPrefix(definition.Group+"/"+version, "/"), ScopeMode: topologyScope(definition.Namespaced), Kind: definition.Kind, Namespace: namespace, Name: name, UID: uid, ResourceVersion: rv}, Generation: int64(generation), Content: string(content)})
|
|
133
|
+
}
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func topologyAPIBase(group, version string) string {
|
|
138
|
+
if group == "" {
|
|
139
|
+
return "/api/" + version
|
|
140
|
+
}
|
|
141
|
+
return "/apis/" + group + "/" + version
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func discoverTopologySource(ctx context.Context, client rest.Interface, definition resource.NetworkTopologySourceDefinition, budget *topologyReadBudget) (string, error) {
|
|
145
|
+
versions := []string{definition.Version}
|
|
146
|
+
if definition.FallbackVersion != "" {
|
|
147
|
+
versions = append(versions, definition.FallbackVersion)
|
|
148
|
+
}
|
|
149
|
+
for _, version := range versions {
|
|
150
|
+
raw, err := boundedTopologyGET(ctx, client, topologyAPIBase(definition.Group, version), false, budget)
|
|
151
|
+
if apierrors.IsNotFound(err) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if err != nil {
|
|
155
|
+
return "", err
|
|
156
|
+
}
|
|
157
|
+
var list metav1.APIResourceList
|
|
158
|
+
if err := json.Unmarshal(raw, &list); err != nil {
|
|
159
|
+
return "", fmt.Errorf("invalid discovery")
|
|
160
|
+
}
|
|
161
|
+
for _, item := range list.APIResources {
|
|
162
|
+
if item.Name == definition.Resource && item.Kind == definition.Kind && item.Namespaced == definition.Namespaced {
|
|
163
|
+
return version, nil
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return "", apierrors.NewNotFound(schema.GroupResource{Group: definition.Group, Resource: definition.Resource}, "")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func boundedTopologyGET(ctx context.Context, client rest.Interface, path string, list bool, budget *topologyReadBudget) ([]byte, error) {
|
|
171
|
+
request := client.Get().AbsPath(path).SetHeader("Accept", "application/json")
|
|
172
|
+
if list {
|
|
173
|
+
request = request.Param("limit", fmt.Sprint(resource.NetworkTopologyMaxSourceItems))
|
|
174
|
+
}
|
|
175
|
+
stream, err := request.Stream(ctx)
|
|
176
|
+
if err != nil {
|
|
177
|
+
return nil, err
|
|
178
|
+
}
|
|
179
|
+
defer func() { _ = stream.Close() }()
|
|
180
|
+
body, err := io.ReadAll(io.LimitReader(&topologyBudgetReader{reader: stream, budget: budget}, resource.NetworkTopologyMaxSourceBytes+1))
|
|
181
|
+
if err != nil {
|
|
182
|
+
return nil, err
|
|
183
|
+
}
|
|
184
|
+
if len(body) > resource.NetworkTopologyMaxSourceBytes {
|
|
185
|
+
return nil, fmt.Errorf("topology byte budget")
|
|
186
|
+
}
|
|
187
|
+
return body, nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func topologyReadError(err error) (string, string) {
|
|
191
|
+
switch {
|
|
192
|
+
case apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err):
|
|
193
|
+
return "denied", "kubernetes_rbac"
|
|
194
|
+
case apierrors.IsNotFound(err):
|
|
195
|
+
return "not_installed", "source_not_served"
|
|
196
|
+
case strings.Contains(err.Error(), "budget"):
|
|
197
|
+
return "partial", "byte_budget"
|
|
198
|
+
default:
|
|
199
|
+
return "error", "source_unavailable"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func topologyScope(namespaced bool) resource.ResourceScopeMode {
|
|
204
|
+
if namespaced {
|
|
205
|
+
return resource.ResourceScopeModeNamespace
|
|
206
|
+
}
|
|
207
|
+
return resource.ResourceScopeModeCluster
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type topologyReadBudget struct {
|
|
211
|
+
mu sync.Mutex
|
|
212
|
+
remaining int
|
|
213
|
+
}
|
|
214
|
+
type topologyBudgetReader struct {
|
|
215
|
+
reader io.Reader
|
|
216
|
+
budget *topologyReadBudget
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func (r *topologyBudgetReader) Read(data []byte) (int, error) {
|
|
220
|
+
r.budget.mu.Lock()
|
|
221
|
+
available := min(len(data), r.budget.remaining)
|
|
222
|
+
r.budget.remaining -= available
|
|
223
|
+
r.budget.mu.Unlock()
|
|
224
|
+
if available <= 0 {
|
|
225
|
+
return 0, errors.New("topology total byte budget")
|
|
226
|
+
}
|
|
227
|
+
count, err := r.reader.Read(data[:available])
|
|
228
|
+
r.budget.mu.Lock()
|
|
229
|
+
r.budget.remaining += available - count
|
|
230
|
+
r.budget.mu.Unlock()
|
|
231
|
+
return count, err
|
|
232
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
cr "github.com/opensoha/soha-contracts/resource"
|
|
13
|
+
"k8s.io/client-go/kubernetes"
|
|
14
|
+
"k8s.io/client-go/rest"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestTopologySnapshotBoundsDiscoveryAndProjection(t *testing.T) {
|
|
18
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
19
|
+
switch r.URL.Path {
|
|
20
|
+
case "/api/v1":
|
|
21
|
+
_, _ = fmt.Fprint(w, `{"resources":[{"name":"pods","kind":"Pod","namespaced":true},{"name":"services","kind":"Service","namespaced":true}]}`)
|
|
22
|
+
case "/api/v1/namespaces/team/pods":
|
|
23
|
+
if r.URL.Query().Get("limit") != "200" {
|
|
24
|
+
t.Error("missing item bound")
|
|
25
|
+
}
|
|
26
|
+
_, _ = fmt.Fprint(w, `{"metadata":{"continue":"next"},"items":[{"metadata":{"name":"p","namespace":"team","uid":"uid","resourceVersion":"9"},"spec":{"secret":"credential"},"status":{"phase":"Running"}}]}`)
|
|
27
|
+
case "/api/v1/namespaces/team/services":
|
|
28
|
+
http.Error(w, `{"kind":"Status","apiVersion":"v1","reason":"Forbidden","code":403}`, http.StatusForbidden)
|
|
29
|
+
case "/apis/traefik.io/v1alpha1":
|
|
30
|
+
http.NotFound(w, r)
|
|
31
|
+
default:
|
|
32
|
+
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
33
|
+
http.NotFound(w, r)
|
|
34
|
+
}
|
|
35
|
+
}))
|
|
36
|
+
defer server.Close()
|
|
37
|
+
client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
output, err := ReadNetworkTopologySnapshot(context.Background(), client.CoreV1().RESTClient(), "cluster", cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{{SourceID: "kubernetes.pods", Namespace: "team"}, {SourceID: "kubernetes.services", Namespace: "team"}, {SourceID: "traefik.ingressroutes", Namespace: "team"}}})
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
if output.Sources[0].Coverage.State != "partial" || !output.Sources[0].Coverage.Truncated || output.Sources[1].Coverage.State != "denied" || output.Sources[2].Coverage.State != "not_installed" {
|
|
46
|
+
t.Fatalf("coverage: %+v", output.Sources)
|
|
47
|
+
}
|
|
48
|
+
raw, _ := json.Marshal(output)
|
|
49
|
+
if strings.Contains(string(raw), "credential") {
|
|
50
|
+
t.Fatal("full manifest escaped")
|
|
51
|
+
}
|
|
52
|
+
if output.Sources[0].Objects[0].Resource.ResourceVersion != "9" {
|
|
53
|
+
t.Fatal("identity lost")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestTopologySnapshotDoesNotReadArbitrarySources(t *testing.T) {
|
|
58
|
+
_, err := ReadNetworkTopologySnapshot(context.Background(), nil, "c", cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{{SourceID: "secrets"}}})
|
|
59
|
+
if err == nil {
|
|
60
|
+
t.Fatal("unknown source accepted")
|
|
61
|
+
}
|
|
62
|
+
}
|