From 86fd505383197e768a2bf460c2398a74e8796454 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Tue, 4 Aug 2026 11:56:19 +0100 Subject: [PATCH 01/15] feat(mesh): add mesh command container and resource discovery Introduce a mesh command container reached as `kongctl get mesh ...`, serving Kong Mesh control planes through a thin REST client. The resource surface is driven by the control plane's own /_resources endpoint rather than a resource type table compiled into kongctl, so policies and resource types added by newer Kong Mesh releases, including enterprise types, are usable without a kongctl release. Scope, aliases, read-only status and policy classification all come from the control plane, so no per-type code is needed. The control plane connection resolves from two sources so that Konnect hosted and self managed control planes present the same commands: an explicit control plane URL, or a Konnect control plane identifier composed onto the profile's Konnect base URL. Only the hosted path is wired up here; the seam is what keeps self managed support additive. Adds `get mesh resource-types` to list what a control plane serves. No new dependencies. Hosted control planes are reached over the Konnect base URL kongctl already resolves, authenticated with the existing PAT, following the raw HTTP pattern established by the regions command. Requires Kong Mesh 2.13 or later, the first release reporting shortName in the discovery response. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 02c2da1322b524e08d6a6ebc129da81048be6c65) --- .../products/konnect/mesh/common/common.go | 144 ++++++++++++ .../konnect/mesh/common/common_test.go | 116 ++++++++++ .../root/products/konnect/mesh/discovery.go | 219 ++++++++++++++++++ .../products/konnect/mesh/discovery_test.go | 164 +++++++++++++ .../products/konnect/mesh/getResourceTypes.go | 166 +++++++++++++ .../cmd/root/products/konnect/mesh/mesh.go | 81 +++++++ internal/cmd/root/verbs/get/get.go | 6 + internal/cmd/root/verbs/get/mesh.go | 72 ++++++ 8 files changed, 968 insertions(+) create mode 100644 internal/cmd/root/products/konnect/mesh/common/common.go create mode 100644 internal/cmd/root/products/konnect/mesh/common/common_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/discovery.go create mode 100644 internal/cmd/root/products/konnect/mesh/discovery_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/getResourceTypes.go create mode 100644 internal/cmd/root/products/konnect/mesh/mesh.go create mode 100644 internal/cmd/root/verbs/get/mesh.go diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go new file mode 100644 index 000000000..1e8378559 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -0,0 +1,144 @@ +package common + +import ( + "fmt" + "strings" + + konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/config" + "github.com/spf13/pflag" +) + +const ( + // CommandName is the name of the mesh container command. + CommandName = "mesh" + + ControlPlaneIDFlagName = "control-plane-id" + ControlPlaneNameFlagName = "control-plane-name" + ControlPlaneURLFlagName = "control-plane-url" + + MeshFlagName = "mesh" + MeshFlagShorthand = "m" + + // DefaultMesh matches the default kumactl applies to mesh scoped + // resources, so that commands carrying no --mesh behave the same way. + DefaultMesh = "default" +) + +var ( + ControlPlaneIDConfigPath = "konnect.mesh.control-plane.id" + ControlPlaneNameConfigPath = "konnect.mesh.control-plane.name" + ControlPlaneURLConfigPath = "konnect.mesh.control-plane.url" + MeshConfigPath = "konnect.mesh.mesh" +) + +// controlPlaneAPIPathFormat fronts a Konnect hosted Kong Mesh control plane's +// own API. The control plane identifier travels in the path, so callers do not +// send a separate tenant header. +const controlPlaneAPIPathFormat = "/v1/mesh/control-planes/%s/api" + +// ControlPlaneAPIPath returns the Konnect path prefix for a hosted Kong Mesh +// control plane API. +func ControlPlaneAPIPath(controlPlaneID string) string { + return fmt.Sprintf(controlPlaneAPIPathFormat, controlPlaneID) +} + +// ResolveControlPlaneAPIURL returns the base URL of the Kong Mesh control plane +// API to send requests to. +// +// Two sources are supported so that Konnect hosted and self managed control +// planes are reached through the same commands: +// +// 1. An explicit control plane URL, used verbatim. This serves self managed +// control planes, and overrides for hosted ones. +// 2. A Konnect control plane identifier, composed onto the Konnect base URL +// already resolved for the active profile. +// +// An explicit URL wins when both are configured. +func ResolveControlPlaneAPIURL(cfg config.Hook) (string, error) { + if explicit := strings.TrimSpace(cfg.GetString(ControlPlaneURLConfigPath)); explicit != "" { + return strings.TrimRight(explicit, "/"), nil + } + + controlPlaneID := strings.TrimSpace(cfg.GetString(ControlPlaneIDConfigPath)) + if controlPlaneID == "" { + return "", missingControlPlaneError(cfg) + } + + konnectBaseURL, err := konnectcommon.ResolveBaseURL(cfg) + if err != nil { + return "", err + } + + return strings.TrimRight(konnectBaseURL, "/") + ControlPlaneAPIPath(controlPlaneID), nil +} + +// missingControlPlaneError explains which inputs identify a control plane, +// naming the control plane by name when one was given but not yet resolved. +func missingControlPlaneError(cfg config.Hook) error { + if name := strings.TrimSpace(cfg.GetString(ControlPlaneNameConfigPath)); name != "" { + return fmt.Errorf( + "control plane %q has not been resolved to an identifier; provide --%s instead", + name, ControlPlaneIDFlagName, + ) + } + return fmt.Errorf( + "no Kong Mesh control plane selected; provide --%s for a Konnect hosted control plane, "+ + "or --%s for a self managed one", + ControlPlaneIDFlagName, + ControlPlaneURLFlagName, + ) +} + +// ResolveMesh returns the mesh that mesh scoped requests apply to. +func ResolveMesh(cfg config.Hook) string { + if mesh := strings.TrimSpace(cfg.GetString(MeshConfigPath)); mesh != "" { + return mesh + } + return DefaultMesh +} + +// AddControlPlaneFlags registers the flags that select a control plane and a +// mesh. Commands share these so that every mesh command accepts the same +// selection inputs. +func AddControlPlaneFlags(flags *pflag.FlagSet) { + flags.String(ControlPlaneIDFlagName, "", + fmt.Sprintf(`ID of the Konnect Kong Mesh control plane to use. +- Config path: [ %s ]`, ControlPlaneIDConfigPath)) + + flags.String(ControlPlaneNameFlagName, "", + fmt.Sprintf(`Name of the Konnect Kong Mesh control plane to use. +- Config path: [ %s ]`, ControlPlaneNameConfigPath)) + + flags.String(ControlPlaneURLFlagName, "", + fmt.Sprintf(`API URL of a self managed Kong Mesh control plane. Takes precedence over --%s. +- Config path: [ %s ]`, ControlPlaneIDFlagName, ControlPlaneURLConfigPath)) + + flags.StringP(MeshFlagName, MeshFlagShorthand, DefaultMesh, + fmt.Sprintf(`Mesh that mesh scoped resources belong to. +- Config path: [ %s ]`, MeshConfigPath)) +} + +// BindFlags associates the control plane selection flags with their +// configuration paths. +func BindFlags(cfg config.Hook, flags *pflag.FlagSet) error { + if cfg == nil || flags == nil { + return nil + } + + bindings := []struct{ flag, config string }{ + {ControlPlaneIDFlagName, ControlPlaneIDConfigPath}, + {ControlPlaneNameFlagName, ControlPlaneNameConfigPath}, + {ControlPlaneURLFlagName, ControlPlaneURLConfigPath}, + {MeshFlagName, MeshConfigPath}, + } + + for _, b := range bindings { + if f := flags.Lookup(b.flag); f != nil { + if err := cfg.BindFlag(b.config, f); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/cmd/root/products/konnect/mesh/common/common_test.go b/internal/cmd/root/products/konnect/mesh/common/common_test.go new file mode 100644 index 000000000..8741eecdc --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/common/common_test.go @@ -0,0 +1,116 @@ +package common + +import ( + "strings" + "testing" + + konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + configtest "github.com/kong/kongctl/test/config" +) + +// stubConfig returns a config hook answering only the given paths, so a test +// states exactly the configuration it depends on. +func stubConfig(values map[string]string) *configtest.MockConfigHook { + return &configtest.MockConfigHook{ + GetStringMock: func(key string) string { return values[key] }, + } +} + +func TestResolveControlPlaneAPIURLFromControlPlaneID(t *testing.T) { + cfg := stubConfig(map[string]string{ + ControlPlaneIDConfigPath: "5bf706d9-1e96-4a3a-bee4-cbf806d1dc1a", + konnectcommon.BaseURLConfigPath: "https://us.api.konghq.com", + }) + + got, err := ResolveControlPlaneAPIURL(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := "https://us.api.konghq.com/v1/mesh/control-planes/5bf706d9-1e96-4a3a-bee4-cbf806d1dc1a/api" + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestResolveControlPlaneAPIURLTrimsTrailingSlashOnBaseURL(t *testing.T) { + cfg := stubConfig(map[string]string{ + ControlPlaneIDConfigPath: "cp-1", + konnectcommon.BaseURLConfigPath: "https://eu.api.konghq.com/", + }) + + got, err := ResolveControlPlaneAPIURL(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(got, "//v1") { + t.Errorf("expected no doubled slash in %q", got) + } + if got != "https://eu.api.konghq.com/v1/mesh/control-planes/cp-1/api" { + t.Errorf("unexpected URL: %s", got) + } +} + +// An explicit URL is what lets a self managed control plane be reached through +// the same commands as a hosted one, so it must win over the hosted inputs. +func TestResolveControlPlaneAPIURLExplicitURLWins(t *testing.T) { + cfg := stubConfig(map[string]string{ + ControlPlaneURLConfigPath: "https://mesh.internal.example.com:5681/", + ControlPlaneIDConfigPath: "cp-1", + konnectcommon.BaseURLConfigPath: "https://us.api.konghq.com", + }) + + got, err := ResolveControlPlaneAPIURL(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "https://mesh.internal.example.com:5681" { + t.Errorf("expected the explicit URL to be used verbatim, got %q", got) + } +} + +func TestResolveControlPlaneAPIURLWithoutSelection(t *testing.T) { + cfg := stubConfig(map[string]string{}) + + _, err := ResolveControlPlaneAPIURL(cfg) + if err == nil { + t.Fatal("expected an error when no control plane is selected") + } + // The message has to name the inputs an operator can supply. + for _, want := range []string{ControlPlaneIDFlagName, ControlPlaneURLFlagName} { + if !strings.Contains(err.Error(), want) { + t.Errorf("expected error %q to mention --%s", err.Error(), want) + } + } +} + +func TestResolveControlPlaneAPIURLWithUnresolvedName(t *testing.T) { + cfg := stubConfig(map[string]string{ + ControlPlaneNameConfigPath: "my-mesh-cp", + }) + + _, err := ResolveControlPlaneAPIURL(cfg) + if err == nil { + t.Fatal("expected an error when only a name is configured") + } + if !strings.Contains(err.Error(), "my-mesh-cp") { + t.Errorf("expected error %q to name the control plane", err.Error()) + } +} + +func TestResolveMesh(t *testing.T) { + if got := ResolveMesh(stubConfig(map[string]string{})); got != DefaultMesh { + t.Errorf("expected the default mesh %q, got %q", DefaultMesh, got) + } + + cfg := stubConfig(map[string]string{MeshConfigPath: " prod "}) + if got := ResolveMesh(cfg); got != "prod" { + t.Errorf("expected surrounding whitespace to be trimmed, got %q", got) + } +} + +func TestControlPlaneAPIPath(t *testing.T) { + if got := ControlPlaneAPIPath("cp-1"); got != "/v1/mesh/control-planes/cp-1/api" { + t.Errorf("unexpected path: %s", got) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/discovery.go b/internal/cmd/root/products/konnect/mesh/discovery.go new file mode 100644 index 000000000..1a030efb0 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/discovery.go @@ -0,0 +1,219 @@ +package mesh + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/kong/kongctl/internal/cmd" + konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/konnect/apiutil" + "github.com/kong/kongctl/internal/konnect/httpclient" +) + +// discoveryPath is the control plane endpoint that describes every resource +// type it serves. Driving the command surface from it means new Kong Mesh +// policy and resource types, including enterprise ones, need no kongctl +// release. +const discoveryPath = "/_resources" + +// Resource scopes reported by the control plane. +const ( + ScopeMesh = "Mesh" + ScopeGlobal = "Global" +) + +// ResourceDescriptor describes one resource type served by a Kong Mesh control +// plane. Optional fields are inconsistently populated across control plane +// versions and resource types, so read them through the accessors below rather +// than directly. +type ResourceDescriptor struct { + Name string `json:"name"` + Path string `json:"path"` + Scope string `json:"scope"` + ShortName string `json:"shortName"` + ReadOnly bool `json:"readOnly"` + SingularDisplayName string `json:"singularDisplayName"` + PluralDisplayName string `json:"pluralDisplayName"` + IncludeInFederation bool `json:"includeInFederation"` + Policy *PolicyDescriptor `json:"policy,omitempty"` +} + +// PolicyDescriptor carries the policy specific metadata the control plane +// reports for resource types that are policies. +type PolicyDescriptor struct { + IsTargetRef bool `json:"isTargetRef"` + HasToTargetRef bool `json:"hasToTargetRef"` + HasFromTargetRef bool `json:"hasFromTargetRef"` + HasRulesTargetRef bool `json:"hasRulesTargetRef"` + IsFromAsRules bool `json:"isFromAsRules"` +} + +// discoveryResponse matches the control plane envelope, which wraps the +// descriptors rather than returning a bare array. +type discoveryResponse struct { + Resources []ResourceDescriptor `json:"resources"` +} + +// IsMeshScoped reports whether the resource type lives inside a mesh, and so +// whether requests for it carry a mesh name. +func (d ResourceDescriptor) IsMeshScoped() bool { + return d.Scope == ScopeMesh +} + +// IsPolicy reports whether the control plane classifies this type as a policy. +func (d ResourceDescriptor) IsPolicy() bool { + return d.Policy != nil +} + +// Singular returns a display name for one instance of the resource type, +// falling back to the type name when the control plane leaves it empty. +func (d ResourceDescriptor) Singular() string { + if name := strings.TrimSpace(d.SingularDisplayName); name != "" { + return name + } + return d.Name +} + +// Plural returns a display name for a collection of the resource type, falling +// back to the URL path when the control plane leaves it empty. +func (d ResourceDescriptor) Plural() string { + if name := strings.TrimSpace(d.PluralDisplayName); name != "" { + return name + } + if path := strings.TrimSpace(d.Path); path != "" { + return path + } + return d.Name +} + +// Alias returns the short command alias for the resource type, or an empty +// string when the control plane reports none. +func (d ResourceDescriptor) Alias() string { + return strings.TrimSpace(d.ShortName) +} + +// CollectionPath returns the control plane API path listing every instance of +// the resource type. Mesh scoped types are addressed within a mesh; global +// types sit at the root. +// +// The path is derived entirely from the descriptor, which is what allows one +// implementation to serve every resource type. +func (d ResourceDescriptor) CollectionPath(mesh string) string { + if !d.IsMeshScoped() { + return "/" + d.Path + } + if mesh == "" { + mesh = meshcommon.DefaultMesh + } + return fmt.Sprintf("/meshes/%s/%s", mesh, d.Path) +} + +// ItemPath returns the control plane API path addressing a single named +// instance of the resource type. +func (d ResourceDescriptor) ItemPath(mesh, name string) string { + return d.CollectionPath(mesh) + "/" + name +} + +// Discover fetches the resource types served by the selected control plane. +// +// Results are sorted by the control plane, which returns them alphabetically by +// type name; callers relying on a specific order should sort explicitly. +func Discover(helper cmd.Helper) ([]ResourceDescriptor, error) { + cfg, err := helper.GetConfig() + if err != nil { + return nil, err + } + + logger, err := helper.GetLogger() + if err != nil { + return nil, err + } + + baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + if err != nil { + return nil, err + } + + tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) + if err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + ctx := helper.GetContext() + if ctx == nil { + ctx = context.Background() + } + if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + result, err := apiutil.RequestWithTokenSource( + ctx, + httpclient.NewLoggingHTTPClient(logger), + http.MethodGet, + baseURL, + discoveryPath, + tokenSource, + nil, + nil, + ) + if err != nil { + return nil, err + } + + logger.Debug( + "mesh resource discovery call completed", + "path", discoveryPath, + "status_code", result.StatusCode, + ) + + if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { + return nil, buildDiscoveryError(result.StatusCode, result.Body) + } + + return decodeDiscoveryResponse(result.Body) +} + +// decodeDiscoveryResponse parses a control plane discovery payload, dropping +// descriptors that carry no path since nothing can be addressed without one. +func decodeDiscoveryResponse(body []byte) ([]ResourceDescriptor, error) { + var payload discoveryResponse + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("failed to decode mesh resource discovery response: %w", err) + } + + descriptors := make([]ResourceDescriptor, 0, len(payload.Resources)) + for _, descriptor := range payload.Resources { + if strings.TrimSpace(descriptor.Path) == "" { + continue + } + descriptors = append(descriptors, descriptor) + } + return descriptors, nil +} + +// buildDiscoveryError turns a failed discovery response into an actionable +// error, calling out the causes an operator can address. +func buildDiscoveryError(statusCode int, body []byte) error { + detail := strings.TrimSpace(string(body)) + + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf( + "not authorized to read the Kong Mesh control plane (status %d); "+ + "check that the credential grants access to this control plane", statusCode) + case http.StatusNotFound: + return fmt.Errorf( + "control plane API not found (status %d); "+ + "check the control plane selection, and that it is running Kong Mesh 2.13 or later", statusCode) + } + + if detail == "" { + return fmt.Errorf("mesh resource discovery failed with status %d", statusCode) + } + return fmt.Errorf("mesh resource discovery failed with status %d: %s", statusCode, detail) +} diff --git a/internal/cmd/root/products/konnect/mesh/discovery_test.go b/internal/cmd/root/products/konnect/mesh/discovery_test.go new file mode 100644 index 000000000..fac593a89 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/discovery_test.go @@ -0,0 +1,164 @@ +package mesh + +import ( + "net/http" + "strings" + "testing" +) + +func TestDecodeDiscoveryResponse(t *testing.T) { + // Shapes taken from a live control plane: enterprise types report a + // shortName but no display names, while several policies report display + // names but no shortName. + body := []byte(`{"resources":[ + {"name":"AccessAudit","path":"accessaudits","scope":"Global","shortName":"aa", + "readOnly":false,"singularDisplayName":"","pluralDisplayName":""}, + {"name":"CircuitBreaker","path":"circuit-breakers","scope":"Mesh","shortName":"", + "readOnly":false,"singularDisplayName":"Circuit Breaker","pluralDisplayName":"Circuit Breakers", + "policy":{"isTargetRef":false}}, + {"name":"DataplaneInsight","path":"dataplane-insights","scope":"Mesh","readOnly":true, + "singularDisplayName":"Dataplane Insight","pluralDisplayName":"Dataplane Insights"} + ]}`) + + descriptors, err := decodeDiscoveryResponse(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(descriptors) != 3 { + t.Fatalf("expected 3 descriptors, got %d", len(descriptors)) + } + + audit := descriptors[0] + if audit.IsMeshScoped() { + t.Error("AccessAudit is global scoped and should not be mesh scoped") + } + if audit.Alias() != "aa" { + t.Errorf("expected alias aa, got %q", audit.Alias()) + } + // Display names are empty on the wire, so both must fall back. + if audit.Singular() != "AccessAudit" { + t.Errorf("expected singular to fall back to the type name, got %q", audit.Singular()) + } + if audit.Plural() != "accessaudits" { + t.Errorf("expected plural to fall back to the path, got %q", audit.Plural()) + } + if audit.IsPolicy() { + t.Error("AccessAudit carries no policy block and is not a policy") + } + + breaker := descriptors[1] + if !breaker.IsMeshScoped() { + t.Error("CircuitBreaker is mesh scoped") + } + if breaker.Alias() != "" { + t.Errorf("expected no alias, got %q", breaker.Alias()) + } + if breaker.Singular() != "Circuit Breaker" { + t.Errorf("expected reported singular display name, got %q", breaker.Singular()) + } + if !breaker.IsPolicy() { + t.Error("CircuitBreaker carries a policy block and is a policy") + } + + if !descriptors[2].ReadOnly { + t.Error("DataplaneInsight is read only") + } +} + +func TestDecodeDiscoveryResponseSkipsPathlessDescriptors(t *testing.T) { + // Nothing can be addressed without a path, so such entries are dropped + // rather than surfaced as unusable commands. + body := []byte(`{"resources":[ + {"name":"Usable","path":"usables","scope":"Global"}, + {"name":"Unaddressable","path":"","scope":"Global"}, + {"name":"Blank","path":" ","scope":"Mesh"} + ]}`) + + descriptors, err := decodeDiscoveryResponse(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(descriptors) != 1 { + t.Fatalf("expected 1 usable descriptor, got %d", len(descriptors)) + } + if descriptors[0].Name != "Usable" { + t.Errorf("expected the descriptor with a path to survive, got %q", descriptors[0].Name) + } +} + +func TestDecodeDiscoveryResponseInvalidJSON(t *testing.T) { + if _, err := decodeDiscoveryResponse([]byte(`not json`)); err == nil { + t.Fatal("expected an error for malformed JSON") + } +} + +func TestDescriptorPaths(t *testing.T) { + meshScoped := ResourceDescriptor{Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh} + globalScoped := ResourceDescriptor{Name: "Zone", Path: "zones", Scope: ScopeGlobal} + + if got := meshScoped.CollectionPath("prod"); got != "/meshes/prod/dataplanes" { + t.Errorf("unexpected mesh scoped collection path: %s", got) + } + if got := meshScoped.ItemPath("prod", "dp-1"); got != "/meshes/prod/dataplanes/dp-1" { + t.Errorf("unexpected mesh scoped item path: %s", got) + } + // An empty mesh falls back to the default, matching kumactl. + if got := meshScoped.CollectionPath(""); got != "/meshes/default/dataplanes" { + t.Errorf("expected the default mesh to be applied, got %s", got) + } + + if got := globalScoped.CollectionPath("prod"); got != "/zones" { + t.Errorf("global scoped paths ignore the mesh, got %s", got) + } + if got := globalScoped.ItemPath("prod", "zone-1"); got != "/zones/zone-1" { + t.Errorf("unexpected global scoped item path: %s", got) + } +} + +func TestBuildDiscoveryError(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantSubstr string + }{ + { + name: "unauthorized names the credential", + statusCode: http.StatusUnauthorized, + wantSubstr: "credential", + }, + { + name: "forbidden names the credential", + statusCode: http.StatusForbidden, + wantSubstr: "credential", + }, + { + name: "not found names the version requirement", + statusCode: http.StatusNotFound, + wantSubstr: "2.13", + }, + { + name: "other statuses surface the body", + statusCode: http.StatusInternalServerError, + body: "boom", + wantSubstr: "boom", + }, + { + name: "empty body still reports the status", + statusCode: http.StatusBadGateway, + wantSubstr: "502", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := buildDiscoveryError(tc.statusCode, []byte(tc.body)) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("expected error %q to contain %q", err.Error(), tc.wantSubstr) + } + }) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/getResourceTypes.go b/internal/cmd/root/products/konnect/mesh/getResourceTypes.go new file mode 100644 index 000000000..49705bdb8 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getResourceTypes.go @@ -0,0 +1,166 @@ +package mesh + +import ( + "fmt" + "slices" + "strings" + + "charm.land/bubbles/v2/table" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/tableview" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/meta" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/segmentio/cli" + "github.com/spf13/cobra" +) + +var ( + getResourceTypesShort = i18n.T("root.products.konnect.mesh.getResourceTypesShort", + "List the resource types a Kong Mesh control plane serves") + + getResourceTypesLong = normalizers.LongDesc(i18n.T("root.products.konnect.mesh.getResourceTypesLong", + `List the resource types the selected Kong Mesh control plane serves, +along with the name each is addressed by, its scope, and whether it can be +modified. + +Use this to discover what a control plane supports, including policies and +enterprise resource types that vary between Kong Mesh releases.`)) + + getResourceTypesExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.getResourceTypesExample", + fmt.Sprintf(` + # List every resource type a control plane serves + %[1]s get mesh resource-types --control-plane-id + + # Show the full descriptors reported by the control plane + %[1]s get mesh resource-types --control-plane-id -o json + `, meta.CLIName))) +) + +// resourceTypeRow is the text table projection of a resource descriptor. +type resourceTypeRow struct { + Name string `table:"NAME"` + Alias string `table:"ALIAS"` + Scope string `table:"SCOPE"` + Kind string `table:"KIND"` + Writable string `table:"WRITABLE"` +} + +type getResourceTypesCmd struct { + *cobra.Command +} + +func newGetResourceTypesCmd( + verb verbs.VerbValue, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) *cobra.Command { + c := &getResourceTypesCmd{} + cmdObj := &cobra.Command{ + Use: "resource-types", + Aliases: []string{"resource-type", "types"}, + Short: getResourceTypesShort, + Long: getResourceTypesLong, + Example: getResourceTypesExample, + RunE: c.runE, + } + + c.Command = cmdObj + if parentPreRun != nil { + c.PreRunE = parentPreRun + } + if addParentFlags != nil { + addParentFlags(verb, c.Command) + } + + return c.Command +} + +func (c *getResourceTypesCmd) runE(cobraCmd *cobra.Command, args []string) error { + helper := cmd.BuildHelper(cobraCmd, args) + if len(helper.GetArgs()) > 0 { + return &cmd.ConfigurationError{ + Err: fmt.Errorf("the resource-types command does not accept arguments"), + } + } + + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + printer, err := cli.Format(outType.String(), helper.GetStreams().Out) + if err != nil { + return err + } + defer printer.Flush() + + descriptors, err := Discover(helper) + if err != nil { + return cmd.PrepareExecutionError("failed to retrieve mesh resource types", err, helper.GetCmd()) + } + + rows := buildResourceTypeRows(descriptors) + + // The default text table curates itself down to a few columns chosen by + // heuristic. Every column here carries information an operator needs to + // act, so the table is declared exactly. + return tableview.RenderForFormat( + helper, + false, + outType, + printer, + helper.GetStreams(), + rows, + descriptors, + "Mesh Resource Types", + tableview.WithExactCustomTable(resourceTypeHeaders, toResourceTypeTableRows(rows)), + tableview.WithRootLabel(helper.GetCmd().Name()), + ) +} + +// resourceTypeHeaders is the column set for the resource type listing. +var resourceTypeHeaders = []string{"NAME", "ALIAS", "SCOPE", "KIND", "WRITABLE"} + +func toResourceTypeTableRows(rows []resourceTypeRow) []table.Row { + tableRows := make([]table.Row, 0, len(rows)) + for _, row := range rows { + tableRows = append(tableRows, table.Row{row.Name, row.Alias, row.Scope, row.Kind, row.Writable}) + } + return tableRows +} + +// buildResourceTypeRows projects descriptors into table rows, sorted by the +// name used to address each type so the listing reads predictably. +func buildResourceTypeRows(descriptors []ResourceDescriptor) []resourceTypeRow { + rows := make([]resourceTypeRow, 0, len(descriptors)) + for _, descriptor := range descriptors { + rows = append(rows, resourceTypeRow{ + Name: descriptor.Path, + Alias: descriptor.Alias(), + Scope: descriptor.Scope, + Kind: describeKind(descriptor), + Writable: writableLabel(descriptor), + }) + } + + slices.SortFunc(rows, func(a, b resourceTypeRow) int { + return strings.Compare(a.Name, b.Name) + }) + return rows +} + +func describeKind(descriptor ResourceDescriptor) string { + if descriptor.IsPolicy() { + return "policy" + } + return "resource" +} + +func writableLabel(descriptor ResourceDescriptor) string { + if descriptor.ReadOnly { + return "no" + } + return "yes" +} diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go new file mode 100644 index 000000000..cb88ea534 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -0,0 +1,81 @@ +package mesh + +import ( + "fmt" + + "github.com/kong/kongctl/internal/cmd" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/meta" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/spf13/cobra" +) + +const CommandName = meshcommon.CommandName + +var ( + meshUse = CommandName + + meshShort = i18n.T("root.products.konnect.mesh.meshShort", + "Manage Kong Mesh control plane resources") + + meshLong = normalizers.LongDesc(i18n.T("root.products.konnect.mesh.meshLong", + `The mesh command works with resources on a Kong Mesh control plane. + +The resource types available are reported by the control plane itself, so +policies and resource types added by newer Kong Mesh releases are usable +without upgrading kongctl. + +Kong Mesh 2.13 or later is required.`)) + + meshExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.meshExample", + fmt.Sprintf(` + # List the resource types a control plane serves + %[1]s get mesh resource-types --control-plane-id + + # List dataplanes in the default mesh + %[1]s get mesh dataplanes --control-plane-id + `, meta.CLIName))) +) + +// NewMeshCmd builds the mesh container command for a verb. +// +// It follows the same constructor shape as the other product containers so +// that the verb packages can register it both directly, giving +// "kongctl get mesh ...", and under the konnect subtree. +func NewMeshCmd( + verb verbs.VerbValue, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) (*cobra.Command, error) { + baseCmd := &cobra.Command{ + Use: meshUse, + Short: meshShort, + Long: meshLong, + Example: meshExample, + } + + if parentPreRun != nil { + baseCmd.PreRunE = parentPreRun + } + if addParentFlags != nil { + addParentFlags(verb, baseCmd) + } + meshcommon.AddControlPlaneFlags(baseCmd.PersistentFlags()) + + baseCmd.RunE = func(cmdObj *cobra.Command, args []string) error { + helper := cmd.BuildHelper(cmdObj, args) + if _, err := helper.GetOutputFormat(); err != nil { + return err + } + return cmd.RequireSubcommand(cmdObj, args) + } + cmd.MarkRequiresSubcommand(baseCmd) + + if verb == verbs.Get { + baseCmd.AddCommand(newGetResourceTypesCmd(verb, addParentFlags, parentPreRun)) + } + + return baseCmd, nil +} diff --git a/internal/cmd/root/verbs/get/get.go b/internal/cmd/root/verbs/get/get.go index 743fb716f..7b20f3a6d 100644 --- a/internal/cmd/root/verbs/get/get.go +++ b/internal/cmd/root/verbs/get/get.go @@ -210,6 +210,12 @@ Setting this value overrides tokens obtained from the login command. } cmd.AddCommand(regionsCmd) + meshCmd, err := NewDirectMeshCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(meshCmd) + eventGatewayControlPlaneCmd, err := NewDirectEventGatewayCmd() if err != nil { return nil, err diff --git a/internal/cmd/root/verbs/get/mesh.go b/internal/cmd/root/verbs/get/mesh.go new file mode 100644 index 000000000..af018d48f --- /dev/null +++ b/internal/cmd/root/verbs/get/mesh.go @@ -0,0 +1,72 @@ +package get + +import ( + "context" + "fmt" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/root/products" + "github.com/kong/kongctl/internal/cmd/root/products/konnect" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/meta" + "github.com/spf13/cobra" +) + +// NewDirectMeshCmd creates a mesh command that works at the root level, +// giving "kongctl get mesh ..." alongside the explicit +// "kongctl get konnect mesh ..." form. +// +// Reaching Kong Mesh through one command path regardless of whether the control +// plane is Konnect hosted or self managed is deliberate: where a control plane +// runs is a connection detail, not a different command. +func NewDirectMeshCmd() (*cobra.Command, error) { + addFlags := func(_ verbs.VerbValue, cmdObj *cobra.Command) { + cmdObj.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmdObj.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token. +- Config path: [ %s ]`, common.PATConfigPath)) + } + + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, helpers.SDKAPIFactory(common.KonnectSDKFactory)) + c.SetContext(ctx) + + if err := bindKonnectFlags(c, args); err != nil { + return err + } + + helper := cmd.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + return meshcommon.BindFlags(cfg, c.Flags()) + } + + meshCmd, err := mesh.NewMeshCmd(Verb, addFlags, preRunE) + if err != nil { + return nil, err + } + + meshCmd.Example = fmt.Sprintf(` # List the resource types a control plane serves + %[1]s get mesh resource-types --control-plane-id + + # List dataplanes without specifying the product + %[1]s get mesh dataplanes --control-plane-id `, meta.CLIName) + + return meshCmd, nil +} From f271dcfbcd8c8352d35aa6d4c767a7aa9c7fb160 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 9 Sep 2026 09:12:32 +0100 Subject: [PATCH 02/15] feat(mesh): read any resource type from a Kong Mesh 3 control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `get mesh [name]` for every resource type a control plane advertises, and retargets the hosted client at the Kong Mesh 3 API line. A single Konnect mesh control plane is fronted by two prefixes that reach two different control planes: /v1/mesh/control-planes/{id}/api serves the 2.14 resource set, while /v3/mesh/control-planes/{id} serves Kong Mesh 3. The /api segment exists only on the v1 line. Verified against a live control plane, where the v3 line reports 38 resource types against the v1 line's 59 — the 21 absent types being exactly those Kong Mesh 3 removes. kongctl supports Kong Mesh 3 only, so it now composes the v3 prefix. The read is driven entirely by /_resources: the type is resolved from the descriptor by path, type name or short name, the URL is composed from its path and scope, and the columns come from the three printers kumactl carries on v3. No resource type is named in code, so a type added by a newer Kong Mesh release works without a kongctl release — confirmed with Workload and MeshIdentity, which are new in 3, and with the four enterprise types, which need no enterprise code. Notable details: - There is no Mesh printer. Mesh.mtls was removed from the API in Kong Mesh 3, so the NAME/mTLS/AGE columns kumactl printed on 2.x cannot be populated, and Mesh falls through to the global printer. A test asserts no printer emits an mTLS column. - TAGS is the resource's labels merged with its gateway tags, labels winning, which is what the control plane itself displays. It is not the inbound tags an older Kuma showed. - Optional /_resources fields keep their fallbacks. The v3 schema marks all eight required, but a live v3 control plane leaves shortName empty on 6 of 38 descriptors and the display names empty on 3, so validating instead of falling back would reject a valid control plane. Empty shortName marks a type that is deliberately not KRI addressable. - Errors surface the control plane's own AIP-193 detail rather than a status code, shared with Konnect's envelope. - JSON and YAML pass the payload through unchanged, envelope included, so kumactl-era scripts keep parsing it. - Resource types cannot be cobra subcommands without a network call at startup, so the container accepts arbitrary args. A near miss of a real subcommand is still reported as a mistyped subcommand rather than sent to the control plane. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 5ae477e351faed54c9a0c2a266a60730246baf2a) --- .../cmd/root/products/konnect/mesh/client.go | 146 +++++++++++++++ .../products/konnect/mesh/common/common.go | 20 ++- .../konnect/mesh/common/common_test.go | 6 +- .../root/products/konnect/mesh/discovery.go | 79 +------- .../products/konnect/mesh/discovery_test.go | 22 ++- .../products/konnect/mesh/getResources.go | 153 ++++++++++++++++ .../konnect/mesh/getResources_test.go | 75 ++++++++ .../cmd/root/products/konnect/mesh/mesh.go | 35 +++- .../root/products/konnect/mesh/printers.go | 169 ++++++++++++++++++ .../products/konnect/mesh/printers_test.go | 159 ++++++++++++++++ .../cmd/root/products/konnect/mesh/resolve.go | 86 +++++++++ .../products/konnect/mesh/resolve_test.go | 91 ++++++++++ 12 files changed, 956 insertions(+), 85 deletions(-) create mode 100644 internal/cmd/root/products/konnect/mesh/client.go create mode 100644 internal/cmd/root/products/konnect/mesh/getResources.go create mode 100644 internal/cmd/root/products/konnect/mesh/getResources_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/printers.go create mode 100644 internal/cmd/root/products/konnect/mesh/printers_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/resolve.go create mode 100644 internal/cmd/root/products/konnect/mesh/resolve_test.go diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go new file mode 100644 index 000000000..5923db8c4 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -0,0 +1,146 @@ +package mesh + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/kong/kongctl/internal/cmd" + konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/konnect/apiutil" + "github.com/kong/kongctl/internal/konnect/httpclient" +) + +// apiError is the error envelope Kong Mesh control planes return. It follows +// AIP-193, the same shape Konnect uses, so the control plane's own wording can +// be surfaced rather than a status code. +// +// Observed deviations from the published schema: type is a bare path such as +// "/std-errors" rather than a URI, and detail is duplicated as details. Only +// detail is read. +type apiError struct { + Status int `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + Instance string `json:"instance"` + InvalidParameters []invalidParameter `json:"invalid_parameters,omitempty"` +} + +// invalidParameter carries field level validation feedback. +type invalidParameter struct { + Field string `json:"field"` + Reason string `json:"reason"` + Source string `json:"source"` +} + +// Error renders the control plane's own description of the failure, preferring +// detail because that is the field carrying the actionable wording. +func (e apiError) Error() string { + msg := strings.TrimSpace(e.Detail) + if title := strings.TrimSpace(e.Title); title != "" { + if msg == "" || strings.EqualFold(msg, title) { + msg = title + } else { + msg = title + ": " + msg + } + } + if msg == "" { + msg = fmt.Sprintf("control plane returned status %d", e.Status) + } + + for _, p := range e.InvalidParameters { + msg += fmt.Sprintf("\n %s (%s): %s", p.Field, p.Source, p.Reason) + } + return msg +} + +// fetch performs a GET against the selected control plane and returns the +// response body. +// +// Every mesh read goes through here so that control plane resolution, +// credentials, and error rendering behave identically across commands. +func fetch(helper cmd.Helper, path string) ([]byte, error) { + cfg, err := helper.GetConfig() + if err != nil { + return nil, err + } + + logger, err := helper.GetLogger() + if err != nil { + return nil, err + } + + baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + if err != nil { + return nil, err + } + + tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) + if err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + ctx := helper.GetContext() + if ctx == nil { + ctx = context.Background() + } + if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + result, err := apiutil.RequestWithTokenSource( + ctx, + httpclient.NewLoggingHTTPClient(logger), + http.MethodGet, + baseURL, + path, + tokenSource, + nil, + nil, + ) + if err != nil { + return nil, err + } + + logger.Debug("mesh control plane call completed", "path", path, "status_code", result.StatusCode) + + if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { + return nil, buildAPIError(result.StatusCode, result.Body) + } + + return result.Body, nil +} + +// buildAPIError turns a failed response into an actionable error, using the +// control plane's own envelope when it sent one. +func buildAPIError(statusCode int, body []byte) error { + var envelope apiError + if err := json.Unmarshal(body, &envelope); err == nil { + if strings.TrimSpace(envelope.Detail) != "" || strings.TrimSpace(envelope.Title) != "" { + if envelope.Status == 0 { + envelope.Status = statusCode + } + return envelope + } + } + + // No envelope to quote, so fall back to the causes an operator can address. + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf( + "not authorized to read the Kong Mesh control plane (status %d); "+ + "check that the credential grants access to this control plane", statusCode) + case http.StatusNotFound: + return fmt.Errorf( + "control plane API not found (status %d); "+ + "check the control plane selection, and that it is running Kong Mesh 3.0 or later", statusCode) + } + + if detail := strings.TrimSpace(string(body)); detail != "" { + return fmt.Errorf("control plane request failed with status %d: %s", statusCode, detail) + } + return fmt.Errorf("control plane request failed with status %d", statusCode) +} diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 1e8378559..75fc21937 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -20,6 +20,11 @@ const ( MeshFlagName = "mesh" MeshFlagShorthand = "m" + // AllMeshesFlagName lists a mesh scoped type across every mesh. Kuma + // registers mesh scoped list endpoints at both /meshes/{mesh}/{path} and + // /{path}, and the second lists across all meshes. + AllMeshesFlagName = "all-meshes" + // DefaultMesh matches the default kumactl applies to mesh scoped // resources, so that commands carrying no --mesh behave the same way. DefaultMesh = "default" @@ -30,12 +35,20 @@ var ( ControlPlaneNameConfigPath = "konnect.mesh.control-plane.name" ControlPlaneURLConfigPath = "konnect.mesh.control-plane.url" MeshConfigPath = "konnect.mesh.mesh" + AllMeshesConfigPath = "konnect.mesh.all-meshes" ) // controlPlaneAPIPathFormat fronts a Konnect hosted Kong Mesh control plane's // own API. The control plane identifier travels in the path, so callers do not // send a separate tenant header. -const controlPlaneAPIPathFormat = "/v1/mesh/control-planes/%s/api" +// +// The leading segment selects the Kong Mesh API line, and one Konnect control +// plane serves more than one: /v1/mesh/control-planes/{id}/api reaches a 2.14 +// control plane, while /v3/mesh/control-planes/{id} reaches a Kong Mesh 3 one. +// These are distinct control planes behind a single Konnect identifier, and the +// /api segment exists only on the v1 line. kongctl supports Kong Mesh 3 only, +// so it composes the v3 form. +const controlPlaneAPIPathFormat = "/v3/mesh/control-planes/%s" // ControlPlaneAPIPath returns the Konnect path prefix for a hosted Kong Mesh // control plane API. @@ -117,6 +130,10 @@ func AddControlPlaneFlags(flags *pflag.FlagSet) { flags.StringP(MeshFlagName, MeshFlagShorthand, DefaultMesh, fmt.Sprintf(`Mesh that mesh scoped resources belong to. - Config path: [ %s ]`, MeshConfigPath)) + + flags.Bool(AllMeshesFlagName, false, + fmt.Sprintf(`List mesh scoped resources across every mesh instead of one. Ignored for global types. +- Config path: [ %s ]`, AllMeshesConfigPath)) } // BindFlags associates the control plane selection flags with their @@ -131,6 +148,7 @@ func BindFlags(cfg config.Hook, flags *pflag.FlagSet) error { {ControlPlaneNameFlagName, ControlPlaneNameConfigPath}, {ControlPlaneURLFlagName, ControlPlaneURLConfigPath}, {MeshFlagName, MeshConfigPath}, + {AllMeshesFlagName, AllMeshesConfigPath}, } for _, b := range bindings { diff --git a/internal/cmd/root/products/konnect/mesh/common/common_test.go b/internal/cmd/root/products/konnect/mesh/common/common_test.go index 8741eecdc..da2c96403 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common_test.go +++ b/internal/cmd/root/products/konnect/mesh/common/common_test.go @@ -27,7 +27,7 @@ func TestResolveControlPlaneAPIURLFromControlPlaneID(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - want := "https://us.api.konghq.com/v1/mesh/control-planes/5bf706d9-1e96-4a3a-bee4-cbf806d1dc1a/api" + want := "https://us.api.konghq.com/v3/mesh/control-planes/5bf706d9-1e96-4a3a-bee4-cbf806d1dc1a" if got != want { t.Errorf("expected %q, got %q", want, got) } @@ -46,7 +46,7 @@ func TestResolveControlPlaneAPIURLTrimsTrailingSlashOnBaseURL(t *testing.T) { if strings.Contains(got, "//v1") { t.Errorf("expected no doubled slash in %q", got) } - if got != "https://eu.api.konghq.com/v1/mesh/control-planes/cp-1/api" { + if got != "https://eu.api.konghq.com/v3/mesh/control-planes/cp-1" { t.Errorf("unexpected URL: %s", got) } } @@ -110,7 +110,7 @@ func TestResolveMesh(t *testing.T) { } func TestControlPlaneAPIPath(t *testing.T) { - if got := ControlPlaneAPIPath("cp-1"); got != "/v1/mesh/control-planes/cp-1/api" { + if got := ControlPlaneAPIPath("cp-1"); got != "/v3/mesh/control-planes/cp-1" { t.Errorf("unexpected path: %s", got) } } diff --git a/internal/cmd/root/products/konnect/mesh/discovery.go b/internal/cmd/root/products/konnect/mesh/discovery.go index 1a030efb0..cbf947948 100644 --- a/internal/cmd/root/products/konnect/mesh/discovery.go +++ b/internal/cmd/root/products/konnect/mesh/discovery.go @@ -1,17 +1,12 @@ package mesh import ( - "context" "encoding/json" "fmt" - "net/http" "strings" "github.com/kong/kongctl/internal/cmd" - konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" - "github.com/kong/kongctl/internal/konnect/apiutil" - "github.com/kong/kongctl/internal/konnect/httpclient" ) // discoveryPath is the control plane endpoint that describes every resource @@ -123,59 +118,11 @@ func (d ResourceDescriptor) ItemPath(mesh, name string) string { // Results are sorted by the control plane, which returns them alphabetically by // type name; callers relying on a specific order should sort explicitly. func Discover(helper cmd.Helper) ([]ResourceDescriptor, error) { - cfg, err := helper.GetConfig() + body, err := fetch(helper, discoveryPath) if err != nil { return nil, err } - - logger, err := helper.GetLogger() - if err != nil { - return nil, err - } - - baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) - if err != nil { - return nil, err - } - - tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) - if err != nil { - return nil, fmt.Errorf("resolve Konnect access token: %w", err) - } - - ctx := helper.GetContext() - if ctx == nil { - ctx = context.Background() - } - if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { - return nil, fmt.Errorf("resolve Konnect access token: %w", err) - } - - result, err := apiutil.RequestWithTokenSource( - ctx, - httpclient.NewLoggingHTTPClient(logger), - http.MethodGet, - baseURL, - discoveryPath, - tokenSource, - nil, - nil, - ) - if err != nil { - return nil, err - } - - logger.Debug( - "mesh resource discovery call completed", - "path", discoveryPath, - "status_code", result.StatusCode, - ) - - if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { - return nil, buildDiscoveryError(result.StatusCode, result.Body) - } - - return decodeDiscoveryResponse(result.Body) + return decodeDiscoveryResponse(body) } // decodeDiscoveryResponse parses a control plane discovery payload, dropping @@ -195,25 +142,3 @@ func decodeDiscoveryResponse(body []byte) ([]ResourceDescriptor, error) { } return descriptors, nil } - -// buildDiscoveryError turns a failed discovery response into an actionable -// error, calling out the causes an operator can address. -func buildDiscoveryError(statusCode int, body []byte) error { - detail := strings.TrimSpace(string(body)) - - switch statusCode { - case http.StatusUnauthorized, http.StatusForbidden: - return fmt.Errorf( - "not authorized to read the Kong Mesh control plane (status %d); "+ - "check that the credential grants access to this control plane", statusCode) - case http.StatusNotFound: - return fmt.Errorf( - "control plane API not found (status %d); "+ - "check the control plane selection, and that it is running Kong Mesh 2.13 or later", statusCode) - } - - if detail == "" { - return fmt.Errorf("mesh resource discovery failed with status %d", statusCode) - } - return fmt.Errorf("mesh resource discovery failed with status %d: %s", statusCode, detail) -} diff --git a/internal/cmd/root/products/konnect/mesh/discovery_test.go b/internal/cmd/root/products/konnect/mesh/discovery_test.go index fac593a89..4e4f78911 100644 --- a/internal/cmd/root/products/konnect/mesh/discovery_test.go +++ b/internal/cmd/root/products/konnect/mesh/discovery_test.go @@ -115,7 +115,7 @@ func TestDescriptorPaths(t *testing.T) { } } -func TestBuildDiscoveryError(t *testing.T) { +func TestBuildAPIError(t *testing.T) { tests := []struct { name string statusCode int @@ -135,7 +135,23 @@ func TestBuildDiscoveryError(t *testing.T) { { name: "not found names the version requirement", statusCode: http.StatusNotFound, - wantSubstr: "2.13", + wantSubstr: "3.0", + }, + { + // The control plane's own wording is preferred over anything + // kongctl would invent for the status code. + name: "AIP-193 envelope is quoted rather than the status", + statusCode: http.StatusMethodNotAllowed, + body: `{"type":"/std-errors","status":405,"title":"Method not allowed",` + + `"detail":"Not allowed on global CP","instance":"abc","details":"Not allowed on global CP"}`, + wantSubstr: "Not allowed on global CP", + }, + { + name: "envelope validation feedback names the field", + statusCode: http.StatusBadRequest, + body: `{"status":400,"title":"Invalid parameters","detail":"validation failed",` + + `"invalid_parameters":[{"field":"spec.targetRef","reason":"must be set","source":"body"}]}`, + wantSubstr: "spec.targetRef", }, { name: "other statuses surface the body", @@ -152,7 +168,7 @@ func TestBuildDiscoveryError(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := buildDiscoveryError(tc.statusCode, []byte(tc.body)) + err := buildAPIError(tc.statusCode, []byte(tc.body)) if err == nil { t.Fatal("expected an error") } diff --git a/internal/cmd/root/products/konnect/mesh/getResources.go b/internal/cmd/root/products/konnect/mesh/getResources.go new file mode 100644 index 000000000..754f1ef6f --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getResources.go @@ -0,0 +1,153 @@ +package mesh + +import ( + "encoding/json" + "fmt" + "time" + + "charm.land/bubbles/v2/table" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/tableview" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/config" + "github.com/segmentio/cli" +) + +// listEnvelope is the envelope Kuma wraps resource lists in. A single resource +// is returned bare, without one. +type listEnvelope struct { + Total int `json:"total"` + Items []map[string]any `json:"items"` + Next string `json:"next"` +} + +// runGetResources serves `get mesh [name]` for every resource type the +// control plane advertises. +// +// There is deliberately no per-type code and no compiled-in type table: the +// type is resolved from /_resources, the URL is composed from the descriptor's +// path and scope, and the columns come from the three printers in printers.go. +// A resource type added by a newer Kong Mesh release therefore works without a +// kongctl release. +func runGetResources(helper cmd.Helper, args []string) error { + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + printer, err := cli.Format(outType.String(), helper.GetStreams().Out) + if err != nil { + return err + } + defer printer.Flush() + + descriptors, err := Discover(helper) + if err != nil { + return cmd.PrepareExecutionError("failed to retrieve mesh resource types", err, helper.GetCmd()) + } + + descriptor, err := ResolveType(descriptors, args[0]) + if err != nil { + return &cmd.ConfigurationError{Err: err} + } + + var name string + if len(args) > 1 { + name = args[1] + } + + path, err := requestPath(cfg, descriptor, name) + if err != nil { + return &cmd.ConfigurationError{Err: err} + } + + body, err := fetch(helper, path) + if err != nil { + return cmd.PrepareExecutionError( + fmt.Sprintf("failed to retrieve mesh %s", descriptor.Plural()), err, helper.GetCmd()) + } + + // JSON and YAML print the control plane payload as it arrived, so scripts + // written against kumactl continue to parse it (NFR-1). + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return fmt.Errorf("failed to decode mesh %s response: %w", descriptor.Plural(), err) + } + + items, err := itemsFrom(body, name) + if err != nil { + return err + } + + rows := buildRows(descriptor, items, time.Now()) + headers := headersFor(descriptor) + + tableRows := make([]table.Row, 0, len(rows)) + for _, row := range rows { + tableRows = append(tableRows, table.Row(cellsFor(descriptor, row))) + } + + return tableview.RenderForFormat( + helper, + false, + outType, + printer, + helper.GetStreams(), + rows, + raw, + descriptor.Plural(), + tableview.WithExactCustomTable(headers, tableRows), + tableview.WithRootLabel(helper.GetCmd().Name()), + ) +} + +// requestPath composes the control plane path for the resolved type, applying +// the mesh only where the discovered scope calls for one. +func requestPath(cfg config.Hook, descriptor ResourceDescriptor, name string) (string, error) { + if !descriptor.IsMeshScoped() { + if name != "" { + return descriptor.ItemPath("", name), nil + } + return descriptor.CollectionPath(""), nil + } + + // Mesh scoped types are registered at both /meshes/{mesh}/{path} and + // /{path}, the latter listing across every mesh. + if cfg.GetBool(meshcommon.AllMeshesConfigPath) { + if name != "" { + return "", fmt.Errorf( + "--%s lists across meshes and cannot address a single resource; drop it and pass --%s", + meshcommon.AllMeshesFlagName, meshcommon.MeshFlagName) + } + return "/" + descriptor.Path, nil + } + + mesh := meshcommon.ResolveMesh(cfg) + if name != "" { + return descriptor.ItemPath(mesh, name), nil + } + return descriptor.CollectionPath(mesh), nil +} + +// itemsFrom extracts the rows to render. A list arrives wrapped in an +// envelope; a single named resource arrives bare. +func itemsFrom(body []byte, name string) ([]map[string]any, error) { + if name != "" { + var item map[string]any + if err := json.Unmarshal(body, &item); err != nil { + return nil, fmt.Errorf("failed to decode mesh resource response: %w", err) + } + return []map[string]any{item}, nil + } + + var envelope listEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("failed to decode mesh resource list response: %w", err) + } + return envelope.Items, nil +} diff --git a/internal/cmd/root/products/konnect/mesh/getResources_test.go b/internal/cmd/root/products/konnect/mesh/getResources_test.go new file mode 100644 index 000000000..1b5f0f45b --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getResources_test.go @@ -0,0 +1,75 @@ +package mesh + +import ( + "strings" + "testing" + + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + configtest "github.com/kong/kongctl/test/config" +) + +// stubConfig returns a config hook answering only the given paths, so a test +// states exactly the configuration it depends on. +func stubConfig(values map[string]string, flags map[string]bool) *configtest.MockConfigHook { + return &configtest.MockConfigHook{ + GetStringMock: func(key string) string { return values[key] }, + GetBoolMock: func(key string) bool { return flags[key] }, + } +} + +func TestRequestPathScoping(t *testing.T) { + dataplanes := ResourceDescriptor{Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh} + zones := ResourceDescriptor{Name: "Zone", Path: "zones", Scope: ScopeGlobal} + + tests := []struct { + name string + descriptor ResourceDescriptor + mesh string + allMeshes bool + resource string + want string + }{ + {"mesh scoped list defaults to the default mesh", dataplanes, "", false, "", "/meshes/default/dataplanes"}, + {"mesh scoped list honours --mesh", dataplanes, "prod", false, "", "/meshes/prod/dataplanes"}, + {"mesh scoped item", dataplanes, "prod", false, "dp-1", "/meshes/prod/dataplanes/dp-1"}, + // Kuma registers mesh scoped lists at /{path} as well, which lists + // across every mesh. + {"--all-meshes drops the mesh segment", dataplanes, "prod", true, "", "/dataplanes"}, + // A global type takes no mesh, so --mesh must not appear in its path. + {"global list ignores --mesh", zones, "prod", false, "", "/zones"}, + {"global item ignores --mesh", zones, "prod", false, "zone-1", "/zones/zone-1"}, + {"global type ignores --all-meshes", zones, "", true, "", "/zones"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := stubConfig( + map[string]string{meshcommon.MeshConfigPath: tc.mesh}, + map[string]bool{meshcommon.AllMeshesConfigPath: tc.allMeshes}, + ) + + got, err := requestPath(cfg, tc.descriptor, tc.resource) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("path = %q, want %q", got, tc.want) + } + }) + } +} + +// --all-meshes lists across meshes, so it cannot also address one resource: +// the request would be ambiguous about which mesh's resource was meant. +func TestRequestPathRejectsAllMeshesWithAName(t *testing.T) { + cfg := stubConfig(nil, map[string]bool{meshcommon.AllMeshesConfigPath: true}) + descriptor := ResourceDescriptor{Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh} + + _, err := requestPath(cfg, descriptor, "dp-1") + if err == nil { + t.Fatal("expected --all-meshes with a resource name to be rejected") + } + if !strings.Contains(err.Error(), meshcommon.AllMeshesFlagName) { + t.Errorf("error should name the offending flag, got %q", err) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go index cb88ea534..460b11ec8 100644 --- a/internal/cmd/root/products/konnect/mesh/mesh.go +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -27,7 +27,7 @@ The resource types available are reported by the control plane itself, so policies and resource types added by newer Kong Mesh releases are usable without upgrading kongctl. -Kong Mesh 2.13 or later is required.`)) +Kong Mesh 3.0 or later is required.`)) meshExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.meshExample", fmt.Sprintf(` @@ -36,6 +36,15 @@ Kong Mesh 2.13 or later is required.`)) # List dataplanes in the default mesh %[1]s get mesh dataplanes --control-plane-id + + # Read one resource, by type and name + %[1]s get mesh meshes default --control-plane-id + + # Address a type by its short name, in a named mesh + %[1]s get mesh dp -m prod --control-plane-id + + # List a mesh scoped type across every mesh + %[1]s get mesh meshtrafficpermissions --all-meshes --control-plane-id `, meta.CLIName))) ) @@ -64,11 +73,35 @@ func NewMeshCmd( } meshcommon.AddControlPlaneFlags(baseCmd.PersistentFlags()) + // Resource types come from the control plane at runtime, so they cannot be + // registered as subcommands without a network call at startup. Arbitrary + // args are accepted instead and dispatched to the generic read, which + // resolves the type against /_resources. + baseCmd.Args = cobra.ArbitraryArgs + // Cobra applies this default only on its own suggestion path, and the + // dispatch below calls SuggestionsFor directly to tell a mistyped + // subcommand apart from a resource type. + baseCmd.SuggestionsMinimumDistance = 2 baseCmd.RunE = func(cmdObj *cobra.Command, args []string) error { helper := cmd.BuildHelper(cmdObj, args) if _, err := helper.GetOutputFormat(); err != nil { return err } + if verb == verbs.Get && len(args) > 0 { + // A near miss of a real subcommand is a mistyped subcommand, not a + // resource type. Saying so here keeps that error immediate, rather + // than sending a doomed request to the control plane first. + if len(cmdObj.SuggestionsFor(args[0])) > 0 { + return cmd.UnknownSubcommandError(cmdObj, args[0]) + } + if len(args) > 2 { + return &cmd.ConfigurationError{ + Err: fmt.Errorf( + "expected a resource type and an optional name, got %d arguments", len(args)), + } + } + return runGetResources(helper, args) + } return cmd.RequireSubcommand(cmdObj, args) } cmd.MarkRequiresSubcommand(baseCmd) diff --git a/internal/cmd/root/products/konnect/mesh/printers.go b/internal/cmd/root/products/konnect/mesh/printers.go new file mode 100644 index 000000000..e81361b39 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/printers.go @@ -0,0 +1,169 @@ +package mesh + +import ( + "fmt" + "maps" + "slices" + "sort" + "strings" + "time" +) + +// Table printers reproducing the columns kumactl prints on Kong Mesh 3. +// +// kumactl's registry is three printers: a bespoke one for Dataplane and a +// generic pair keyed on scope. Everything else — every policy, every +// enterprise type, every type added by a newer release — falls through to the +// generic pair, which is why no per-type code is needed here. +// +// The Mesh printer kumactl carried on 2.x (NAME, mTLS, AGE) is deliberately +// absent: Mesh.mtls was removed from the API in Kong Mesh 3, so the column +// cannot be populated. Do not reintroduce it in any derived form. + +// resourceRow is one rendered row. Columns not used by the resolved printer +// are left empty. +type resourceRow struct { + Mesh string + Name string + Tags string + Address string + Age string +} + +// headersFor returns the column set for a resource type, matching kumactl. +func headersFor(d ResourceDescriptor) []string { + switch { + case d.Name == "Dataplane": + return []string{"MESH", "NAME", "TAGS", "ADDRESS", "AGE"} + case d.IsMeshScoped(): + return []string{"MESH", "NAME", "AGE"} + default: + return []string{"NAME", "AGE"} + } +} + +// cellsFor projects a row onto the resolved column set. +func cellsFor(d ResourceDescriptor, row resourceRow) []string { + switch { + case d.Name == "Dataplane": + return []string{row.Mesh, row.Name, row.Tags, row.Address, row.Age} + case d.IsMeshScoped(): + return []string{row.Mesh, row.Name, row.Age} + default: + return []string{row.Name, row.Age} + } +} + +// buildRows projects control plane items into rows. +func buildRows(d ResourceDescriptor, items []map[string]any, now time.Time) []resourceRow { + rows := make([]resourceRow, 0, len(items)) + for _, item := range items { + rows = append(rows, resourceRow{ + Mesh: stringField(item, "mesh"), + Name: stringField(item, "name"), + Tags: displayTags(item), + Address: dataplaneAddress(item), + Age: age(item, now), + }) + } + return rows +} + +// age renders the time since the resource was last modified, in kumactl's +// format, so that existing eyes and awk scripts read it the same way. +func age(item map[string]any, now time.Time) string { + raw := stringField(item, "modificationTime") + if raw == "" { + raw = stringField(item, "creationTime") + } + if raw == "" { + return "-" + } + t, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return "-" + } + return duration(now.Sub(t)) +} + +// duration mirrors kumactl's compact age rendering. +func duration(d time.Duration) string { + switch seconds := int(d.Seconds()); { + case seconds < -1: + return "never" + case seconds < 0: + return "0s" + case seconds < 60: + return fmt.Sprintf("%ds", seconds) + } + if minutes := int(d.Minutes()); minutes < 60 { + return fmt.Sprintf("%dm", minutes) + } + hours := int(d.Hours()) + if hours < 24 { + return fmt.Sprintf("%dh", hours) + } + if hours < 24*365 { + return fmt.Sprintf("%dd", hours/24) + } + return fmt.Sprintf("%dy", hours/24/365) +} + +// displayTags renders the TAGS column for a Dataplane. +// +// On Kong Mesh 3 this is the resource's labels merged with its gateway tags, +// not the inbound tags an older Kuma displayed. Labels win on conflict, which +// is what the control plane itself does. +func displayTags(item map[string]any) string { + tags := map[string][]string{} + + for key, value := range mapField(item, "labels") { + if s, ok := value.(string); ok { + tags[key] = []string{s} + } + } + + gateway := mapField(mapField(item, "networking"), "gateway") + for key, value := range mapField(gateway, "tags") { + if _, taken := tags[key]; taken { + continue + } + if s, ok := value.(string); ok { + tags[key] = []string{s} + } + } + + rendered := make([]string, 0, len(tags)) + for _, key := range slices.Sorted(maps.Keys(tags)) { + values := tags[key] + sort.Strings(values) + rendered = append(rendered, fmt.Sprintf("%s=%s", key, strings.Join(values, ","))) + } + sort.Strings(rendered) + return strings.Join(rendered, " ") +} + +// dataplaneAddress reads the ADDRESS column for a Dataplane. +func dataplaneAddress(item map[string]any) string { + return stringField(mapField(item, "networking"), "address") +} + +func stringField(m map[string]any, key string) string { + if m == nil { + return "" + } + if s, ok := m[key].(string); ok { + return s + } + return "" +} + +func mapField(m map[string]any, key string) map[string]any { + if m == nil { + return nil + } + if nested, ok := m[key].(map[string]any); ok { + return nested + } + return nil +} diff --git a/internal/cmd/root/products/konnect/mesh/printers_test.go b/internal/cmd/root/products/konnect/mesh/printers_test.go new file mode 100644 index 000000000..f072a4b14 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/printers_test.go @@ -0,0 +1,159 @@ +package mesh + +import ( + "encoding/json" + "testing" + "time" +) + +func TestHeadersFor(t *testing.T) { + tests := []struct { + name string + descriptor ResourceDescriptor + want []string + }{ + { + "Dataplane has its own printer", + ResourceDescriptor{Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh}, + []string{"MESH", "NAME", "TAGS", "ADDRESS", "AGE"}, + }, + { + "any other mesh scoped type", + ResourceDescriptor{Name: "MeshTimeout", Path: "meshtimeouts", Scope: ScopeMesh}, + []string{"MESH", "NAME", "AGE"}, + }, + { + // Mesh.mtls was removed from the API in Kong Mesh 3, so Mesh falls + // through to the global printer rather than carrying an mTLS column. + "Mesh falls through to the global printer", + ResourceDescriptor{Name: "Mesh", Path: "meshes", Scope: ScopeGlobal}, + []string{"NAME", "AGE"}, + }, + { + "any other global type", + ResourceDescriptor{Name: "Zone", Path: "zones", Scope: ScopeGlobal}, + []string{"NAME", "AGE"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := headersFor(tc.descriptor) + if len(got) != len(tc.want) { + t.Fatalf("headers = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("headers = %v, want %v", got, tc.want) + } + } + }) + } +} + +// No printer may emit an mTLS column: the field does not exist on Kong Mesh 3, +// so any value shown would be fabricated. +func TestNoPrinterEmitsMTLSColumn(t *testing.T) { + for _, d := range []ResourceDescriptor{ + {Name: "Mesh", Path: "meshes", Scope: ScopeGlobal}, + {Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh}, + {Name: "MeshTimeout", Path: "meshtimeouts", Scope: ScopeMesh}, + } { + for _, h := range headersFor(d) { + if h == "mTLS" || h == "MTLS" { + t.Errorf("%s printer emits an mTLS column", d.Name) + } + } + } +} + +func TestDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {30 * time.Second, "30s"}, + {90 * time.Second, "1m"}, + {2 * time.Hour, "2h"}, + {50 * time.Hour, "2d"}, + {24 * 400 * time.Hour, "1y"}, + {-5 * time.Second, "never"}, + } + for _, tc := range tests { + if got := duration(tc.d); got != tc.want { + t.Errorf("duration(%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +// The TAGS column on Kong Mesh 3 is the resource's labels merged with its +// gateway tags, with labels winning — not the inbound tags older Kuma showed. +func TestDisplayTags(t *testing.T) { + var item map[string]any + body := `{ + "labels": {"kuma.io/zone": "east", "app": "backend"}, + "networking": {"address": "10.0.0.1", "gateway": {"tags": {"role": "edge", "app": "ignored"}}} + }` + if err := json.Unmarshal([]byte(body), &item); err != nil { + t.Fatal(err) + } + + want := "app=backend kuma.io/zone=east role=edge" + if got := displayTags(item); got != want { + t.Errorf("displayTags = %q, want %q", got, want) + } + if got := dataplaneAddress(item); got != "10.0.0.1" { + t.Errorf("address = %q, want 10.0.0.1", got) + } +} + +func TestDisplayTagsEmpty(t *testing.T) { + if got := displayTags(map[string]any{}); got != "" { + t.Errorf("expected no tags, got %q", got) + } + if got := dataplaneAddress(map[string]any{}); got != "" { + t.Errorf("expected no address, got %q", got) + } +} + +func TestAgePrefersModificationTime(t *testing.T) { + now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC) + item := map[string]any{ + "creationTime": "2026-09-01T12:00:00Z", + "modificationTime": "2026-09-08T10:00:00Z", + } + if got := age(item, now); got != "2h" { + t.Errorf("age = %q, want 2h from modificationTime", got) + } + + // Falls back to creationTime, then to a placeholder. + if got := age(map[string]any{"creationTime": "2026-09-08T11:00:00Z"}, now); got != "1h" { + t.Errorf("age = %q, want 1h from creationTime", got) + } + if got := age(map[string]any{}, now); got != "-" { + t.Errorf("age = %q, want -", got) + } + if got := age(map[string]any{"modificationTime": "not-a-time"}, now); got != "-" { + t.Errorf("age = %q, want - for an unparseable time", got) + } +} + +func TestItemsFrom(t *testing.T) { + // A list arrives wrapped in an envelope. + items, err := itemsFrom([]byte(`{"total":2,"items":[{"name":"a"},{"name":"b"}],"next":null}`), "") + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0]["name"] != "a" { + t.Errorf("unexpected items: %v", items) + } + + // A single named resource arrives bare. + items, err = itemsFrom([]byte(`{"name":"default","type":"Mesh"}`), "default") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0]["name"] != "default" { + t.Errorf("unexpected item: %v", items) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/resolve.go b/internal/cmd/root/products/konnect/mesh/resolve.go new file mode 100644 index 000000000..d740de68c --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/resolve.go @@ -0,0 +1,86 @@ +package mesh + +import ( + "fmt" + "slices" + "strings" +) + +// ResolveType finds the resource type an operator named on the command line. +// +// A type is addressable by its URL path ("dataplanes"), its Kuma type name +// ("Dataplane"), or its control-plane-reported short name ("dp"). All three +// come from discovery, so a type added by a newer Kong Mesh release is +// addressable without a kongctl change. +// +// Matching is case insensitive because the path is lower case while the type +// name is CamelCase, and operators should not have to remember which is which. +func ResolveType(descriptors []ResourceDescriptor, arg string) (ResourceDescriptor, error) { + wanted := strings.ToLower(strings.TrimSpace(arg)) + if wanted == "" { + return ResourceDescriptor{}, fmt.Errorf("no resource type given") + } + + // Path first: it is what the tables and help text display, so it is the + // form an operator is most likely to have copied. + for _, d := range descriptors { + if strings.ToLower(d.Path) == wanted { + return d, nil + } + } + for _, d := range descriptors { + if strings.ToLower(d.Name) == wanted { + return d, nil + } + } + for _, d := range descriptors { + if alias := d.Alias(); alias != "" && strings.ToLower(alias) == wanted { + return d, nil + } + } + + return ResourceDescriptor{}, unknownTypeError(descriptors, arg) +} + +// unknownTypeError reports an unmatched type, offering near misses so an +// operator can correct a typo without listing every type on the control plane. +func unknownTypeError(descriptors []ResourceDescriptor, arg string) error { + wanted := strings.ToLower(strings.TrimSpace(arg)) + + // A shared prefix catches the realistic mistakes — a missing or extra + // plural, a typo in the tail — where substring matching alone does not. + var near []string + for _, d := range descriptors { + path := strings.ToLower(d.Path) + if strings.Contains(path, wanted) || strings.Contains(wanted, path) || + commonPrefixLen(path, wanted) >= minNearMissPrefix { + near = append(near, d.Path) + } + } + slices.Sort(near) + near = slices.Compact(near) + + if len(near) > 0 { + return fmt.Errorf( + "unknown mesh resource type %q; did you mean %s? "+ + "run 'get mesh resource-types' to list every type this control plane serves", + arg, strings.Join(near, ", ")) + } + return fmt.Errorf( + "unknown mesh resource type %q on this control plane; "+ + "run 'get mesh resource-types' to list every type it serves", arg) +} + +// minNearMissPrefix is how many leading characters two type names must share +// before one is offered as a correction for the other. +const minNearMissPrefix = 4 + +func commonPrefixLen(a, b string) int { + n := min(len(a), len(b)) + for i := range n { + if a[i] != b[i] { + return i + } + } + return n +} diff --git a/internal/cmd/root/products/konnect/mesh/resolve_test.go b/internal/cmd/root/products/konnect/mesh/resolve_test.go new file mode 100644 index 000000000..1eb19749b --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/resolve_test.go @@ -0,0 +1,91 @@ +package mesh + +import ( + "strings" + "testing" +) + +func testDescriptors() []ResourceDescriptor { + return []ResourceDescriptor{ + {Name: "Dataplane", Path: "dataplanes", Scope: ScopeMesh, ShortName: "dp"}, + {Name: "Mesh", Path: "meshes", Scope: ScopeGlobal, ShortName: "m"}, + {Name: "MeshTrafficPermission", Path: "meshtrafficpermissions", Scope: ScopeMesh, ShortName: "mtp"}, + // An insight type carries no short name, so it is not KRI addressable + // and offers no alias. + {Name: "DataplaneInsight", Path: "dataplane-insights", Scope: ScopeMesh}, + } +} + +func TestResolveType(t *testing.T) { + tests := []struct { + name string + arg string + wantType string + }{ + {"by path", "dataplanes", "Dataplane"}, + {"by type name", "Dataplane", "Dataplane"}, + {"by short name", "dp", "Dataplane"}, + {"path is case insensitive", "DATAPLANES", "Dataplane"}, + {"type name is case insensitive", "dataplane", "Dataplane"}, + {"short name is case insensitive", "DP", "Dataplane"}, + {"global type by path", "meshes", "Mesh"}, + {"type with no short name", "dataplane-insights", "DataplaneInsight"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveType(testDescriptors(), tc.arg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != tc.wantType { + t.Errorf("resolved %q to %s, want %s", tc.arg, got.Name, tc.wantType) + } + }) + } +} + +// A path match must win over a type name match, since the path is the form +// displayed in tables and therefore the one an operator is likeliest to copy. +func TestResolveTypePrefersPathOverName(t *testing.T) { + descriptors := []ResourceDescriptor{ + {Name: "collision", Path: "other-path", Scope: ScopeGlobal}, + {Name: "Other", Path: "collision", Scope: ScopeGlobal}, + } + + got, err := ResolveType(descriptors, "collision") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "Other" { + t.Errorf("resolved to %s, want the descriptor whose path matched", got.Name) + } +} + +func TestResolveTypeUnknown(t *testing.T) { + if _, err := ResolveType(testDescriptors(), ""); err == nil { + t.Error("expected an error for an empty type") + } + + // A type removed in Kong Mesh 3 is simply absent from discovery, so it + // must report as unknown rather than produce a request that 404s. + err := mustFailResolve(t, "zoneingresses") + if !strings.Contains(err.Error(), "resource-types") { + t.Errorf("error should point at the discovery command, got %q", err) + } + + // A near miss should be offered rather than the whole type list. + err = mustFailResolve(t, "dataplane-typo") + if !strings.Contains(err.Error(), "did you mean") || !strings.Contains(err.Error(), "dataplanes") { + t.Errorf("expected a near miss naming dataplanes, got %q", err) + } +} + +func mustFailResolve(t *testing.T, arg string) error { + t.Helper() + _, err := ResolveType(testDescriptors(), arg) + if err == nil { + t.Fatalf("expected %q to be unresolvable", arg) + } + return err +} From 13a155dbbaf698d145eb2c2f2412eddd537040a8 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 9 Sep 2026 09:20:20 +0100 Subject: [PATCH 03/15] feat(mesh): create and delete mesh resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `create mesh -f` and `delete mesh `, registering the mesh container under the create and delete verbs. kongctl mesh was read only until now. Both are driven by /_resources like the read is, so no resource type is named in code. Verified against a live Kong Mesh 3 control plane creating and deleting MeshTrafficPermission, MeshTimeout and MeshRetry, addressing types by path and by short name. create accepts a file, a directory, stdin, or a URL, and reads a multi document YAML stream document by document the way kumactl apply does. JSON input needs no separate path, being valid YAML. Each document is addressed by its own `type` and `name`; `mesh` falls back to --mesh when a document omits it, and is ignored for global scoped types. The verb is create rather than apply because Kuma creates or replaces with a PUT, while kongctl's apply carries plan-and-diff semantics this does not have. The response status distinguishes a created resource from a replaced one, so re-applying reports "updated". Every document is reported, successes and failures together, so a partial apply stays legible instead of being masked by the first error; a failure sets the exit status once at the end. Writes to a read-only type are refused before sending, naming the type rather than relaying a 405 — the control plane enforces this too. Validation feedback comes from the control plane's AIP-193 invalid_parameters array, so a rejected document reports the offending field: spec.rules[0].allow[0].spiffeID (): must be a valid Spiffe ID: path cannot have a trailing slash The shared client now takes any method and an optional body, and returns the status alongside it. A URL source is fetched without the control plane credential, since the URL is not the control plane. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 9a592d66280b066842cf97cc512ca2058b6b7a10) --- .../cmd/root/products/konnect/mesh/client.go | 54 ++- .../products/konnect/mesh/createResources.go | 357 ++++++++++++++++++ .../konnect/mesh/createResources_test.go | 131 +++++++ .../products/konnect/mesh/deleteResources.go | 99 +++++ .../cmd/root/products/konnect/mesh/mesh.go | 30 +- internal/cmd/root/verbs/create/create.go | 6 + internal/cmd/root/verbs/create/mesh.go | 75 ++++ internal/cmd/root/verbs/del/del.go | 6 + internal/cmd/root/verbs/del/mesh.go | 72 ++++ 9 files changed, 815 insertions(+), 15 deletions(-) create mode 100644 internal/cmd/root/products/konnect/mesh/createResources.go create mode 100644 internal/cmd/root/products/konnect/mesh/createResources_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/deleteResources.go create mode 100644 internal/cmd/root/verbs/create/mesh.go create mode 100644 internal/cmd/root/verbs/del/mesh.go diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index 5923db8c4..fc3bfc285 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -1,9 +1,11 @@ package mesh import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "strings" @@ -59,28 +61,42 @@ func (e apiError) Error() string { // fetch performs a GET against the selected control plane and returns the // response body. +func fetch(helper cmd.Helper, path string) ([]byte, error) { + body, _, err := send(helper, http.MethodGet, path, nil) + return body, err +} + +// sendForStatus performs a request and returns only the response status, for +// callers that distinguish a created resource from a replaced one. +func sendForStatus(helper cmd.Helper, method, path string, body []byte) (int, error) { + _, status, err := send(helper, method, path, body) + return status, err +} + +// send performs a request against the selected control plane and returns the +// response body. // -// Every mesh read goes through here so that control plane resolution, +// Every mesh call goes through here so that control plane resolution, // credentials, and error rendering behave identically across commands. -func fetch(helper cmd.Helper, path string) ([]byte, error) { +func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, error) { cfg, err := helper.GetConfig() if err != nil { - return nil, err + return nil, 0, err } logger, err := helper.GetLogger() if err != nil { - return nil, err + return nil, 0, err } baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) if err != nil { - return nil, err + return nil, 0, err } tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) if err != nil { - return nil, fmt.Errorf("resolve Konnect access token: %w", err) + return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) } ctx := helper.GetContext() @@ -88,30 +104,40 @@ func fetch(helper cmd.Helper, path string) ([]byte, error) { ctx = context.Background() } if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { - return nil, fmt.Errorf("resolve Konnect access token: %w", err) + return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) + } + + var ( + payload io.Reader + headers map[string]string + ) + if body != nil { + payload = bytes.NewReader(body) + headers = map[string]string{"Content-Type": "application/json"} } result, err := apiutil.RequestWithTokenSource( ctx, httpclient.NewLoggingHTTPClient(logger), - http.MethodGet, + method, baseURL, path, tokenSource, - nil, - nil, + headers, + payload, ) if err != nil { - return nil, err + return nil, 0, err } - logger.Debug("mesh control plane call completed", "path", path, "status_code", result.StatusCode) + logger.Debug("mesh control plane call completed", + "method", method, "path", path, "status_code", result.StatusCode) if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { - return nil, buildAPIError(result.StatusCode, result.Body) + return nil, result.StatusCode, buildAPIError(result.StatusCode, result.Body) } - return result.Body, nil + return result.Body, result.StatusCode, nil } // buildAPIError turns a failed response into an actionable error, using the diff --git a/internal/cmd/root/products/konnect/mesh/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go new file mode 100644 index 000000000..219aad409 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -0,0 +1,357 @@ +package mesh + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/table" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/tableview" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/declarative/loader" + "github.com/kong/kongctl/internal/konnect/apiutil" + "github.com/kong/kongctl/internal/konnect/httpclient" + "github.com/segmentio/cli" + "gopkg.in/yaml.v3" +) + +// meshResource is one document read from the input, carrying only the fields +// needed to address it. The document is sent to the control plane in full. +type meshResource struct { + Type string + Name string + Mesh string + Body []byte + // Origin names where the document came from, for error messages. + Origin string +} + +// applyResult records what happened to one document. +type applyResult struct { + Resource meshResource + Created bool + Err error +} + +// runCreateResources serves `create mesh -f `, applying every document +// in the input to the control plane. +// +// Kuma addresses a resource by type and name and creates or replaces it with a +// PUT, which is what kumactl apply does. The verb here is create rather than +// apply because kongctl's apply carries plan-and-diff semantics this does not. +func runCreateResources(helper cmd.Helper, filenames []string) error { + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + descriptors, err := Discover(helper) + if err != nil { + return cmd.PrepareExecutionError("failed to retrieve mesh resource types", err, helper.GetCmd()) + } + + sources, err := loader.ParseSources(filenames) + if err != nil { + return &cmd.ConfigurationError{Err: err} + } + + resources, err := readResources(helper, sources, meshcommon.ResolveMesh(cfg)) + if err != nil { + return err + } + if len(resources) == 0 { + return &cmd.ConfigurationError{ + Err: errors.New("no mesh resources found in the given input"), + } + } + + results := make([]applyResult, 0, len(resources)) + var failed bool + for _, resource := range resources { + created, err := applyResource(helper, descriptors, resource) + if err != nil { + failed = true + } + results = append(results, applyResult{Resource: resource, Created: created, Err: err}) + } + + if err := reportApplyResults(helper, results); err != nil { + return err + } + if failed { + return cmd.PrepareExecutionError( + "one or more mesh resources could not be applied", errApplyFailed, helper.GetCmd()) + } + return nil +} + +// errApplyFailed marks a partial failure. Per-resource detail is already +// reported, so this only sets the exit status. +var errApplyFailed = errors.New("see the reported resources above") + +// applyResource sends one document, reporting whether the control plane created +// it rather than replaced an existing one. +func applyResource(helper cmd.Helper, descriptors []ResourceDescriptor, resource meshResource) (bool, error) { + descriptor, err := ResolveType(descriptors, resource.Type) + if err != nil { + return false, err + } + + // The control plane also refuses a write to a read-only type with a 405, + // but saying so before sending names the type rather than the status. + if descriptor.ReadOnly { + return false, fmt.Errorf( + "%s is read only on this control plane and cannot be created or updated", descriptor.Singular()) + } + + mesh := resource.Mesh + if !descriptor.IsMeshScoped() { + mesh = "" + } + + status, err := sendForStatus(helper, http.MethodPut, descriptor.ItemPath(mesh, resource.Name), resource.Body) + if err != nil { + return false, err + } + return status == http.StatusCreated, nil +} + +// readResources collects every document from the given sources. +func readResources(helper cmd.Helper, sources []loader.Source, defaultMesh string) ([]meshResource, error) { + var resources []meshResource + + for _, source := range sources { + switch source.Type { + case loader.SourceTypeSTDIN: + docs, err := decodeResources(helper.GetStreams().In, "stdin", defaultMesh) + if err != nil { + return nil, err + } + resources = append(resources, docs...) + + case loader.SourceTypeFile: + docs, err := readResourceFile(source.Path, defaultMesh) + if err != nil { + return nil, err + } + resources = append(resources, docs...) + + case loader.SourceTypeDirectory: + paths, err := yamlFilesIn(source.Path) + if err != nil { + return nil, err + } + for _, path := range paths { + docs, err := readResourceFile(path, defaultMesh) + if err != nil { + return nil, err + } + resources = append(resources, docs...) + } + + case loader.SourceTypeURL: + docs, err := readResourceURL(helper, source.Path, defaultMesh) + if err != nil { + return nil, err + } + resources = append(resources, docs...) + } + } + + return resources, nil +} + +func readResourceFile(path, defaultMesh string) ([]meshResource, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + defer file.Close() + + return decodeResources(bufio.NewReader(file), path, defaultMesh) +} + +// readResourceURL fetches documents from an HTTP source. It deliberately does +// not carry the control plane credential, since the URL is not the control +// plane. +func readResourceURL(helper cmd.Helper, rawURL, defaultMesh string) ([]meshResource, error) { + logger, err := helper.GetLogger() + if err != nil { + return nil, err + } + + ctx := helper.GetContext() + result, err := apiutil.Request( + ctx, httpclient.NewLoggingHTTPClient(logger), http.MethodGet, "", rawURL, "", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", rawURL, err) + } + if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("failed to fetch %s: status %d", rawURL, result.StatusCode) + } + + return decodeResources(strings.NewReader(string(result.Body)), rawURL, defaultMesh) +} + +// yamlFilesIn lists the YAML files directly inside a directory, sorted so that +// applying a directory twice sends the same order. +func yamlFilesIn(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + var paths []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch strings.ToLower(filepath.Ext(entry.Name())) { + case ".yaml", ".yml", ".json": + paths = append(paths, filepath.Join(dir, entry.Name())) + } + } + return paths, nil +} + +// decodeResources reads every document from one input. YAML and JSON are both +// accepted, since JSON is valid YAML, and a multi document YAML stream is read +// document by document the way kumactl reads one. +func decodeResources(in io.Reader, origin, defaultMesh string) ([]meshResource, error) { + decoder := yaml.NewDecoder(in) + + var resources []meshResource + for index := 0; ; index++ { + var document map[string]any + err := decoder.Decode(&document) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", describeDocument(origin, index), err) + } + if len(document) == 0 { + continue + } + + resource, err := newMeshResource(document, origin, index, defaultMesh) + if err != nil { + return nil, err + } + resources = append(resources, resource) + } + + return resources, nil +} + +// newMeshResource validates that a document can be addressed and renders it as +// the JSON the control plane expects. +func newMeshResource(document map[string]any, origin string, index int, defaultMesh string) (meshResource, error) { + where := describeDocument(origin, index) + + resourceType := stringField(document, "type") + if resourceType == "" { + return meshResource{}, &cmd.ConfigurationError{ + Err: fmt.Errorf("%s has no 'type' field, so the resource type cannot be determined", where), + } + } + + name := stringField(document, "name") + if name == "" { + return meshResource{}, &cmd.ConfigurationError{ + Err: fmt.Errorf("%s has no 'name' field, so the resource cannot be addressed", where), + } + } + + mesh := stringField(document, "mesh") + if mesh == "" { + mesh = defaultMesh + } + + body, err := json.Marshal(document) + if err != nil { + return meshResource{}, fmt.Errorf("failed to encode %s: %w", where, err) + } + + return meshResource{ + Type: resourceType, + Name: name, + Mesh: mesh, + Body: body, + Origin: where, + }, nil +} + +func describeDocument(origin string, index int) string { + if index == 0 { + return origin + } + return fmt.Sprintf("%s (document %d)", origin, index+1) +} + +// applyRow is the text table projection of one applied document. +type applyRow struct { + Type string `json:"type" table:"TYPE"` + Name string `json:"name" table:"NAME"` + Mesh string `json:"mesh" table:"MESH"` + Result string `json:"result" table:"RESULT"` +} + +// reportApplyResults renders what happened to each document. Every document is +// reported, successes and failures together, so a partial apply is legible +// rather than being masked by the first error. +func reportApplyResults(helper cmd.Helper, results []applyResult) error { + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + printer, err := cli.Format(outType.String(), helper.GetStreams().Out) + if err != nil { + return err + } + defer printer.Flush() + + rows := make([]applyRow, 0, len(results)) + tableRows := make([]table.Row, 0, len(results)) + for _, result := range results { + row := applyRow{ + Type: result.Resource.Type, + Name: result.Resource.Name, + Mesh: result.Resource.Mesh, + Result: describeApplyOutcome(result), + } + rows = append(rows, row) + tableRows = append(tableRows, table.Row{row.Type, row.Name, row.Mesh, row.Result}) + } + + return tableview.RenderForFormat( + helper, + false, + outType, + printer, + helper.GetStreams(), + rows, + rows, + "Applied Mesh Resources", + tableview.WithExactCustomTable([]string{"TYPE", "NAME", "MESH", "RESULT"}, tableRows), + tableview.WithRootLabel(helper.GetCmd().Name()), + ) +} + +func describeApplyOutcome(result applyResult) string { + if result.Err != nil { + return "failed: " + result.Err.Error() + } + if result.Created { + return "created" + } + return "updated" +} diff --git a/internal/cmd/root/products/konnect/mesh/createResources_test.go b/internal/cmd/root/products/konnect/mesh/createResources_test.go new file mode 100644 index 000000000..633f5936f --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createResources_test.go @@ -0,0 +1,131 @@ +package mesh + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestDecodeResourcesMultiDocument(t *testing.T) { + input := `type: MeshTimeout +name: slow +mesh: prod +spec: + targetRef: + kind: Mesh +--- +type: MeshRetry +name: retries +spec: {} +` + resources, err := decodeResources(strings.NewReader(input), "policies.yaml", "default") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resources) != 2 { + t.Fatalf("expected 2 documents, got %d", len(resources)) + } + + if resources[0].Type != "MeshTimeout" || resources[0].Name != "slow" || resources[0].Mesh != "prod" { + t.Errorf("unexpected first resource: %+v", resources[0]) + } + // A document that omits mesh inherits the resolved default. + if resources[1].Mesh != "default" { + t.Errorf("expected the default mesh, got %q", resources[1].Mesh) + } + // The second document is reported by position so an error can be located. + if !strings.Contains(resources[1].Origin, "document 2") { + t.Errorf("expected the origin to name the document, got %q", resources[1].Origin) + } + + // The whole document is forwarded, not just the addressing fields. + var body map[string]any + if err := json.Unmarshal(resources[0].Body, &body); err != nil { + t.Fatal(err) + } + if _, ok := body["spec"]; !ok { + t.Error("spec was dropped from the forwarded body") + } +} + +// JSON is valid YAML, so a JSON document needs no separate path. +func TestDecodeResourcesAcceptsJSON(t *testing.T) { + resources, err := decodeResources( + strings.NewReader(`{"type":"MeshTimeout","name":"slow","spec":{}}`), "policy.json", "default") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resources) != 1 || resources[0].Type != "MeshTimeout" { + t.Fatalf("unexpected resources: %+v", resources) + } +} + +// A stream of separators, or trailing separators, yields nothing rather than +// empty resources that would be sent to the control plane. +func TestDecodeResourcesSkipsEmptyDocuments(t *testing.T) { + resources, err := decodeResources(strings.NewReader("---\n---\n"), "empty.yaml", "default") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected no resources, got %d", len(resources)) + } +} + +func TestDecodeResourcesRequiresAddressableFields(t *testing.T) { + for _, tc := range []struct{ name, input, want string }{ + {"missing type", "name: orphan\n", "'type'"}, + {"missing name", "type: MeshTimeout\n", "'name'"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := decodeResources(strings.NewReader(tc.input), "stdin", "default") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error should name the missing field %s, got %q", tc.want, err) + } + }) + } +} + +func TestDecodeResourcesReportsMalformedInput(t *testing.T) { + _, err := decodeResources(strings.NewReader("type: [unclosed\n"), "broken.yaml", "default") + if err == nil { + t.Fatal("expected a parse error") + } + if !strings.Contains(err.Error(), "broken.yaml") { + t.Errorf("error should name the source, got %q", err) + } +} + +func TestDescribeApplyOutcome(t *testing.T) { + tests := []struct { + name string + result applyResult + want string + }{ + {"created", applyResult{Created: true}, "created"}, + {"updated", applyResult{Created: false}, "updated"}, + {"failed", applyResult{Err: errors.New("boom")}, "failed: boom"}, + // A failure is reported as such even if the status suggested a create. + {"failure wins over created", applyResult{Created: true, Err: errors.New("boom")}, "failed: boom"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := describeApplyOutcome(tc.result); got != tc.want { + t.Errorf("outcome = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDescribeDocument(t *testing.T) { + if got := describeDocument("policies.yaml", 0); got != "policies.yaml" { + t.Errorf("single document should not be numbered, got %q", got) + } + if got := describeDocument("policies.yaml", 2); got != "policies.yaml (document 3)" { + t.Errorf("unexpected description: %q", got) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/deleteResources.go b/internal/cmd/root/products/konnect/mesh/deleteResources.go new file mode 100644 index 000000000..736fdc94e --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/deleteResources.go @@ -0,0 +1,99 @@ +package mesh + +import ( + "fmt" + "net/http" + + "charm.land/bubbles/v2/table" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/tableview" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/segmentio/cli" +) + +// deleteRow is the text table projection of a deleted resource. +type deleteRow struct { + Type string `json:"type" table:"TYPE"` + Name string `json:"name" table:"NAME"` + Mesh string `json:"mesh" table:"MESH"` + Result string `json:"result" table:"RESULT"` +} + +// runDeleteResources serves `delete mesh `. +// +// The type is resolved from /_resources like every other mesh command, so no +// resource type is named in code here either. +func runDeleteResources(helper cmd.Helper, args []string) error { + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + descriptors, err := Discover(helper) + if err != nil { + return cmd.PrepareExecutionError("failed to retrieve mesh resource types", err, helper.GetCmd()) + } + + descriptor, err := ResolveType(descriptors, args[0]) + if err != nil { + return &cmd.ConfigurationError{Err: err} + } + + // The control plane also refuses this with a 405, but naming the type is + // more use than reporting a status. + if descriptor.ReadOnly { + return &cmd.ConfigurationError{ + Err: fmt.Errorf( + "%s is read only on this control plane and cannot be deleted", descriptor.Singular()), + } + } + + name := args[1] + mesh := "" + if descriptor.IsMeshScoped() { + mesh = meshcommon.ResolveMesh(cfg) + } + + if _, err := sendForStatus(helper, http.MethodDelete, descriptor.ItemPath(mesh, name), nil); err != nil { + return cmd.PrepareExecutionError( + fmt.Sprintf("failed to delete %s %s", descriptor.Singular(), name), err, helper.GetCmd()) + } + + return reportDeleted(helper, descriptor, mesh, name) +} + +func reportDeleted(helper cmd.Helper, descriptor ResourceDescriptor, mesh, name string) error { + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + printer, err := cli.Format(outType.String(), helper.GetStreams().Out) + if err != nil { + return err + } + defer printer.Flush() + + rows := []deleteRow{{ + Type: descriptor.Name, + Name: name, + Mesh: mesh, + Result: "deleted", + }} + + return tableview.RenderForFormat( + helper, + false, + outType, + printer, + helper.GetStreams(), + rows, + rows, + "Deleted Mesh Resource", + tableview.WithExactCustomTable( + []string{"TYPE", "NAME", "MESH", "RESULT"}, + []table.Row{{descriptor.Name, name, mesh, "deleted"}}, + ), + tableview.WithRootLabel(helper.GetCmd().Name()), + ) +} diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go index 460b11ec8..bb6678fe9 100644 --- a/internal/cmd/root/products/konnect/mesh/mesh.go +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -14,6 +14,9 @@ import ( const CommandName = meshcommon.CommandName +// FilenameFlagName names the -f flag that supplies resources to create. +const FilenameFlagName = "filename" + var ( meshUse = CommandName @@ -72,6 +75,10 @@ func NewMeshCmd( addParentFlags(verb, baseCmd) } meshcommon.AddControlPlaneFlags(baseCmd.PersistentFlags()) + if verb == verbs.Create { + baseCmd.Flags().StringSliceP(FilenameFlagName, "f", nil, + "Files, directories, URLs, or - for stdin, holding the mesh resources to apply. Repeatable.") + } // Resource types come from the control plane at runtime, so they cannot be // registered as subcommands without a network call at startup. Arbitrary @@ -87,6 +94,25 @@ func NewMeshCmd( if _, err := helper.GetOutputFormat(); err != nil { return err } + if verb == verbs.Create { + // Read the flag rather than binding a variable: one process can + // hold a mesh command per verb, and a shared variable would leak + // between them. + filenames, err := cmdObj.Flags().GetStringSlice(FilenameFlagName) + if err != nil { + return err + } + return runCreateResources(helper, filenames) + } + if verb == verbs.Delete { + if len(args) != 2 { + return &cmd.ConfigurationError{ + Err: fmt.Errorf("expected a resource type and a name, for example 'delete mesh %s '", + "meshtrafficpermission"), + } + } + return runDeleteResources(helper, args) + } if verb == verbs.Get && len(args) > 0 { // A near miss of a real subcommand is a mistyped subcommand, not a // resource type. Saying so here keeps that error immediate, rather @@ -104,7 +130,9 @@ func NewMeshCmd( } return cmd.RequireSubcommand(cmdObj, args) } - cmd.MarkRequiresSubcommand(baseCmd) + if verb != verbs.Create && verb != verbs.Delete { + cmd.MarkRequiresSubcommand(baseCmd) + } if verb == verbs.Get { baseCmd.AddCommand(newGetResourceTypesCmd(verb, addParentFlags, parentPreRun)) diff --git a/internal/cmd/root/verbs/create/create.go b/internal/cmd/root/verbs/create/create.go index e6c122344..8e962fdeb 100644 --- a/internal/cmd/root/verbs/create/create.go +++ b/internal/cmd/root/verbs/create/create.go @@ -104,6 +104,12 @@ Setting this value overrides tokens obtained from the login command. cmd.AddCommand(c) + meshCmd, err := NewDirectMeshCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(meshCmd) + patCmd, err := token.NewPATCmd(Verb, nil, nil) if err != nil { return nil, err diff --git a/internal/cmd/root/verbs/create/mesh.go b/internal/cmd/root/verbs/create/mesh.go new file mode 100644 index 000000000..eaa6c8b1a --- /dev/null +++ b/internal/cmd/root/verbs/create/mesh.go @@ -0,0 +1,75 @@ +package create + +import ( + "context" + "fmt" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/root/products" + "github.com/kong/kongctl/internal/cmd/root/products/konnect" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/meta" + "github.com/spf13/cobra" +) + +// NewDirectMeshCmd creates a mesh command that works at the root level, +// giving "kongctl create mesh ..." alongside the explicit +// "kongctl create konnect mesh ..." form. +// +// Reaching Kong Mesh through one command path regardless of whether the control +// plane is Konnect hosted or self managed is deliberate: where a control plane +// runs is a connection detail, not a different command. +func NewDirectMeshCmd() (*cobra.Command, error) { + addFlags := func(_ verbs.VerbValue, cmdObj *cobra.Command) { + cmdObj.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmdObj.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token. +- Config path: [ %s ]`, common.PATConfigPath)) + } + + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, helpers.SDKAPIFactory(common.KonnectSDKFactory)) + c.SetContext(ctx) + + if err := bindKonnectFlags(c, args); err != nil { + return err + } + + helper := cmd.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + return meshcommon.BindFlags(cfg, c.Flags()) + } + + meshCmd, err := mesh.NewMeshCmd(Verb, addFlags, preRunE) + if err != nil { + return nil, err + } + + meshCmd.Example = fmt.Sprintf(` # Apply mesh resources from a file + %[1]s create mesh -f policy.yaml --control-plane-id + + # Apply every resource in a directory + %[1]s create mesh -f ./policies --control-plane-id + + # Apply from stdin + cat policy.yaml | %[1]s create mesh -f - --control-plane-id `, meta.CLIName) + + return meshCmd, nil +} diff --git a/internal/cmd/root/verbs/del/del.go b/internal/cmd/root/verbs/del/del.go index 6b88ce936..32a58e2ff 100644 --- a/internal/cmd/root/verbs/del/del.go +++ b/internal/cmd/root/verbs/del/del.go @@ -164,6 +164,12 @@ func addDeleteTokenCommands(cmd *cobra.Command) error { konnectCmd.AddCommand(konnectOrgCmd) cmd.AddCommand(konnectCmd) + meshCmd, err := NewDirectMeshCmd() + if err != nil { + return err + } + cmd.AddCommand(meshCmd) + return nil } diff --git a/internal/cmd/root/verbs/del/mesh.go b/internal/cmd/root/verbs/del/mesh.go new file mode 100644 index 000000000..9249524dc --- /dev/null +++ b/internal/cmd/root/verbs/del/mesh.go @@ -0,0 +1,72 @@ +package del + +import ( + "context" + "fmt" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/root/products" + "github.com/kong/kongctl/internal/cmd/root/products/konnect" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/meta" + "github.com/spf13/cobra" +) + +// NewDirectMeshCmd creates a mesh command that works at the root level, +// giving "kongctl delete mesh ..." alongside the explicit +// "kongctl delete konnect mesh ..." form. +// +// Reaching Kong Mesh through one command path regardless of whether the control +// plane is Konnect hosted or self managed is deliberate: where a control plane +// runs is a connection detail, not a different command. +func NewDirectMeshCmd() (*cobra.Command, error) { + addFlags := func(_ verbs.VerbValue, cmdObj *cobra.Command) { + cmdObj.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmdObj.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token. +- Config path: [ %s ]`, common.PATConfigPath)) + } + + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, helpers.SDKAPIFactory(common.KonnectSDKFactory)) + c.SetContext(ctx) + + if err := bindKonnectFlags(c, args); err != nil { + return err + } + + helper := cmd.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + return meshcommon.BindFlags(cfg, c.Flags()) + } + + meshCmd, err := mesh.NewMeshCmd(Verb, addFlags, preRunE) + if err != nil { + return nil, err + } + + meshCmd.Example = fmt.Sprintf(` # Delete a mesh scoped resource + %[1]s delete mesh meshtrafficpermission allow-all --control-plane-id + + # Delete a resource in a named mesh + %[1]s delete mesh meshtimeout slow -m prod --control-plane-id `, meta.CLIName) + + return meshCmd, nil +} From 3d0c96d7f9693834637d8cd84631c0ea83e96da8 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 9 Sep 2026 10:14:30 +0100 Subject: [PATCH 04/15] feat(mesh): issue dataplane and zone tokens Adds `create mesh dataplane-token` and `create mesh zone-token`, so a dataplane or a zone control plane can be bootstrapped without kumactl. Flags mirror kumactl generate, including --valid-for being required and --tag splitting comma separated values into the multi value form the control plane expects. The token is written to stdout with no trailing newline, as kumactl does, so redirecting it produces a file holding exactly the credential. Verified against a live Kong Mesh 3 control plane. A dataplane token came back bound to its name, mesh, tags and workload; a zone token came back carrying its zone and scope. `create mesh user-token` is deliberately absent. Probing the hosted control plane, POST /tokens/user answers 404: the user token plugin is not registered when the API server authenticates through Konnect, which it does when hosted. User tokens are therefore a self-managed capability and belong with the rest of phase 3, rather than being a command that cannot work against any control plane reachable today. --scope defaults to "cp" on the zone token, which matters more than it looks. Omitting the scope makes the control plane answer 500 rather than falling back to the distribution's full scope: 500 {"zone":"zone1","validFor":"60s"} 200 {"zone":"zone1","scope":["cp"],"validFor":"60s"} kumactl defaults --scope to zone.FullScope, which Kong Mesh populates with its control plane scope, so kumactl never meets that failure. Sending the scope by default matches it and routes around the fault rather than waiting on a fix. Kuma's own 3.0 upgrade note states the endpoint no longer requires a scope, which holds for Kuma, whose full scope is empty, but not for Kong Mesh. A test pins the default so it is not tidied away. A zero or negative --valid-for is refused before sending, since the control plane would accept it and mint a token that never expires. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 9b3acff414408643cfae2a548eb2fb3b6407d92e) --- .../products/konnect/mesh/createTokens.go | 281 ++++++++++++++++++ .../konnect/mesh/createTokens_test.go | 153 ++++++++++ .../cmd/root/products/konnect/mesh/mesh.go | 9 + 3 files changed, 443 insertions(+) create mode 100644 internal/cmd/root/products/konnect/mesh/createTokens.go create mode 100644 internal/cmd/root/products/konnect/mesh/createTokens_test.go diff --git a/internal/cmd/root/products/konnect/mesh/createTokens.go b/internal/cmd/root/products/konnect/mesh/createTokens.go new file mode 100644 index 000000000..339d3a07c --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createTokens.go @@ -0,0 +1,281 @@ +package mesh + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/kong/kongctl/internal/cmd" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/meta" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/spf13/cobra" +) + +// Token endpoints on the control plane. A user token endpoint exists in Kuma +// but is not registered on a Konnect hosted control plane, which authenticates +// through Konnect instead, so it is not offered here. See Phase 3. +const ( + dataplaneTokenPath = "/tokens/dataplane" + zoneTokenPath = "/tokens/zone" +) + +// controlPlaneZoneScope is the zone token scope Kong Mesh registers. +// +// It is sent by default because omitting the scope makes the control plane +// answer 500 rather than falling back to the distribution's full scope. kumactl +// defaults the same way, which is why it never meets that failure. +const controlPlaneZoneScope = "cp" + +// Flag names for the token commands. +const ( + tokenNameFlagName = "name" + tokenValidForFlagName = "valid-for" + tokenTagFlagName = "tag" + tokenProxyTypeFlagName = "proxy-type" + tokenWorkloadFlagName = "workload" + tokenZoneFlagName = "zone" + tokenScopeFlagName = "scope" +) + +// dataplaneTokenRequest is the payload the control plane expects. Fields are +// omitted when empty so the control plane applies its own defaults. +type dataplaneTokenRequest struct { + Name string `json:"name,omitempty"` + Mesh string `json:"mesh"` + Tags map[string][]string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` + Workload string `json:"workload,omitempty"` + ValidFor string `json:"validFor"` +} + +// zoneTokenRequest is the payload for a zone token. +type zoneTokenRequest struct { + Zone string `json:"zone"` + Scope []string `json:"scope,omitempty"` + ValidFor string `json:"validFor"` +} + +var ( + dataplaneTokenShort = i18n.T("root.products.konnect.mesh.dataplaneTokenShort", + "Issue a token that proves a dataplane's identity") + + dataplaneTokenLong = normalizers.LongDesc(i18n.T("root.products.konnect.mesh.dataplaneTokenLong", + `Issue a dataplane token from the control plane. + +A dataplane token lets kuma-dp prove its identity when it connects. Bind the +token as narrowly as the deployment allows: to a name, to a workload, or to +tags, rather than to the mesh alone. + +The token is written to stdout with no trailing newline, so it can be +redirected straight into the file kuma-dp reads.`)) + + dataplaneTokenExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.dataplaneTokenExample", + fmt.Sprintf(` + # A token bound to one dataplane, written to a file + %[1]s create mesh dataplane-token --name dp-01 --valid-for 24h > /tmp/token + + # A token bound to a mesh only + %[1]s create mesh dataplane-token -m prod --valid-for 24h + + # A token bound to tags + %[1]s create mesh dataplane-token --tag kuma.io/service=web --valid-for 24h + `, meta.CLIName))) + + zoneTokenShort = i18n.T("root.products.konnect.mesh.zoneTokenShort", + "Issue a token that proves a zone's identity") + + zoneTokenLong = normalizers.LongDesc(i18n.T("root.products.konnect.mesh.zoneTokenLong", + `Issue a zone token from the control plane. + +A zone token lets a zone control plane prove its identity to a global control +plane when it joins. + +The token is written to stdout with no trailing newline, so it can be +redirected straight into a file.`)) + + zoneTokenExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.zoneTokenExample", + fmt.Sprintf(` + # A token for a zone, written to a file + %[1]s create mesh zone-token --zone zone-1 --valid-for 24h > /tmp/zone-token + `, meta.CLIName))) +) + +// newDataplaneTokenCmd builds `create mesh dataplane-token`. +func newDataplaneTokenCmd(parentPreRun func(*cobra.Command, []string) error) *cobra.Command { + cmdObj := &cobra.Command{ + Use: "dataplane-token", + Aliases: []string{"dp-token"}, + Short: dataplaneTokenShort, + Long: dataplaneTokenLong, + Example: dataplaneTokenExample, + Args: cobra.NoArgs, + } + if parentPreRun != nil { + cmdObj.PreRunE = parentPreRun + } + + cmdObj.Flags().String(tokenNameFlagName, "", "Name of the dataplane the token identifies.") + cmdObj.Flags().StringToString(tokenTagFlagName, nil, + "Tag values the dataplane must carry. Repeatable; separate multiple values for one tag with commas.") + cmdObj.Flags().String(tokenProxyTypeFlagName, "", `Proxy type the token is for (for example "dataplane").`) + cmdObj.Flags().String(tokenWorkloadFlagName, "", "Workload label value the dataplane must carry.") + cmdObj.Flags().Duration(tokenValidForFlagName, 0, `How long the token remains valid, for example "24h".`) + _ = cmdObj.MarkFlagRequired(tokenValidForFlagName) + + cmdObj.RunE = func(c *cobra.Command, args []string) error { + helper := cmd.BuildHelper(c, args) + return runDataplaneToken(helper, c) + } + return cmdObj +} + +// newZoneTokenCmd builds `create mesh zone-token`. +func newZoneTokenCmd(parentPreRun func(*cobra.Command, []string) error) *cobra.Command { + cmdObj := &cobra.Command{ + Use: "zone-token", + Short: zoneTokenShort, + Long: zoneTokenLong, + Example: zoneTokenExample, + Args: cobra.NoArgs, + } + if parentPreRun != nil { + cmdObj.PreRunE = parentPreRun + } + + cmdObj.Flags().String(tokenZoneFlagName, "", "Name of the zone the token identifies.") + cmdObj.Flags().StringSlice(tokenScopeFlagName, []string{controlPlaneZoneScope}, + "Scope of resources the token can identify.") + cmdObj.Flags().Duration(tokenValidForFlagName, 0, `How long the token remains valid, for example "24h".`) + _ = cmdObj.MarkFlagRequired(tokenZoneFlagName) + _ = cmdObj.MarkFlagRequired(tokenValidForFlagName) + + cmdObj.RunE = func(c *cobra.Command, args []string) error { + helper := cmd.BuildHelper(c, args) + return runZoneToken(helper, c) + } + return cmdObj +} + +func runDataplaneToken(helper cmd.Helper, cmdObj *cobra.Command) error { + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + validFor, err := requireValidFor(cmdObj) + if err != nil { + return err + } + + name, err := cmdObj.Flags().GetString(tokenNameFlagName) + if err != nil { + return err + } + proxyType, err := cmdObj.Flags().GetString(tokenProxyTypeFlagName) + if err != nil { + return err + } + workload, err := cmdObj.Flags().GetString(tokenWorkloadFlagName) + if err != nil { + return err + } + rawTags, err := cmdObj.Flags().GetStringToString(tokenTagFlagName) + if err != nil { + return err + } + + request := dataplaneTokenRequest{ + Name: name, + Mesh: meshcommon.ResolveMesh(cfg), + Tags: splitTagValues(rawTags), + Type: proxyType, + Workload: workload, + ValidFor: validFor, + } + + return issueToken(helper, dataplaneTokenPath, request, "dataplane token") +} + +func runZoneToken(helper cmd.Helper, cmdObj *cobra.Command) error { + validFor, err := requireValidFor(cmdObj) + if err != nil { + return err + } + + zone, err := cmdObj.Flags().GetString(tokenZoneFlagName) + if err != nil { + return err + } + scope, err := cmdObj.Flags().GetStringSlice(tokenScopeFlagName) + if err != nil { + return err + } + + request := zoneTokenRequest{ + Zone: zone, + Scope: scope, + ValidFor: validFor, + } + + return issueToken(helper, zoneTokenPath, request, "zone token") +} + +// requireValidFor reads --valid-for and renders it the way the control plane +// parses it. A token with no expiry is refused rather than sent, because the +// control plane would accept it. +func requireValidFor(cmdObj *cobra.Command) (string, error) { + validFor, err := cmdObj.Flags().GetDuration(tokenValidForFlagName) + if err != nil { + return "", err + } + if validFor <= 0 { + return "", &cmd.ConfigurationError{ + Err: fmt.Errorf("--%s must be a positive duration, for example 24h", tokenValidForFlagName), + } + } + return validFor.String(), nil +} + +// splitTagValues turns --tag key=a,b into the multi value form the control +// plane expects. +func splitTagValues(raw map[string]string) map[string][]string { + if len(raw) == 0 { + return nil + } + tags := make(map[string][]string, len(raw)) + for key, value := range raw { + tags[key] = strings.Split(value, ",") + } + return tags +} + +// issueToken posts a token request and writes the token to stdout. +// +// The response body is the token itself rather than JSON, and it is written +// without a trailing newline so that redirecting it produces a file holding +// exactly the credential. +func issueToken(helper cmd.Helper, path string, request any, description string) error { + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to encode the %s request: %w", description, err) + } + + response, _, err := send(helper, http.MethodPost, path, body) + if err != nil { + return cmd.PrepareExecutionError( + fmt.Sprintf("failed to issue a %s", description), err, helper.GetCmd()) + } + + token := strings.TrimSpace(string(response)) + if token == "" { + return cmd.PrepareExecutionError( + fmt.Sprintf("the control plane returned an empty %s", description), + fmt.Errorf("empty response body"), helper.GetCmd()) + } + + _, err = fmt.Fprint(helper.GetStreams().Out, token) + return err +} diff --git a/internal/cmd/root/products/konnect/mesh/createTokens_test.go b/internal/cmd/root/products/konnect/mesh/createTokens_test.go new file mode 100644 index 000000000..3c68a1a07 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createTokens_test.go @@ -0,0 +1,153 @@ +package mesh + +import ( + "encoding/json" + "maps" + "slices" + "strings" + "testing" + "time" +) + +func TestSplitTagValues(t *testing.T) { + tests := []struct { + name string + raw map[string]string + want map[string][]string + }{ + {"no tags yields nothing to send", nil, nil}, + {"empty map yields nothing to send", map[string]string{}, nil}, + { + "a single value", + map[string]string{"kuma.io/service": "web"}, + map[string][]string{"kuma.io/service": {"web"}}, + }, + { + // kumactl splits on commas so one flag can carry several values. + "commas separate multiple values", + map[string]string{"kuma.io/service": "web,web-api"}, + map[string][]string{"kuma.io/service": {"web", "web-api"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := splitTagValues(tc.raw) + if tc.want == nil { + if got != nil { + t.Fatalf("expected nil, got %v", got) + } + return + } + if !maps.EqualFunc(got, tc.want, slices.Equal) { + t.Errorf("tags = %v, want %v", got, tc.want) + } + }) + } +} + +func TestRequireValidFor(t *testing.T) { + tests := []struct { + name string + duration time.Duration + want string + wantErr bool + }{ + {"a day", 24 * time.Hour, "24h0m0s", false}, + {"a minute", time.Minute, "1m0s", false}, + // A token with no expiry would be accepted by the control plane, so it + // is refused here rather than sent. + {"zero is refused", 0, "", true}, + {"negative is refused", -time.Hour, "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmdObj := newDataplaneTokenCmd(nil) + if err := cmdObj.Flags().Set(tokenValidForFlagName, tc.duration.String()); err != nil { + t.Fatal(err) + } + + got, err := requireValidFor(cmdObj) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tokenValidForFlagName) { + t.Errorf("error should name the flag, got %q", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("validFor = %q, want %q", got, tc.want) + } + }) + } +} + +// A zone token must carry a scope by default. Omitting it makes the control +// plane answer 500 instead of falling back to the distribution's full scope, +// and kumactl defaults the same way. Do not remove this default without +// confirming the control plane handles an absent scope. +func TestZoneTokenDefaultsToControlPlaneScope(t *testing.T) { + cmdObj := newZoneTokenCmd(nil) + + scope, err := cmdObj.Flags().GetStringSlice(tokenScopeFlagName) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(scope, []string{controlPlaneZoneScope}) { + t.Errorf("default scope = %v, want [%s]", scope, controlPlaneZoneScope) + } +} + +// Empty fields are omitted so the control plane applies its own defaults, +// while the fields it requires are always present. +func TestDataplaneTokenRequestOmitsEmptyFields(t *testing.T) { + body, err := json.Marshal(dataplaneTokenRequest{Mesh: "default", ValidFor: "24h0m0s"}) + if err != nil { + t.Fatal(err) + } + + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + + for _, required := range []string{"mesh", "validFor"} { + if _, ok := got[required]; !ok { + t.Errorf("%s must always be sent, got %s", required, body) + } + } + for _, omitted := range []string{"name", "tags", "type", "workload"} { + if _, ok := got[omitted]; ok { + t.Errorf("%s should be omitted when empty, got %s", omitted, body) + } + } +} + +func TestZoneTokenRequestShape(t *testing.T) { + body, err := json.Marshal(zoneTokenRequest{ + Zone: "zone-1", Scope: []string{controlPlaneZoneScope}, ValidFor: "24h0m0s", + }) + if err != nil { + t.Fatal(err) + } + want := `{"zone":"zone-1","scope":["cp"],"validFor":"24h0m0s"}` + if string(body) != want { + t.Errorf("body = %s, want %s", body, want) + } +} + +// Both commands take no positional arguments; everything is a flag. +func TestTokenCommandsRejectPositionalArgs(t *testing.T) { + if err := newDataplaneTokenCmd(nil).Args(newDataplaneTokenCmd(nil), []string{"stray"}); err == nil { + t.Error("dataplane-token should reject positional arguments") + } + if err := newZoneTokenCmd(nil).Args(newZoneTokenCmd(nil), []string{"stray"}); err == nil { + t.Error("zone-token should reject positional arguments") + } +} diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go index bb6678fe9..7d2873733 100644 --- a/internal/cmd/root/products/konnect/mesh/mesh.go +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -95,6 +95,11 @@ func NewMeshCmd( return err } if verb == verbs.Create { + // Resources come from -f, so a positional argument here is either + // a mistyped subcommand or a misunderstanding of the command. + if len(args) > 0 { + return cmd.UnknownSubcommandError(cmdObj, args[0]) + } // Read the flag rather than binding a variable: one process can // hold a mesh command per verb, and a shared variable would leak // between them. @@ -137,6 +142,10 @@ func NewMeshCmd( if verb == verbs.Get { baseCmd.AddCommand(newGetResourceTypesCmd(verb, addParentFlags, parentPreRun)) } + if verb == verbs.Create { + baseCmd.AddCommand(newDataplaneTokenCmd(parentPreRun)) + baseCmd.AddCommand(newZoneTokenCmd(parentPreRun)) + } return baseCmd, nil } From 88d921178c0332a9d664b4047382f41387839631 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 9 Sep 2026 10:40:58 +0100 Subject: [PATCH 05/15] feat(mesh): list control planes and select one by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `get mesh control-planes` and makes --control-plane-name work on every mesh command. Until now a control plane could only be addressed by UUID, and there was no way to discover one from kongctl at all. A name is resolved to an identifier by listing the control planes, which needs a Konnect call and so cannot live in the configuration-only resolver in mesh/common. The client resolves it and writes the identifier back to configuration, leaving URL composition in one place. The no-control-plane case is now a sentinel error, so a caller able to reach Konnect can tell "nothing was selected" from "a name was given" and try the name before surfacing anything. Konnect does not constrain control plane names to be unique, so an ambiguous name is reported with the matching identifiers rather than resolved arbitrarily — picking one would send writes to a control plane the operator did not choose. The listing labels the version column API LINE rather than VERSION. That field is the Konnect API line, v0 or v3, not the version the control plane runs: a control plane labelled v3 is reached on the v3 prefix, while the v1 prefix on the same identifier reaches a 2.14 control plane. Calling it VERSION invites exactly the confusion that made an earlier session conclude no v3 control plane existed. The long help says where the real version comes from. Identifiers abbreviate in text output as they do elsewhere in kongctl, which is unhelpful for a command whose purpose is to hand over an identifier. Rather than special-case the shared output layer, the help names --text-id-format full and -o json, and points out that --control-plane-name usually removes the need to copy one. Verified against the live control plane: the listing, resolution by name, an unknown name, and the unchanged error when nothing is selected. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 7e32cc13b13b8d0e855e982686dd637765ba6730) --- .../cmd/root/products/konnect/mesh/client.go | 31 +++- .../products/konnect/mesh/common/common.go | 42 +++-- .../konnect/mesh/common/common_test.go | 22 ++- .../products/konnect/mesh/controlPlanes.go | 156 ++++++++++++++++++ .../konnect/mesh/controlPlanes_test.go | 62 +++++++ .../products/konnect/mesh/getControlPlanes.go | 136 +++++++++++++++ .../cmd/root/products/konnect/mesh/mesh.go | 1 + 7 files changed, 422 insertions(+), 28 deletions(-) create mode 100644 internal/cmd/root/products/konnect/mesh/controlPlanes.go create mode 100644 internal/cmd/root/products/konnect/mesh/controlPlanes_test.go create mode 100644 internal/cmd/root/products/konnect/mesh/getControlPlanes.go diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index fc3bfc285..06df0f73b 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -12,6 +12,7 @@ import ( "github.com/kong/kongctl/internal/cmd" konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/config" "github.com/kong/kongctl/internal/konnect/apiutil" "github.com/kong/kongctl/internal/konnect/httpclient" ) @@ -89,7 +90,7 @@ func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, err return nil, 0, err } - baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + baseURL, err := resolveBaseURL(helper, cfg) if err != nil { return nil, 0, err } @@ -170,3 +171,31 @@ func buildAPIError(statusCode int, body []byte) error { } return fmt.Errorf("control plane request failed with status %d", statusCode) } + +// resolveBaseURL determines which control plane a command addresses. +// +// meshcommon resolves an explicit URL or an identifier from configuration +// alone. A name needs a Konnect call to become an identifier, which cannot +// live there, so it is resolved here and the identifier written back to +// configuration — leaving the composition itself in one place. +func resolveBaseURL(helper cmd.Helper, cfg config.Hook) (string, error) { + baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + if err == nil { + return baseURL, nil + } + + name := strings.TrimSpace(cfg.GetString(meshcommon.ControlPlaneNameConfigPath)) + if name == "" { + // Nothing identifies a control plane, so report that rather than the + // failure to resolve a name that was never given. + return "", err + } + + controlPlaneID, resolveErr := resolveControlPlaneIDByName(helper, name) + if resolveErr != nil { + return "", resolveErr + } + + cfg.SetString(meshcommon.ControlPlaneIDConfigPath, controlPlaneID) + return meshcommon.ResolveControlPlaneAPIURL(cfg) +} diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 75fc21937..98b8f6a6b 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -38,9 +38,9 @@ var ( AllMeshesConfigPath = "konnect.mesh.all-meshes" ) -// controlPlaneAPIPathFormat fronts a Konnect hosted Kong Mesh control plane's -// own API. The control plane identifier travels in the path, so callers do not -// send a separate tenant header. +// ControlPlanesPath lists the Konnect hosted Kong Mesh control planes, and +// each control plane's own API hangs off its entry there. The control plane +// identifier travels in the path, so callers send no separate tenant header. // // The leading segment selects the Kong Mesh API line, and one Konnect control // plane serves more than one: /v1/mesh/control-planes/{id}/api reaches a 2.14 @@ -48,12 +48,13 @@ var ( // These are distinct control planes behind a single Konnect identifier, and the // /api segment exists only on the v1 line. kongctl supports Kong Mesh 3 only, // so it composes the v3 form. -const controlPlaneAPIPathFormat = "/v3/mesh/control-planes/%s" +const ControlPlanesPath = "/v3/mesh/control-planes" // ControlPlaneAPIPath returns the Konnect path prefix for a hosted Kong Mesh -// control plane API. +// control plane API. A control plane's own API hangs directly off its entry in +// the control plane collection. func ControlPlaneAPIPath(controlPlaneID string) string { - return fmt.Sprintf(controlPlaneAPIPathFormat, controlPlaneID) + return ControlPlanesPath + "/" + controlPlaneID } // ResolveControlPlaneAPIURL returns the base URL of the Kong Mesh control plane @@ -75,7 +76,7 @@ func ResolveControlPlaneAPIURL(cfg config.Hook) (string, error) { controlPlaneID := strings.TrimSpace(cfg.GetString(ControlPlaneIDConfigPath)) if controlPlaneID == "" { - return "", missingControlPlaneError(cfg) + return "", ErrNoControlPlaneSelected } konnectBaseURL, err := konnectcommon.ResolveBaseURL(cfg) @@ -86,22 +87,17 @@ func ResolveControlPlaneAPIURL(cfg config.Hook) (string, error) { return strings.TrimRight(konnectBaseURL, "/") + ControlPlaneAPIPath(controlPlaneID), nil } -// missingControlPlaneError explains which inputs identify a control plane, -// naming the control plane by name when one was given but not yet resolved. -func missingControlPlaneError(cfg config.Hook) error { - if name := strings.TrimSpace(cfg.GetString(ControlPlaneNameConfigPath)); name != "" { - return fmt.Errorf( - "control plane %q has not been resolved to an identifier; provide --%s instead", - name, ControlPlaneIDFlagName, - ) - } - return fmt.Errorf( - "no Kong Mesh control plane selected; provide --%s for a Konnect hosted control plane, "+ - "or --%s for a self managed one", - ControlPlaneIDFlagName, - ControlPlaneURLFlagName, - ) -} +// ErrNoControlPlaneSelected reports that nothing identified a control plane. +// +// A name is not resolvable from configuration alone, so callers that can reach +// Konnect check for this and try the name before surfacing it. +var ErrNoControlPlaneSelected = fmt.Errorf( + "no Kong Mesh control plane selected; provide --%s or --%s for a Konnect hosted control plane, "+ + "or --%s for a self managed one", + ControlPlaneIDFlagName, + ControlPlaneNameFlagName, + ControlPlaneURLFlagName, +) // ResolveMesh returns the mesh that mesh scoped requests apply to. func ResolveMesh(cfg config.Hook) string { diff --git a/internal/cmd/root/products/konnect/mesh/common/common_test.go b/internal/cmd/root/products/konnect/mesh/common/common_test.go index da2c96403..1e839909e 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common_test.go +++ b/internal/cmd/root/products/konnect/mesh/common/common_test.go @@ -1,6 +1,7 @@ package common import ( + "errors" "strings" "testing" @@ -84,17 +85,30 @@ func TestResolveControlPlaneAPIURLWithoutSelection(t *testing.T) { } } +// A name cannot be turned into an identifier from configuration alone, so this +// reports the sentinel rather than an error about the name. Callers that can +// reach Konnect check for the sentinel and resolve the name themselves. func TestResolveControlPlaneAPIURLWithUnresolvedName(t *testing.T) { cfg := stubConfig(map[string]string{ ControlPlaneNameConfigPath: "my-mesh-cp", }) _, err := ResolveControlPlaneAPIURL(cfg) - if err == nil { - t.Fatal("expected an error when only a name is configured") + if !errors.Is(err, ErrNoControlPlaneSelected) { + t.Errorf("expected ErrNoControlPlaneSelected, got %v", err) } - if !strings.Contains(err.Error(), "my-mesh-cp") { - t.Errorf("expected error %q to name the control plane", err.Error()) +} + +func TestResolveControlPlaneAPIURLWithNothingSelected(t *testing.T) { + _, err := ResolveControlPlaneAPIURL(stubConfig(map[string]string{})) + if !errors.Is(err, ErrNoControlPlaneSelected) { + t.Errorf("expected ErrNoControlPlaneSelected, got %v", err) + } + // The message has to name every way a control plane can be given. + for _, flag := range []string{ControlPlaneIDFlagName, ControlPlaneNameFlagName, ControlPlaneURLFlagName} { + if !strings.Contains(err.Error(), flag) { + t.Errorf("error should mention --%s, got %q", flag, err) + } } } diff --git a/internal/cmd/root/products/konnect/mesh/controlPlanes.go b/internal/cmd/root/products/konnect/mesh/controlPlanes.go new file mode 100644 index 000000000..2744525f7 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/controlPlanes.go @@ -0,0 +1,156 @@ +package mesh + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/kong/kongctl/internal/cmd" + konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/konnect/apiutil" + "github.com/kong/kongctl/internal/konnect/httpclient" +) + +// ControlPlane is a Konnect hosted Kong Mesh control plane. +// +// Version is the Konnect API line the control plane is reached on — "v0" or +// "v3" — not the control plane's own version, which only GET / reports. A +// control plane labelled v3 may still be running 2.14 behind the v1 line, so +// nothing may be inferred from this field beyond which prefix to use. +type ControlPlane struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Labels map[string]string `json:"labels"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Features []any `json:"features"` +} + +// controlPlanesResponse is the standard Konnect list envelope, which differs +// from the envelope a control plane's own API uses for resource lists. +type controlPlanesResponse struct { + Data []ControlPlane `json:"data"` + Meta struct { + Page struct { + Number int `json:"number"` + Size int `json:"size"` + Total int `json:"total"` + } `json:"page"` + } `json:"meta"` +} + +// controlPlanePageSize is how many control planes are requested per call. +const controlPlanePageSize = 100 + +// ListControlPlanes returns every Kong Mesh control plane the authenticated +// identity can see. +// +// This addresses Konnect rather than a control plane, so it composes the +// Konnect base URL directly instead of going through the per control plane +// resolver — which would be circular, since that resolver may need this list. +func ListControlPlanes(helper cmd.Helper) ([]ControlPlane, error) { + cfg, err := helper.GetConfig() + if err != nil { + return nil, err + } + + logger, err := helper.GetLogger() + if err != nil { + return nil, err + } + + baseURL, err := konnectcommon.ResolveBaseURL(cfg) + if err != nil { + return nil, err + } + + tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) + if err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + ctx := helper.GetContext() + if ctx == nil { + ctx = context.Background() + } + if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { + return nil, fmt.Errorf("resolve Konnect access token: %w", err) + } + + var controlPlanes []ControlPlane + client := httpclient.NewLoggingHTTPClient(logger) + + for page := 1; ; page++ { + query := url.Values{} + query.Set("page[size]", fmt.Sprint(controlPlanePageSize)) + query.Set("page[number]", fmt.Sprint(page)) + path := meshcommon.ControlPlanesPath + "?" + query.Encode() + + result, err := apiutil.RequestWithTokenSource( + ctx, client, http.MethodGet, strings.TrimRight(baseURL, "/"), path, tokenSource, nil, nil) + if err != nil { + return nil, err + } + + logger.Debug("mesh control plane list call completed", + "path", path, "status_code", result.StatusCode) + + if result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices { + return nil, buildAPIError(result.StatusCode, result.Body) + } + + var payload controlPlanesResponse + if err := json.Unmarshal(result.Body, &payload); err != nil { + return nil, fmt.Errorf("failed to decode the mesh control plane list: %w", err) + } + + controlPlanes = append(controlPlanes, payload.Data...) + + // An empty page ends the listing whatever the total says, so a + // miscounted total cannot spin here. + if len(payload.Data) == 0 || len(controlPlanes) >= payload.Meta.Page.Total { + return controlPlanes, nil + } + } +} + +// resolveControlPlaneIDByName finds the control plane an operator named. +// +// Konnect does not constrain control plane names to be unique, so an ambiguous +// name is reported rather than resolved arbitrarily: picking one would send +// writes to a control plane the operator did not choose. +func resolveControlPlaneIDByName(helper cmd.Helper, name string) (string, error) { + controlPlanes, err := ListControlPlanes(helper) + if err != nil { + return "", err + } + + var matches []ControlPlane + for _, controlPlane := range controlPlanes { + if controlPlane.Name == name { + matches = append(matches, controlPlane) + } + } + + switch len(matches) { + case 1: + return matches[0].ID, nil + case 0: + return "", fmt.Errorf( + "no Kong Mesh control plane named %q; run 'get mesh control-planes' to list them", name) + default: + ids := make([]string, 0, len(matches)) + for _, match := range matches { + ids = append(ids, match.ID) + } + return "", fmt.Errorf( + "%d Kong Mesh control planes are named %q; select one with --%s: %s", + len(matches), name, meshcommon.ControlPlaneIDFlagName, strings.Join(ids, ", ")) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/controlPlanes_test.go b/internal/cmd/root/products/konnect/mesh/controlPlanes_test.go new file mode 100644 index 000000000..ee874040d --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/controlPlanes_test.go @@ -0,0 +1,62 @@ +package mesh + +import ( + "encoding/json" + "testing" +) + +// The Konnect control plane list uses a different envelope from the one a +// control plane's own API uses for resource lists, so both are decoded. +func TestControlPlanesResponseDecoding(t *testing.T) { + body := `{ + "data": [ + {"id":"11111111-1111-1111-1111-111111111111","name":"prod","version":"v3", + "labels":{"team":"mesh"},"created_at":"2026-09-07T10:29:26Z", + "features":[{"type":"MeshCreation","meshCreation":{"enabled":false}}]}, + {"id":"22222222-2222-2222-2222-222222222222","name":"legacy","version":"v0"} + ], + "meta": {"page": {"number": 1, "size": 100, "total": 2}} + }` + + var payload controlPlanesResponse + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatal(err) + } + + if len(payload.Data) != 2 { + t.Fatalf("expected 2 control planes, got %d", len(payload.Data)) + } + if payload.Meta.Page.Total != 2 { + t.Errorf("total = %d, want 2", payload.Meta.Page.Total) + } + + first := payload.Data[0] + if first.Name != "prod" || first.ID != "11111111-1111-1111-1111-111111111111" { + t.Errorf("unexpected first control plane: %+v", first) + } + // Version is the API line, not the control plane's version. A v0 entry is + // a 2.14 control plane, which this command surface does not support. + if first.Version != "v3" || payload.Data[1].Version != "v0" { + t.Errorf("unexpected API lines: %q, %q", first.Version, payload.Data[1].Version) + } + if first.Labels["team"] != "mesh" { + t.Errorf("labels were dropped: %v", first.Labels) + } + // Features vary in shape and are passed through rather than modelled. + if len(first.Features) != 1 { + t.Errorf("features were dropped: %v", first.Features) + } +} + +// Absent optional fields must not fail the decode: only id and name are +// dependably present. +func TestControlPlanesResponseTolerantOfMissingFields(t *testing.T) { + var payload controlPlanesResponse + err := json.Unmarshal([]byte(`{"data":[{"id":"x","name":"y"}],"meta":{}}`), &payload) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload.Data[0].Version != "" || payload.Data[0].Labels != nil { + t.Errorf("expected zero values, got %+v", payload.Data[0]) + } +} diff --git a/internal/cmd/root/products/konnect/mesh/getControlPlanes.go b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go new file mode 100644 index 000000000..d708eebfd --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go @@ -0,0 +1,136 @@ +package mesh + +import ( + "fmt" + "slices" + "strings" + + "charm.land/bubbles/v2/table" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/tableview" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/meta" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/segmentio/cli" + "github.com/spf13/cobra" +) + +var ( + getControlPlanesShort = i18n.T("root.products.konnect.mesh.getControlPlanesShort", + "List the Kong Mesh control planes available to you") + + getControlPlanesLong = normalizers.LongDesc(i18n.T("root.products.konnect.mesh.getControlPlanesLong", + `List the Konnect hosted Kong Mesh control planes the authenticated identity +can see, with the identifier every other mesh command needs. + +The API LINE column is the Konnect API line a control plane is reached on, +not the version it runs. A control plane on the v3 line runs Kong Mesh 3; +one on the v0 line runs the 2.14 series, which this command surface does +not support. To read the version a control plane actually runs, address it +and read its index endpoint. + +Identifiers are abbreviated in text output, as they are elsewhere in +kongctl. Pass --text-id-format full to print them whole, or -o json. In +most cases the identifier is not needed at all: other mesh commands accept +--control-plane-name.`)) + + getControlPlanesExample = normalizers.Examples(i18n.T("root.products.konnect.mesh.getControlPlanesExample", + fmt.Sprintf(` + # List the control planes available + %[1]s get mesh control-planes + + # Use one by name, without looking up its identifier + %[1]s get mesh dataplanes --control-plane-name my-mesh + + # Print identifiers in full, to copy one + %[1]s get mesh control-planes --text-id-format full + `, meta.CLIName))) +) + +// controlPlaneRow is the text table projection of a control plane. +type controlPlaneRow struct { + Name string `json:"name" table:"NAME"` + ID string `json:"id" table:"ID"` + APILine string `json:"api_line" table:"API LINE"` +} + +type getControlPlanesCmd struct { + *cobra.Command +} + +func newGetControlPlanesCmd( + verb verbs.VerbValue, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) *cobra.Command { + c := &getControlPlanesCmd{} + cmdObj := &cobra.Command{ + Use: "control-planes", + Aliases: []string{"control-plane", "cps", "cp"}, + Short: getControlPlanesShort, + Long: getControlPlanesLong, + Example: getControlPlanesExample, + Args: cobra.NoArgs, + RunE: c.runE, + } + + c.Command = cmdObj + if parentPreRun != nil { + c.PreRunE = parentPreRun + } + if addParentFlags != nil { + addParentFlags(verb, c.Command) + } + return c.Command +} + +func (c *getControlPlanesCmd) runE(cobraCmd *cobra.Command, args []string) error { + helper := cmd.BuildHelper(cobraCmd, args) + + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + printer, err := cli.Format(outType.String(), helper.GetStreams().Out) + if err != nil { + return err + } + defer printer.Flush() + + controlPlanes, err := ListControlPlanes(helper) + if err != nil { + return cmd.PrepareExecutionError("failed to list mesh control planes", err, helper.GetCmd()) + } + + rows := make([]controlPlaneRow, 0, len(controlPlanes)) + for _, controlPlane := range controlPlanes { + rows = append(rows, controlPlaneRow{ + Name: controlPlane.Name, + ID: controlPlane.ID, + APILine: controlPlane.Version, + }) + } + slices.SortFunc(rows, func(a, b controlPlaneRow) int { + return strings.Compare(a.Name, b.Name) + }) + + tableRows := make([]table.Row, 0, len(rows)) + for _, row := range rows { + tableRows = append(tableRows, table.Row{row.Name, row.ID, row.APILine}) + } + + return tableview.RenderForFormat( + helper, + false, + outType, + printer, + helper.GetStreams(), + rows, + controlPlanes, + "Mesh Control Planes", + tableview.WithExactCustomTable([]string{"NAME", "ID", "API LINE"}, tableRows), + tableview.WithRootLabel(helper.GetCmd().Name()), + ) +} diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go index 7d2873733..a603f85a0 100644 --- a/internal/cmd/root/products/konnect/mesh/mesh.go +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -141,6 +141,7 @@ func NewMeshCmd( if verb == verbs.Get { baseCmd.AddCommand(newGetResourceTypesCmd(verb, addParentFlags, parentPreRun)) + baseCmd.AddCommand(newGetControlPlanesCmd(verb, addParentFlags, parentPreRun)) } if verb == verbs.Create { baseCmd.AddCommand(newDataplaneTokenCmd(parentPreRun)) From dcd70596d17cf442c161214612041a507f9d22b2 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 9 Sep 2026 17:03:54 +0100 Subject: [PATCH 06/15] fix(mesh): satisfy the linter on yaml import and an unused parameter Two golangci-lint failures on the pull request. gopkg.in/yaml.v3 is on the gomodguard blocked list. The blocklist recommends sigs.k8s.io/yaml, which has no streaming decoder and would mean splitting multi document input by hand. go.yaml.in/yaml/v4 is not blocked, is already a direct dependency used in five other places here, and offers the same NewDecoder and Decode with the same io.EOF behaviour, so the change is an import swap. The repository does exempt yaml.v3 in internal/declarative/tags with //nolint:gomodguard_v2, but those exemptions cite its custom tag support, which is not why this code needed it. Moving to the unblocked library is better than widening the exemption. buildRows never used its descriptor parameter: the row carries every column, and which of them are printed is decided by headersFor and cellsFor. Dropped rather than blanked, since nothing needs it. Verified with golangci-lint v2.13.1, the version CI pins, under GOOS=linux: no issues in the mesh packages. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 927ed6b42e05fa14cfc454e5dd908bd2b604411c) --- internal/cmd/root/products/konnect/mesh/createResources.go | 2 +- internal/cmd/root/products/konnect/mesh/getResources.go | 2 +- internal/cmd/root/products/konnect/mesh/printers.go | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go index 219aad409..bea8e922b 100644 --- a/internal/cmd/root/products/konnect/mesh/createResources.go +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -19,7 +19,7 @@ import ( "github.com/kong/kongctl/internal/konnect/apiutil" "github.com/kong/kongctl/internal/konnect/httpclient" "github.com/segmentio/cli" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // meshResource is one document read from the input, carrying only the fields diff --git a/internal/cmd/root/products/konnect/mesh/getResources.go b/internal/cmd/root/products/konnect/mesh/getResources.go index 754f1ef6f..a0866049b 100644 --- a/internal/cmd/root/products/konnect/mesh/getResources.go +++ b/internal/cmd/root/products/konnect/mesh/getResources.go @@ -84,7 +84,7 @@ func runGetResources(helper cmd.Helper, args []string) error { return err } - rows := buildRows(descriptor, items, time.Now()) + rows := buildRows(items, time.Now()) headers := headersFor(descriptor) tableRows := make([]table.Row, 0, len(rows)) diff --git a/internal/cmd/root/products/konnect/mesh/printers.go b/internal/cmd/root/products/konnect/mesh/printers.go index e81361b39..fa9fe86cc 100644 --- a/internal/cmd/root/products/konnect/mesh/printers.go +++ b/internal/cmd/root/products/konnect/mesh/printers.go @@ -55,7 +55,10 @@ func cellsFor(d ResourceDescriptor, row resourceRow) []string { } // buildRows projects control plane items into rows. -func buildRows(d ResourceDescriptor, items []map[string]any, now time.Time) []resourceRow { +// +// Every column is filled whatever the resource type; which of them are printed +// is decided by headersFor and cellsFor, so no descriptor is needed here. +func buildRows(items []map[string]any, now time.Time) []resourceRow { rows := make([]resourceRow, 0, len(items)) for _, item := range items { rows = append(rows, resourceRow{ From 12fe535568344e1ed667c14d240955b9a0b62792 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Tue, 15 Sep 2026 08:58:31 +0100 Subject: [PATCH 07/15] feat(mesh): surface control plane warnings on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful mesh write is answered with {"warnings":[...]} carrying deprecation notices for the resource just written. applyResource went through sendForStatus, which keeps the status code and discards the body, so those notices were lost: applying a MeshRateLimit whose onRateLimit.status is below 400 reported only "created" while the control plane had asked for the field to be changed. Four types emit them today — MeshService, MeshExternalService, MeshMultiZoneService and MeshRateLimit. Adds sendForWrite alongside sendForStatus, returning the status and the parsed warnings, and leaves the delete path on sendForStatus since a delete response carries none. Warnings print as each document is applied, so the notice sits with the write that caused it, and go to stderr in kongctl's existing "warning: ..." form: the summary table on stdout stays byte-identical and pipes cleanly. They are also carried on the result row, so -o json does not lose what stderr reported. Parsing is deliberately forgiving. A body that is empty, is not JSON, or has no warnings yields none rather than an error, because failing a write the control plane accepted would be worse than dropping a notice. Verified against a Konnect control plane: the deprecating policy prints the warning on stderr with the table on stdout and the warning in JSON output, and a policy with a valid status prints no stderr at all. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit be52c508cd520fca1d149c70195ad4073eddafd2) --- .../cmd/root/products/konnect/mesh/client.go | 44 ++++++++++ .../products/konnect/mesh/createResources.go | 61 +++++++++++--- .../konnect/mesh/createResources_test.go | 83 +++++++++++++++++++ 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index 06df0f73b..ac7d7854c 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -74,6 +74,50 @@ func sendForStatus(helper cmd.Helper, method, path string, body []byte) (int, er return status, err } +// sendForWrite performs a write and returns the response status together with +// any warnings the control plane reported. +// +// A successful create or update is answered with {"warnings":[...]} carrying +// deprecation notices for the resource that was just written. They are the only +// place the control plane reports a resource it accepted but wants changed, so +// they are read here rather than discarded with the rest of the body. +func sendForWrite(helper cmd.Helper, method, path string, body []byte) (int, []string, error) { + respBody, status, err := send(helper, method, path, body) + if err != nil { + return status, nil, err + } + return status, parseWarnings(respBody), nil +} + +// parseWarnings reads the warnings from a successful write response. +// +// The warnings are advisory, so a body that is empty, is not JSON, or carries +// no warnings yields none rather than an error: failing a write that the +// control plane accepted would be worse than losing the notice. +func parseWarnings(body []byte) []string { + if len(body) == 0 { + return nil + } + + var payload struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil + } + + warnings := make([]string, 0, len(payload.Warnings)) + for _, warning := range payload.Warnings { + if trimmed := strings.TrimSpace(warning); trimmed != "" { + warnings = append(warnings, trimmed) + } + } + if len(warnings) == 0 { + return nil + } + return warnings +} + // send performs a request against the selected control plane and returns the // response body. // diff --git a/internal/cmd/root/products/konnect/mesh/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go index bea8e922b..5b6ecc6b8 100644 --- a/internal/cmd/root/products/konnect/mesh/createResources.go +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -37,6 +37,9 @@ type meshResource struct { type applyResult struct { Resource meshResource Created bool + // Warnings are the notices the control plane returned for a write it + // accepted, such as a deprecated field. + Warnings []string Err error } @@ -75,11 +78,20 @@ func runCreateResources(helper cmd.Helper, filenames []string) error { results := make([]applyResult, 0, len(resources)) var failed bool for _, resource := range resources { - created, err := applyResource(helper, descriptors, resource) + created, warnings, err := applyResource(helper, descriptors, resource) if err != nil { failed = true } - results = append(results, applyResult{Resource: resource, Created: created, Err: err}) + // Reported as each document is applied, before the summary, so the + // notice is attached to the write that caused it and reaches stderr + // even when the summary is machine-readable. + reportApplyWarnings(helper, resource, warnings) + results = append(results, applyResult{ + Resource: resource, + Created: created, + Warnings: warnings, + Err: err, + }) } if err := reportApplyResults(helper, results); err != nil { @@ -98,16 +110,18 @@ var errApplyFailed = errors.New("see the reported resources above") // applyResource sends one document, reporting whether the control plane created // it rather than replaced an existing one. -func applyResource(helper cmd.Helper, descriptors []ResourceDescriptor, resource meshResource) (bool, error) { +func applyResource( + helper cmd.Helper, descriptors []ResourceDescriptor, resource meshResource, +) (bool, []string, error) { descriptor, err := ResolveType(descriptors, resource.Type) if err != nil { - return false, err + return false, nil, err } // The control plane also refuses a write to a read-only type with a 405, // but saying so before sending names the type rather than the status. if descriptor.ReadOnly { - return false, fmt.Errorf( + return false, nil, fmt.Errorf( "%s is read only on this control plane and cannot be created or updated", descriptor.Singular()) } @@ -116,11 +130,29 @@ func applyResource(helper cmd.Helper, descriptors []ResourceDescriptor, resource mesh = "" } - status, err := sendForStatus(helper, http.MethodPut, descriptor.ItemPath(mesh, resource.Name), resource.Body) + status, warnings, err := sendForWrite( + helper, http.MethodPut, descriptor.ItemPath(mesh, resource.Name), resource.Body) if err != nil { - return false, err + return false, nil, err + } + return status == http.StatusCreated, warnings, nil +} + +// reportApplyWarnings writes the control plane's notices for one document to +// stderr, naming the resource because a single command can apply many. +func reportApplyWarnings(helper cmd.Helper, resource meshResource, warnings []string) { + if len(warnings) == 0 { + return + } + + streams := helper.GetStreams() + if streams == nil || streams.ErrOut == nil { + return + } + + for _, warning := range warnings { + fmt.Fprintf(streams.ErrOut, "warning: %s %s: %s\n", resource.Type, resource.Name, warning) } - return status == http.StatusCreated, nil } // readResources collects every document from the given sources. @@ -302,6 +334,10 @@ type applyRow struct { Name string `json:"name" table:"NAME"` Mesh string `json:"mesh" table:"MESH"` Result string `json:"result" table:"RESULT"` + // Warnings is omitted when empty so that the common case renders + // unchanged, and carried otherwise so machine-readable output does not + // lose what stderr reported. + Warnings []string `json:"warnings,omitempty"` } // reportApplyResults renders what happened to each document. Every document is @@ -323,10 +359,11 @@ func reportApplyResults(helper cmd.Helper, results []applyResult) error { tableRows := make([]table.Row, 0, len(results)) for _, result := range results { row := applyRow{ - Type: result.Resource.Type, - Name: result.Resource.Name, - Mesh: result.Resource.Mesh, - Result: describeApplyOutcome(result), + Type: result.Resource.Type, + Name: result.Resource.Name, + Mesh: result.Resource.Mesh, + Result: describeApplyOutcome(result), + Warnings: result.Warnings, } rows = append(rows, row) tableRows = append(tableRows, table.Row{row.Type, row.Name, row.Mesh, row.Result}) diff --git a/internal/cmd/root/products/konnect/mesh/createResources_test.go b/internal/cmd/root/products/konnect/mesh/createResources_test.go index 633f5936f..b35b640de 100644 --- a/internal/cmd/root/products/konnect/mesh/createResources_test.go +++ b/internal/cmd/root/products/konnect/mesh/createResources_test.go @@ -5,6 +5,9 @@ import ( "errors" "strings" "testing" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/iostreams" ) func TestDecodeResourcesMultiDocument(t *testing.T) { @@ -129,3 +132,83 @@ func TestDescribeDocument(t *testing.T) { t.Errorf("unexpected description: %q", got) } } + +func TestParseWarnings(t *testing.T) { + cases := []struct { + name string + body string + want []string + }{ + { + name: "warnings are returned in order", + body: `{"warnings":["first notice","second notice"]}`, + want: []string{"first notice", "second notice"}, + }, + { + name: "blank entries are dropped", + body: `{"warnings":[" ","kept","\t"]}`, + want: []string{"kept"}, + }, + { + name: "surrounding space is trimmed", + body: `{"warnings":[" padded "]}`, + want: []string{"padded"}, + }, + // A write the control plane accepted must not be reported as failed + // just because its body carried nothing useful. + {name: "an empty body yields nothing", body: "", want: nil}, + {name: "an empty object yields nothing", body: `{}`, want: nil}, + {name: "an empty list yields nothing", body: `{"warnings":[]}`, want: nil}, + {name: "a body that is not JSON yields nothing", body: "not json at all", want: nil}, + {name: "a body of the wrong shape yields nothing", body: `{"warnings":"a string"}`, want: nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseWarnings([]byte(tc.body)) + if len(got) != len(tc.want) { + t.Fatalf("expected %v, got %v", tc.want, got) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Errorf("entry %d: expected %q, got %q", i, tc.want[i], got[i]) + } + } + }) + } +} + +func TestReportApplyWarningsNamesTheResource(t *testing.T) { + errOut := &strings.Builder{} + streams := &iostreams.IOStreams{Out: &strings.Builder{}, ErrOut: errOut} + helper := &cmd.MockHelper{} + helper.EXPECT().GetStreams().Return(streams) + + resource := meshResource{Type: "MeshRateLimit", Name: "warn-probe", Mesh: "default"} + reportApplyWarnings(helper, resource, []string{"status must be 400 or higher", "second notice"}) + + got := errOut.String() + // Naming the resource matters because one command can apply many. + for _, want := range []string{ + "warning: MeshRateLimit warn-probe: status must be 400 or higher\n", + "warning: MeshRateLimit warn-probe: second notice\n", + } { + if !strings.Contains(got, want) { + t.Errorf("expected stderr to contain %q, got %q", want, got) + } + } +} + +func TestReportApplyWarningsSilentWithoutWarnings(t *testing.T) { + errOut := &strings.Builder{} + streams := &iostreams.IOStreams{Out: &strings.Builder{}, ErrOut: errOut} + helper := &cmd.MockHelper{} + helper.EXPECT().GetStreams().Return(streams).Maybe() + + reportApplyWarnings(helper, meshResource{Type: "MeshTimeout", Name: "slow"}, nil) + + // The common case must stay quiet: a clean apply prints no stderr at all. + if got := errOut.String(); got != "" { + t.Errorf("expected no output, got %q", got) + } +} From 66a56807ca3336a381667a2e814acdd3a4660ad8 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 08:17:19 +0100 Subject: [PATCH 08/15] fix(mesh): honor explicit control plane selection, stop shadowing --profile Both from review on #2128. Selection accepted three alternatives but resolved them in a fixed order that never consulted the name, so a configured konnect.mesh.control-plane.id answered an explicit --control-plane-name. Since the same resolver serves writes and deletes, that could act on a control plane the operator did not choose. A selector given on the command line now decides the target, and two at once is reported rather than resolved by precedence: --control-plane-id and --control-plane-name select different control planes; provide only one An explicit ID is likewise no longer shadowed by a configured URL, via a new ControlPlaneAPIURLForID that addresses one control plane without re-entering the precedence. Configuration-only selection keeps the documented URL, ID, name order. The export selection was registered as --profile, which shadowed kongctl's global configuration profile: `dump mesh --profile tech` failed as an invalid export selection instead of switching profile. Renamed to --export-profile and given a configuration path (konnect.mesh.export-profile), read through configuration so a persistent default is honoured with the flag winning. Verified against a Konnect control plane: an explicit name that does not exist now fails instead of silently using the configured ID, an existing name resolves, conflicting selectors are rejected, `dump mesh --profile tech` now selects the profile, and `--export-profile all` exports. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 2c96f3bbf4559ea308ef844a7b1a011f51aa6f31) --- .../cmd/root/products/konnect/mesh/client.go | 90 +++++++++++++++++- .../root/products/konnect/mesh/client_test.go | 92 +++++++++++++++++++ .../products/konnect/mesh/common/common.go | 20 ++++ 3 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/root/products/konnect/mesh/client_test.go diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index ac7d7854c..86b4a9451 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -223,6 +223,29 @@ func buildAPIError(statusCode int, body []byte) error { // live there, so it is resolved here and the identifier written back to // configuration — leaving the composition itself in one place. func resolveBaseURL(helper cmd.Helper, cfg config.Hook) (string, error) { + // A selector named on the command line is this invocation's intent, so it + // decides the target even when configuration names a different one. Without + // this, a configured ID answers an explicit --control-plane-name, and since + // this resolver also serves writes and deletes that would act on a control + // plane the operator did not choose. + selector, err := explicitControlPlaneSelector(helper) + if err != nil { + return "", err + } + + switch selector { + case meshcommon.ControlPlaneURLFlagName: + return meshcommon.ResolveControlPlaneAPIURL(cfg) + case meshcommon.ControlPlaneIDFlagName: + return meshcommon.ControlPlaneAPIURLForID( + cfg, cfg.GetString(meshcommon.ControlPlaneIDConfigPath)) + case meshcommon.ControlPlaneNameFlagName: + return resolveBaseURLByName( + helper, cfg, cfg.GetString(meshcommon.ControlPlaneNameConfigPath)) + } + + // Nothing was named on the command line, so configuration decides in the + // documented order: URL, then ID, then name. baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) if err == nil { return baseURL, nil @@ -235,11 +258,70 @@ func resolveBaseURL(helper cmd.Helper, cfg config.Hook) (string, error) { return "", err } - controlPlaneID, resolveErr := resolveControlPlaneIDByName(helper, name) - if resolveErr != nil { - return "", resolveErr + return resolveBaseURLByName(helper, cfg, name) +} + +// resolveBaseURLByName turns a control plane name into its API URL. +// +// A name is not resolvable from configuration alone, so it is looked up against +// Konnect and the resulting ID is recorded for the rest of the invocation. +func resolveBaseURLByName(helper cmd.Helper, cfg config.Hook, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", meshcommon.ErrNoControlPlaneSelected + } + + controlPlaneID, err := resolveControlPlaneIDByName(helper, name) + if err != nil { + return "", err } cfg.SetString(meshcommon.ControlPlaneIDConfigPath, controlPlaneID) - return meshcommon.ResolveControlPlaneAPIURL(cfg) + return meshcommon.ControlPlaneAPIURLForID(cfg, controlPlaneID) +} + +// controlPlaneSelectorFlags are the mutually exclusive ways to name a control +// plane, in the order configuration consults them. +var controlPlaneSelectorFlags = []string{ + meshcommon.ControlPlaneURLFlagName, + meshcommon.ControlPlaneIDFlagName, + meshcommon.ControlPlaneNameFlagName, +} + +// explicitControlPlaneSelector reports which selector was given on the command +// line, or "" when none was. +// +// Two selectors at once is rejected rather than resolved by precedence: the +// operator has asked for two different control planes and guessing which one +// they meant is worse than making them choose. +func explicitControlPlaneSelector(helper cmd.Helper) (string, error) { + if helper == nil { + return "", nil + } + command := helper.GetCmd() + if command == nil { + return "", nil + } + + var named []string + for _, flag := range controlPlaneSelectorFlags { + if command.Flags().Changed(flag) { + named = append(named, flag) + } + } + + switch len(named) { + case 0: + return "", nil + case 1: + return named[0], nil + default: + quoted := make([]string, 0, len(named)) + for _, flag := range named { + quoted = append(quoted, "--"+flag) + } + return "", &cmd.ConfigurationError{Err: fmt.Errorf( + "%s select different control planes; provide only one", + strings.Join(quoted, " and "))} + } } diff --git a/internal/cmd/root/products/konnect/mesh/client_test.go b/internal/cmd/root/products/konnect/mesh/client_test.go new file mode 100644 index 000000000..5ec93aa80 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/client_test.go @@ -0,0 +1,92 @@ +package mesh + +import ( + "testing" + + "github.com/kong/kongctl/internal/cmd" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// selectorCmd builds a command carrying the control plane selection flags, with +// the named ones marked as given on the command line. +func selectorCmd(t *testing.T, given ...string) *cobra.Command { + t.Helper() + + cmdObj := &cobra.Command{Use: "mesh-selector-test"} + meshcommon.AddControlPlaneFlags(cmdObj.Flags()) + for _, flag := range given { + require.NoError(t, cmdObj.Flags().Set(flag, "value-for-"+flag)) + } + return cmdObj +} + +func TestExplicitControlPlaneSelector(t *testing.T) { + cases := []struct { + name string + given []string + want string + }{ + {name: "nothing given", given: nil, want: ""}, + {name: "id", given: []string{meshcommon.ControlPlaneIDFlagName}, want: meshcommon.ControlPlaneIDFlagName}, + { + name: "name", + given: []string{meshcommon.ControlPlaneNameFlagName}, + want: meshcommon.ControlPlaneNameFlagName, + }, + {name: "url", given: []string{meshcommon.ControlPlaneURLFlagName}, want: meshcommon.ControlPlaneURLFlagName}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + helper := &cmd.MockHelper{} + helper.EXPECT().GetCmd().Return(selectorCmd(t, tc.given...)) + + got, err := explicitControlPlaneSelector(helper) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// Two selectors name two different control planes, and this resolver serves +// writes and deletes, so the conflict is reported rather than resolved. +func TestExplicitControlPlaneSelectorRejectsConflicts(t *testing.T) { + cases := [][]string{ + {meshcommon.ControlPlaneIDFlagName, meshcommon.ControlPlaneNameFlagName}, + {meshcommon.ControlPlaneURLFlagName, meshcommon.ControlPlaneIDFlagName}, + {meshcommon.ControlPlaneURLFlagName, meshcommon.ControlPlaneNameFlagName}, + { + meshcommon.ControlPlaneURLFlagName, + meshcommon.ControlPlaneIDFlagName, + meshcommon.ControlPlaneNameFlagName, + }, + } + + for _, given := range cases { + helper := &cmd.MockHelper{} + helper.EXPECT().GetCmd().Return(selectorCmd(t, given...)) + + _, err := explicitControlPlaneSelector(helper) + require.Error(t, err, "expected %v to conflict", given) + require.Contains(t, err.Error(), "provide only one") + for _, flag := range given { + require.Contains(t, err.Error(), "--"+flag) + } + } +} + +// A nil helper or command must not panic: some call paths build the helper +// before a command is attached. +func TestExplicitControlPlaneSelectorWithoutCommand(t *testing.T) { + got, err := explicitControlPlaneSelector(nil) + require.NoError(t, err) + require.Equal(t, "", got) + + helper := &cmd.MockHelper{} + helper.EXPECT().GetCmd().Return(nil) + got, err = explicitControlPlaneSelector(helper) + require.NoError(t, err) + require.Equal(t, "", got) +} diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 98b8f6a6b..79b4a51d0 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -28,6 +28,11 @@ const ( // DefaultMesh matches the default kumactl applies to mesh scoped // resources, so that commands carrying no --mesh behave the same way. DefaultMesh = "default" + + // ExportProfileFlagName selects which types an export covers. It is not + // called "profile": that is kongctl's global configuration profile, and a + // local flag of the same name shadows it. + ExportProfileFlagName = "export-profile" ) var ( @@ -79,6 +84,21 @@ func ResolveControlPlaneAPIURL(cfg config.Hook) (string, error) { return "", ErrNoControlPlaneSelected } + return ControlPlaneAPIURLForID(cfg, controlPlaneID) +} + +// ControlPlaneAPIURLForID builds the API URL of a Konnect hosted control plane +// from its ID. +// +// Callers that have established which selector the operator chose use this to +// address that control plane directly, without re-entering the precedence in +// ResolveControlPlaneAPIURL and picking up a different configured selector. +func ControlPlaneAPIURLForID(cfg config.Hook, controlPlaneID string) (string, error) { + controlPlaneID = strings.TrimSpace(controlPlaneID) + if controlPlaneID == "" { + return "", ErrNoControlPlaneSelected + } + konnectBaseURL, err := konnectcommon.ResolveBaseURL(cfg) if err != nil { return "", err From cc2458ed2753a24291533c31bc01f4b774a6e02a Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 08:33:20 +0100 Subject: [PATCH 09/15] fix(mesh): paginate collections, register the explicit path, modernize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing the review on #2128. Collections were fetched once, so a mesh larger than one page silently reported a subset; the export path already had a correct listAll, so it is now shared from client.go rather than a second policy being written. `get` and both inspect collections use it, and the paging loop is extracted as paginate() taking the page fetch as a function, so its termination rules are testable without HTTP: a short first page is followed, several pages concatenate in order, an empty collection fetches once, and an empty page ends a total that overreports so a control plane cannot make it spin. Structured output rebuilds the envelope from everything collected, without a `next` link that would suggest there is more to read. Verified live: 24 dataplanes in the table, 24 in the JSON, total 24, no next. The constructors promised a direct and an explicit form but only the direct one was registered, so `get konnect mesh` failed with "unknown command". Registered for get, create and dump. Delete is deliberately absent: `delete konnect` is replaced by the declarative delete command and takes its own arguments, so a mesh subcommand there is read as one of them rather than dispatching. That is asserted rather than assumed — the command-path test checks the mesh command's own help appears, which caught the swallowed argument that a check for "unknown command" alone missed. Modernization: sort.SliceStable becomes slices.SortStableFunc, the remaining sort.Strings calls are gone, and displayTags no longer sorts singleton slices or re-sorts an already ordered list — its map is now map[string]string. `go fix ./...` reports no further changes. The repeated "Dataplane" literal is a named constant, which also clears the two pre-existing goconst findings, so the whole repository now lints clean. renderInspect takes the payload rather than raw bytes, removing a marshal-then-unmarshal round trip on the policy inspection path. Also corrected the dump examples, which still told operators to pass --profile for the export selection after it was renamed. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 5e8c76db6cb32e4da46175939688dca392f2608b) --- internal/cmd/root/mesh_command_paths_test.go | 69 +++++++++++ internal/cmd/root/products/konnect/konnect.go | 41 +++++++ .../cmd/root/products/konnect/mesh/client.go | 72 ++++++++++++ .../root/products/konnect/mesh/client_test.go | 108 ++++++++++++++++++ .../products/konnect/mesh/getResources.go | 47 +++++--- .../root/products/konnect/mesh/printers.go | 38 ++++-- 6 files changed, 350 insertions(+), 25 deletions(-) create mode 100644 internal/cmd/root/mesh_command_paths_test.go diff --git a/internal/cmd/root/mesh_command_paths_test.go b/internal/cmd/root/mesh_command_paths_test.go new file mode 100644 index 000000000..043333174 --- /dev/null +++ b/internal/cmd/root/mesh_command_paths_test.go @@ -0,0 +1,69 @@ +package root + +import ( + "strings" + "testing" +) + +// Mesh is reachable both directly and under the explicit product path. The +// constructors promise both, but only the direct form was registered, so +// `kongctl get konnect mesh` failed with "unknown command". These assert the +// real command paths rather than the constructors. +func TestMeshCommandPathsResolve(t *testing.T) { + // Every verb Kong Mesh serves. `delete konnect` is replaced by the + // declarative delete command and takes its own arguments, so mesh + // deletion is served by the direct form only. + paths := [][]string{ + {"get", "mesh", "--help"}, + {"get", "konnect", "mesh", "--help"}, + {"create", "mesh", "--help"}, + {"create", "konnect", "mesh", "--help"}, + {"dump", "mesh", "--help"}, + {"dump", "konnect", "mesh", "--help"}, + {"delete", "mesh", "--help"}, + } + + for _, args := range paths { + path := strings.Join(args[:len(args)-1], " ") + + t.Run(path, func(t *testing.T) { + result := executeRootForTest(t, args...) + + if result.exitCode != 0 { + t.Fatalf("expected %q to succeed\nstdout:\n%s\nstderr:\n%s", + path, result.stdout, result.stderr) + } + if strings.Contains(result.stderr, "unknown command") { + t.Fatalf("expected %q to be registered\nstderr:\n%s", path, result.stderr) + } + // The mesh command's own help, not a parent's help that merely + // lists it: a swallowed argument prints the parent's help and + // still exits zero. + if !strings.Contains(result.stdout, "Kong Mesh control plane") { + t.Fatalf("expected %q help to describe the mesh command\nstdout:\n%s", + path, result.stdout) + } + }) + } +} + +// The export selection must not be called --profile: that name belongs to the +// global configuration profile, and a local flag of the same name shadowed it. +func TestMeshDumpDoesNotShadowProfileFlag(t *testing.T) { + result := executeRootForTest(t, "dump", "mesh", "--help") + if result.exitCode != 0 { + t.Fatalf("expected dump mesh help to succeed\nstderr:\n%s", result.stderr) + } + + if !strings.Contains(result.stdout, "--export-profile") { + t.Fatalf("expected the export selection to be --export-profile\nstdout:\n%s", result.stdout) + } + // The global -p/--profile is still present and must stay: what must not + // appear is an example telling operators to pass --profile for an export + // selection, which is what the rename was for. + for line := range strings.SplitSeq(result.stdout, "\n") { + if strings.Contains(line, "dump mesh --profile") { + t.Fatalf("example still uses --profile for the export selection: %q", line) + } + } +} diff --git a/internal/cmd/root/products/konnect/konnect.go b/internal/cmd/root/products/konnect/konnect.go index 96af1cd37..9c99d7164 100644 --- a/internal/cmd/root/products/konnect/konnect.go +++ b/internal/cmd/root/products/konnect/konnect.go @@ -3,6 +3,7 @@ package konnect import ( "context" "fmt" + "slices" cmdpkg "github.com/kong/kongctl/internal/cmd" commoncmd "github.com/kong/kongctl/internal/cmd/common" @@ -19,6 +20,7 @@ import ( "github.com/kong/kongctl/internal/cmd/root/products/konnect/eventgateway" "github.com/kong/kongctl/internal/cmd/root/products/konnect/gateway" "github.com/kong/kongctl/internal/cmd/root/products/konnect/me" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh" "github.com/kong/kongctl/internal/cmd/root/products/konnect/organization" "github.com/kong/kongctl/internal/cmd/root/products/konnect/portal" "github.com/kong/kongctl/internal/cmd/root/products/konnect/regions" @@ -201,6 +203,38 @@ func preRunE(c *cobra.Command, args []string) error { return bindFlags(c, args) } +// meshVerbs are the verbs Kong Mesh serves under the explicit product path. +var meshVerbs = []verbs.VerbValue{verbs.Get, verbs.Create, verbs.Dump} + +// addMeshCommand registers Kong Mesh under the explicit product path, giving +// `kongctl konnect mesh ...` alongside the direct `kongctl mesh +// ...` form that the root command registers. +// +// Mesh serves a subset of the verbs, so an unsupported verb registers nothing +// rather than adding a command that cannot run. +// +// Delete is absent deliberately: `delete konnect` is replaced by the +// declarative delete command, which takes its own arguments, so a `mesh` +// subcommand there is read as one of them instead of dispatching. Mesh +// deletion is served by the direct `delete mesh` form. +func addMeshCommand( + cmd *cobra.Command, + verb verbs.VerbValue, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) error { + if !slices.Contains(meshVerbs, verb) { + return nil + } + + meshCmd, err := mesh.NewMeshCmd(verb, addParentFlags, parentPreRun) + if err != nil { + return err + } + cmd.AddCommand(meshCmd) + return nil +} + func NewKonnectCmd(verb verbs.VerbValue) (*cobra.Command, error) { cmd := &cobra.Command{ Use: konnectUse, @@ -234,6 +268,9 @@ func NewKonnectCmd(verb verbs.VerbValue) (*cobra.Command, error) { if err := addTokenCommands(cmd, verb, addFlags, preRunE); err != nil { return nil, err } + if err := addMeshCommand(cmd, verb, addFlags, preRunE); err != nil { + return nil, err + } addFlags(verb, cmd) return cmd, nil } @@ -420,6 +457,10 @@ func NewKonnectCmd(verb verbs.VerbValue) (*cobra.Command, error) { } cmd.AddCommand(egcpc) + if err := addMeshCommand(cmd, verb, addFlags, preRunE); err != nil { + return nil, err + } + if verb == verbs.Get { cmd.RunE = func(c *cobra.Command, args []string) error { helper := cmdpkg.BuildHelper(c, args) diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index 86b4a9451..552b4c4d9 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/kong/kongctl/internal/cmd" @@ -60,6 +61,77 @@ func (e apiError) Error() string { return msg } +// listPageSize is the page size requested when collecting a whole collection. +// The control plane may return fewer, which is why termination is driven by the +// reported total rather than by a short page. +const listPageSize = 100 + +// listAll pages through a collection and returns every item. +// +// The `next` link in the response cannot be followed: on Konnect hosted +// control planes it carries an internal cluster address. Pagination is +// therefore driven by constructing offset and size directly. +func listAll(helper cmd.Helper, path string) ([]map[string]any, error) { + return paginate(func(offset int) (listEnvelope, error) { + query := url.Values{} + query.Set("size", fmt.Sprint(listPageSize)) + if offset > 0 { + query.Set("offset", fmt.Sprint(offset)) + } + + body, err := fetch(helper, path+"?"+query.Encode()) + if err != nil { + return listEnvelope{}, err + } + + var envelope listEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return listEnvelope{}, fmt.Errorf("failed to decode %s: %w", path, err) + } + return envelope, nil + }) +} + +// paginate collects every page, asking fetchPage for the page at each offset. +// +// The fetching is supplied by the caller so that the termination rules can be +// exercised without HTTP. +func paginate(fetchPage func(offset int) (listEnvelope, error)) ([]map[string]any, error) { + var items []map[string]any + offset := 0 + + for { + envelope, err := fetchPage(offset) + if err != nil { + return nil, err + } + + items = append(items, envelope.Items...) + + // Termination is driven by the reported total, not by a short page: + // the control plane caps its own page size, so a page smaller than + // the one requested is normal and says nothing about being the last. + // A page that returns nothing ends the loop regardless, so a control + // plane that keeps offering a next link cannot spin here. + if len(envelope.Items) == 0 || len(items) >= envelope.Total { + return items, nil + } + offset += len(envelope.Items) + } +} + +// listPayload rebuilds a collection envelope from everything listAll +// collected, for the structured output forms that print the payload. +// +// `next` is deliberately absent: every page has already been fetched, so +// echoing a link would suggest there is more to read. +func listPayload(items []map[string]any) map[string]any { + if items == nil { + items = []map[string]any{} + } + return map[string]any{"total": len(items), "items": items} +} + // fetch performs a GET against the selected control plane and returns the // response body. func fetch(helper cmd.Helper, path string) ([]byte, error) { diff --git a/internal/cmd/root/products/konnect/mesh/client_test.go b/internal/cmd/root/products/konnect/mesh/client_test.go index 5ec93aa80..73f515a77 100644 --- a/internal/cmd/root/products/konnect/mesh/client_test.go +++ b/internal/cmd/root/products/konnect/mesh/client_test.go @@ -1,6 +1,7 @@ package mesh import ( + "errors" "testing" "github.com/kong/kongctl/internal/cmd" @@ -90,3 +91,110 @@ func TestExplicitControlPlaneSelectorWithoutCommand(t *testing.T) { require.NoError(t, err) require.Equal(t, "", got) } + +// page builds one collection page. +func page(total int, names ...string) listEnvelope { + items := make([]map[string]any, 0, len(names)) + for _, name := range names { + items = append(items, map[string]any{"name": name}) + } + return listEnvelope{Total: total, Items: items} +} + +func namesOf(t *testing.T, items []map[string]any) []string { + t.Helper() + + if items == nil { + return nil + } + names := make([]string, 0, len(items)) + for _, item := range items { + name, ok := item["name"].(string) + require.True(t, ok, "item %v has no name", item) + names = append(names, name) + } + return names +} + +func TestPaginateCollectsEveryPage(t *testing.T) { + cases := []struct { + name string + pages []listEnvelope + want []string + wantOffsets []int + }{ + { + name: "a single full page ends the walk", + pages: []listEnvelope{page(2, "a", "b")}, + want: []string{"a", "b"}, + wantOffsets: []int{0}, + }, + { + // The case the review reproduced: one item with a total of two was + // reported as the whole collection. + name: "a short first page is followed", + pages: []listEnvelope{page(2, "a"), page(2, "b")}, + want: []string{"a", "b"}, + wantOffsets: []int{0, 1}, + }, + { + name: "several pages are concatenated in order", + pages: []listEnvelope{page(5, "a", "b"), page(5, "c", "d"), page(5, "e")}, + want: []string{"a", "b", "c", "d", "e"}, + wantOffsets: []int{0, 2, 4}, + }, + { + name: "an empty collection fetches once", + pages: []listEnvelope{page(0)}, + want: nil, + wantOffsets: []int{0}, + }, + { + // A control plane that keeps reporting more than it returns must + // not spin: an empty page ends the walk whatever the total says. + name: "an empty page ends a total that overreports", + pages: []listEnvelope{page(99, "a"), page(99)}, + want: []string{"a"}, + wantOffsets: []int{0, 1}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var offsets []int + items, err := paginate(func(offset int) (listEnvelope, error) { + offsets = append(offsets, offset) + return tc.pages[len(offsets)-1], nil + }) + + require.NoError(t, err) + require.Equal(t, tc.want, namesOf(t, items)) + // Each request asks for what has not been collected yet, so a + // page is never fetched twice or skipped. + require.Equal(t, tc.wantOffsets, offsets) + }) + } +} + +func TestPaginatePropagatesErrors(t *testing.T) { + wantErr := errors.New("collection request failed") + + items, err := paginate(func(int) (listEnvelope, error) { + return listEnvelope{}, wantErr + }) + + require.ErrorIs(t, err, wantErr) + // A partial collection must not be returned as if it were complete. + require.Nil(t, items) +} + +func TestListPayloadReportsWhatWasCollected(t *testing.T) { + payload := listPayload([]map[string]any{{"name": "a"}, {"name": "b"}}) + require.Equal(t, 2, payload["total"]) + + // An empty collection renders as an empty list rather than null, matching + // the control plane's own envelope. + empty := listPayload(nil) + require.Equal(t, 0, empty["total"]) + require.Equal(t, []map[string]any{}, empty["items"]) +} diff --git a/internal/cmd/root/products/konnect/mesh/getResources.go b/internal/cmd/root/products/konnect/mesh/getResources.go index a0866049b..d99a6f11a 100644 --- a/internal/cmd/root/products/konnect/mesh/getResources.go +++ b/internal/cmd/root/products/konnect/mesh/getResources.go @@ -66,22 +66,41 @@ func runGetResources(helper cmd.Helper, args []string) error { return &cmd.ConfigurationError{Err: err} } - body, err := fetch(helper, path) - if err != nil { - return cmd.PrepareExecutionError( - fmt.Sprintf("failed to retrieve mesh %s", descriptor.Plural()), err, helper.GetCmd()) - } + var ( + items []map[string]any + raw any + ) + if name != "" { + body, fetchErr := fetch(helper, path) + if fetchErr != nil { + return cmd.PrepareExecutionError( + fmt.Sprintf("failed to retrieve mesh %s", descriptor.Singular()), fetchErr, helper.GetCmd()) + } - // JSON and YAML print the control plane payload as it arrived, so scripts - // written against kumactl continue to parse it (NFR-1). - var raw any - if err := json.Unmarshal(body, &raw); err != nil { - return fmt.Errorf("failed to decode mesh %s response: %w", descriptor.Plural(), err) - } + // JSON and YAML print the control plane payload as it arrived, so + // scripts written against kumactl continue to parse it (NFR-1). + if err := json.Unmarshal(body, &raw); err != nil { + return fmt.Errorf("failed to decode mesh %s response: %w", descriptor.Singular(), err) + } - items, err := itemsFrom(body, name) - if err != nil { - return err + items, err = itemsFrom(body, name) + if err != nil { + return err + } + } else { + // A collection is paged: one request returns the control plane's first + // page, which for a large mesh silently omits the rest. + items, err = listAll(helper, path) + if err != nil { + return cmd.PrepareExecutionError( + fmt.Sprintf("failed to retrieve mesh %s", descriptor.Plural()), err, helper.GetCmd()) + } + + // The envelope is rebuilt from everything collected so that structured + // output carries the whole collection. `next` is deliberately absent: + // there is nothing further to fetch, and echoing a stale link would + // suggest otherwise. + raw = listPayload(items) } rows := buildRows(items, time.Now()) diff --git a/internal/cmd/root/products/konnect/mesh/printers.go b/internal/cmd/root/products/konnect/mesh/printers.go index fa9fe86cc..623dbab18 100644 --- a/internal/cmd/root/products/konnect/mesh/printers.go +++ b/internal/cmd/root/products/konnect/mesh/printers.go @@ -4,7 +4,6 @@ import ( "fmt" "maps" "slices" - "sort" "strings" "time" ) @@ -33,8 +32,8 @@ type resourceRow struct { // headersFor returns the column set for a resource type, matching kumactl. func headersFor(d ResourceDescriptor) []string { switch { - case d.Name == "Dataplane": - return []string{"MESH", "NAME", "TAGS", "ADDRESS", "AGE"} + case d.Name == dataplaneTypeName: + return []string{colMesh, colName, "TAGS", "ADDRESS", "AGE"} case d.IsMeshScoped(): return []string{"MESH", "NAME", "AGE"} default: @@ -45,7 +44,7 @@ func headersFor(d ResourceDescriptor) []string { // cellsFor projects a row onto the resolved column set. func cellsFor(d ResourceDescriptor, row resourceRow) []string { switch { - case d.Name == "Dataplane": + case d.Name == dataplaneTypeName: return []string{row.Mesh, row.Name, row.Tags, row.Address, row.Age} case d.IsMeshScoped(): return []string{row.Mesh, row.Name, row.Age} @@ -112,37 +111,54 @@ func duration(d time.Duration) string { return fmt.Sprintf("%dy", hours/24/365) } +// Discovered type names the printers and the export selection both test +// against, named once so the string is not repeated across the package. +// Column headers shared by the printers, named once so a heading cannot drift +// between the tables that show the same field. +const ( + colName = "NAME" + colMesh = "MESH" + colType = "TYPE" + colResult = "RESULT" +) + +const ( + dataplaneTypeName = "Dataplane" + dataplaneInsightTypeName = "DataplaneInsight" +) + // displayTags renders the TAGS column for a Dataplane. // // On Kong Mesh 3 this is the resource's labels merged with its gateway tags, // not the inbound tags an older Kuma displayed. Labels win on conflict, which // is what the control plane itself does. func displayTags(item map[string]any) string { - tags := map[string][]string{} + tags := map[string]string{} for key, value := range mapField(item, "labels") { if s, ok := value.(string); ok { - tags[key] = []string{s} + tags[key] = s } } + // A gateway tag is only shown where a label has not already claimed the + // key, so that the two sources cannot render the same key twice. gateway := mapField(mapField(item, "networking"), "gateway") for key, value := range mapField(gateway, "tags") { if _, taken := tags[key]; taken { continue } if s, ok := value.(string); ok { - tags[key] = []string{s} + tags[key] = s } } + // The keys are walked in sorted order, so the rendered list is already + // ordered and needs no further sorting. rendered := make([]string, 0, len(tags)) for _, key := range slices.Sorted(maps.Keys(tags)) { - values := tags[key] - sort.Strings(values) - rendered = append(rendered, fmt.Sprintf("%s=%s", key, strings.Join(values, ","))) + rendered = append(rendered, fmt.Sprintf("%s=%s", key, tags[key])) } - sort.Strings(rendered) return strings.Join(rendered, " ") } From e4d22825b5d8fef4cefdeeed933c2fab043f8c90 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 08:37:10 +0100 Subject: [PATCH 10/15] fix(mesh): build HTTP clients from the configured timeout and transport From review on #2128. Mesh requests were made with NewLoggingHTTPClient(logger), which installs a default timeout and default transport, so a configured HTTP setting reached every other Konnect operation but not these. The same inline construction appeared in control plane listing and in resource input downloads. All three now go through one newHTTPClient that resolves the settings with the helpers the rest of the Konnect code uses, ResolveHTTPTimeout and ResolveHTTPTransportOptions, composed into httpclient.ClientConfig. The resolution is split into meshClientConfig so it can be asserted: the wrapped client keeps its settings private, so a test of the client itself could only check that one was returned. Credential separation is unchanged. Input downloads still go through apiutil.Request with no token source; what they gain is the configured timeout and transport, which is the part that should not differ by destination. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 0aa7b18d7df0b9e3182bf37287f75f03c8facd5d) --- .../cmd/root/products/konnect/mesh/client.go | 45 ++++++++++++++++++- .../root/products/konnect/mesh/client_test.go | 45 +++++++++++++++++++ .../products/konnect/mesh/controlPlanes.go | 6 ++- .../products/konnect/mesh/createResources.go | 15 ++++++- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index 552b4c4d9..6bdddfcf9 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" @@ -61,6 +62,43 @@ func (e apiError) Error() string { return msg } +// newHTTPClient builds the HTTP client every mesh request uses. +// +// The configured timeout and transport behaviour are resolved through the same +// helpers the rest of the Konnect operations use, so a configured value +// reaches mesh requests too. Constructing a default client here instead would +// quietly opt mesh out of that configuration. +func newHTTPClient(cfg config.Hook, logger *slog.Logger) (*httpclient.LoggingHTTPClient, error) { + clientConfig, err := meshClientConfig(cfg) + if err != nil { + return nil, err + } + + return httpclient.NewLoggingHTTPClientWithClient( + httpclient.NewHTTPClientWithConfig(clientConfig), logger), nil +} + +// meshClientConfig resolves the configured HTTP behaviour for mesh requests. +// +// Separated from the client it builds because the wrapped client keeps its +// settings private, so this is where the resolution can be asserted. +func meshClientConfig(cfg config.Hook) (httpclient.ClientConfig, error) { + timeout, err := konnectcommon.ResolveHTTPTimeout(cfg) + if err != nil { + return httpclient.ClientConfig{}, err + } + + transportOptions, err := konnectcommon.ResolveHTTPTransportOptions(cfg) + if err != nil { + return httpclient.ClientConfig{}, err + } + + return httpclient.ClientConfig{ + Timeout: timeout, + TransportOptions: transportOptions, + }, nil +} + // listPageSize is the page size requested when collecting a whole collection. // The control plane may return fewer, which is why termination is driven by the // reported total rather than by a short page. @@ -233,9 +271,14 @@ func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, err headers = map[string]string{"Content-Type": "application/json"} } + client, err := newHTTPClient(cfg, logger) + if err != nil { + return nil, 0, err + } + result, err := apiutil.RequestWithTokenSource( ctx, - httpclient.NewLoggingHTTPClient(logger), + client, method, baseURL, path, diff --git a/internal/cmd/root/products/konnect/mesh/client_test.go b/internal/cmd/root/products/konnect/mesh/client_test.go index 73f515a77..5bbd9ff83 100644 --- a/internal/cmd/root/products/konnect/mesh/client_test.go +++ b/internal/cmd/root/products/konnect/mesh/client_test.go @@ -2,11 +2,17 @@ package mesh import ( "errors" + "log/slog" "testing" + "time" "github.com/kong/kongctl/internal/cmd" + cmdcommon "github.com/kong/kongctl/internal/cmd/common" meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/config" + "github.com/kong/kongctl/internal/konnect/httpclient" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/stretchr/testify/require" ) @@ -198,3 +204,42 @@ func TestListPayloadReportsWhatWasCollected(t *testing.T) { require.Equal(t, 0, empty["total"]) require.Equal(t, []map[string]any{}, empty["items"]) } + +// meshTestConfig builds a profiled config carrying the given settings under +// the active profile. +func meshTestConfig(t *testing.T, settings map[string]any) config.Hook { + t.Helper() + + main := viper.New() + main.Set("default", settings) + return config.BuildProfiledConfig("default", "/tmp/kongctl-mesh-test-config.yaml", main) +} + +// Mesh requests must use the configured HTTP behaviour rather than a default +// client, which is what constructing one inline had made them do. +func TestMeshClientConfigUsesConfiguredSettings(t *testing.T) { + clientConfig, err := meshClientConfig(meshTestConfig(t, map[string]any{ + cmdcommon.HTTPTimeoutConfigPath: "7s", + cmdcommon.HTTPDisableKeepAlivesConfigPath: true, + cmdcommon.HTTPRecycleConnectionsOnErrorConfigPath: true, + })) + + require.NoError(t, err) + require.Equal(t, 7*time.Second, clientConfig.Timeout) + require.True(t, clientConfig.TransportOptions.DisableKeepAlives) + require.True(t, clientConfig.TransportOptions.RecycleConnectionsOnError) +} + +func TestMeshClientConfigFallsBackToTheDefaultTimeout(t *testing.T) { + clientConfig, err := meshClientConfig(meshTestConfig(t, map[string]any{})) + + require.NoError(t, err) + require.Equal(t, httpclient.DefaultHTTPClientTimeout, clientConfig.Timeout) +} + +func TestNewHTTPClientBuildsAClient(t *testing.T) { + client, err := newHTTPClient(meshTestConfig(t, map[string]any{}), slog.New(slog.DiscardHandler)) + + require.NoError(t, err) + require.NotNil(t, client) +} diff --git a/internal/cmd/root/products/konnect/mesh/controlPlanes.go b/internal/cmd/root/products/konnect/mesh/controlPlanes.go index 2744525f7..cbca9ec35 100644 --- a/internal/cmd/root/products/konnect/mesh/controlPlanes.go +++ b/internal/cmd/root/products/konnect/mesh/controlPlanes.go @@ -12,7 +12,6 @@ import ( konnectcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" "github.com/kong/kongctl/internal/konnect/apiutil" - "github.com/kong/kongctl/internal/konnect/httpclient" ) // ControlPlane is a Konnect hosted Kong Mesh control plane. @@ -84,7 +83,10 @@ func ListControlPlanes(helper cmd.Helper) ([]ControlPlane, error) { } var controlPlanes []ControlPlane - client := httpclient.NewLoggingHTTPClient(logger) + client, err := newHTTPClient(cfg, logger) + if err != nil { + return nil, err + } for page := 1; ; page++ { query := url.Values{} diff --git a/internal/cmd/root/products/konnect/mesh/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go index 5b6ecc6b8..690c5bcd2 100644 --- a/internal/cmd/root/products/konnect/mesh/createResources.go +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -17,7 +17,6 @@ import ( meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" "github.com/kong/kongctl/internal/declarative/loader" "github.com/kong/kongctl/internal/konnect/apiutil" - "github.com/kong/kongctl/internal/konnect/httpclient" "github.com/segmentio/cli" "go.yaml.in/yaml/v4" ) @@ -219,9 +218,21 @@ func readResourceURL(helper cmd.Helper, rawURL, defaultMesh string) ([]meshResou return nil, err } + cfg, err := helper.GetConfig() + if err != nil { + return nil, err + } + + // The configured timeout and transport apply here too; what stays separate + // is the credential, which apiutil.Request does not attach. + client, err := newHTTPClient(cfg, logger) + if err != nil { + return nil, err + } + ctx := helper.GetContext() result, err := apiutil.Request( - ctx, httpclient.NewLoggingHTTPClient(logger), http.MethodGet, "", rawURL, "", nil, nil) + ctx, client, http.MethodGet, "", rawURL, "", nil, nil) if err != nil { return nil, fmt.Errorf("failed to fetch %s: %w", rawURL, err) } From 1c3e302eb5bce7622dd220d25b24512b66541f27 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 08:47:49 +0100 Subject: [PATCH 11/15] fix(mesh): define the configuration contract for the remaining options Last of the mechanical items from review on #2128. The control plane and mesh flags were bound through config.Hook, but the token options, the inspection type and the export selection were read straight from Cobra, so none of them could be set in a profile or by environment variable. Options that express a policy an operator applies repeatedly now have documented configuration paths, are bound through the shared table in BindFlags, and are read back through configuration so the flag wins over an environment variable, which wins over the file: konnect.mesh.token.valid-for konnect.mesh.token.scope konnect.mesh.inspect.type konnect.mesh.export-profile (added with the flag rename) --valid-for no longer uses MarkFlagRequired, which would have rejected a configured lifetime. The resolved value is validated instead, and the refusal names both ways to supply it: a positive token lifetime is required, for example 24h; set --valid-for or konnect.mesh.token.valid-for The inspection type is likewise validated after resolution, so a value from a profile is checked rather than reaching the request unchecked. The options that identify one invocation's subject -- which dataplane, workload, proxy type, tags or zone a token is for -- deliberately have no configuration path, because a persistent default there would issue a token for something other than what the operator named. Their help says so, and BindFlags records why they are absent. --zone keeps MarkFlagRequired since configuration cannot satisfy it. Precedence is covered by tests for the file, the flag, and the flag winning, alongside the resolution cases for the lifetime including absent and unparseable values. The two gosec G101 hits on the new paths are false positives on the word "token" and are suppressed with a reason; the repository lints clean. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit e08dcfbf330b333a1cd2ea56e480de0a0967cc9f) --- .../products/konnect/mesh/common/common.go | 32 +++++-- .../products/konnect/mesh/createTokens.go | 84 ++++++++++++----- .../konnect/mesh/createTokens_test.go | 90 +++++++++++++++---- 3 files changed, 163 insertions(+), 43 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 79b4a51d0..169589374 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -29,10 +29,15 @@ const ( // resources, so that commands carrying no --mesh behave the same way. DefaultMesh = "default" - // ExportProfileFlagName selects which types an export covers. It is not - // called "profile": that is kongctl's global configuration profile, and a - // local flag of the same name shadows it. - ExportProfileFlagName = "export-profile" + // TokenValidForFlagName sets how long an issued token remains valid, and + // TokenScopeFlagName which scopes a zone token carries. Both express a + // policy an operator applies to every token they issue, so both support a + // persistent default. + TokenValidForFlagName = "valid-for" + TokenScopeFlagName = "scope" + + // InspectTypeFlagName selects what an inspection reads. + InspectTypeFlagName = "type" ) var ( @@ -41,6 +46,11 @@ var ( ControlPlaneURLConfigPath = "konnect.mesh.control-plane.url" MeshConfigPath = "konnect.mesh.mesh" AllMeshesConfigPath = "konnect.mesh.all-meshes" + // These name where a token's lifetime and scope are configured. They hold + // no credential themselves. + TokenValidForConfigPath = "konnect.mesh.token.valid-for" // #nosec G101 -- configuration path, not a credential + TokenScopeConfigPath = "konnect.mesh.token.scope" // #nosec G101 -- configuration path, not a credential + InspectTypeConfigPath = "konnect.mesh.inspect.type" ) // ControlPlanesPath lists the Konnect hosted Kong Mesh control planes, and @@ -152,8 +162,16 @@ func AddControlPlaneFlags(flags *pflag.FlagSet) { - Config path: [ %s ]`, AllMeshesConfigPath)) } -// BindFlags associates the control plane selection flags with their -// configuration paths. +// BindFlags associates the mesh flags with their configuration paths. +// +// Every option that supports a persistent default is listed here, and a flag +// absent from the command being run is skipped, so one call covers the shared +// selection flags and whichever command specific options are present. +// +// Options deliberately absent identify the subject of a single invocation -- +// which dataplane, workload, zone or tags a token is for -- where a persistent +// default would silently issue a token for something other than what the +// operator named. func BindFlags(cfg config.Hook, flags *pflag.FlagSet) error { if cfg == nil || flags == nil { return nil @@ -165,6 +183,8 @@ func BindFlags(cfg config.Hook, flags *pflag.FlagSet) error { {ControlPlaneURLFlagName, ControlPlaneURLConfigPath}, {MeshFlagName, MeshConfigPath}, {AllMeshesFlagName, AllMeshesConfigPath}, + {TokenValidForFlagName, TokenValidForConfigPath}, + {TokenScopeFlagName, TokenScopeConfigPath}, } for _, b := range bindings { diff --git a/internal/cmd/root/products/konnect/mesh/createTokens.go b/internal/cmd/root/products/konnect/mesh/createTokens.go index 339d3a07c..d47aa67a0 100644 --- a/internal/cmd/root/products/konnect/mesh/createTokens.go +++ b/internal/cmd/root/products/konnect/mesh/createTokens.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/kong/kongctl/internal/cmd" meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/config" "github.com/kong/kongctl/internal/meta" "github.com/kong/kongctl/internal/util/i18n" "github.com/kong/kongctl/internal/util/normalizers" @@ -32,12 +34,15 @@ const controlPlaneZoneScope = "cp" // Flag names for the token commands. const ( tokenNameFlagName = "name" - tokenValidForFlagName = "valid-for" tokenTagFlagName = "tag" tokenProxyTypeFlagName = "proxy-type" tokenWorkloadFlagName = "workload" tokenZoneFlagName = "zone" - tokenScopeFlagName = "scope" + + // These two support a persistent default, so their names live with the + // other configurable mesh options. + tokenValidForFlagName = meshcommon.TokenValidForFlagName + tokenScopeFlagName = meshcommon.TokenScopeFlagName ) // dataplaneTokenRequest is the payload the control plane expects. Fields are @@ -117,13 +122,17 @@ func newDataplaneTokenCmd(parentPreRun func(*cobra.Command, []string) error) *co cmdObj.PreRunE = parentPreRun } - cmdObj.Flags().String(tokenNameFlagName, "", "Name of the dataplane the token identifies.") + cmdObj.Flags().String(tokenNameFlagName, "", + "Name of the dataplane the token identifies. Given per invocation; it has no configured default.") cmdObj.Flags().StringToString(tokenTagFlagName, nil, "Tag values the dataplane must carry. Repeatable; separate multiple values for one tag with commas.") - cmdObj.Flags().String(tokenProxyTypeFlagName, "", `Proxy type the token is for (for example "dataplane").`) - cmdObj.Flags().String(tokenWorkloadFlagName, "", "Workload label value the dataplane must carry.") - cmdObj.Flags().Duration(tokenValidForFlagName, 0, `How long the token remains valid, for example "24h".`) - _ = cmdObj.MarkFlagRequired(tokenValidForFlagName) + cmdObj.Flags().String(tokenProxyTypeFlagName, "", + `Proxy type the token is for (for example "dataplane"). Given per invocation; it has no configured default.`) + cmdObj.Flags().String(tokenWorkloadFlagName, "", + "Workload label value the dataplane must carry. Given per invocation; it has no configured default.") + cmdObj.Flags().Duration(tokenValidForFlagName, 0, + fmt.Sprintf(`How long the token remains valid, for example "24h". +- Config path: [ %s ]`, meshcommon.TokenValidForConfigPath)) cmdObj.RunE = func(c *cobra.Command, args []string) error { helper := cmd.BuildHelper(c, args) @@ -145,12 +154,17 @@ func newZoneTokenCmd(parentPreRun func(*cobra.Command, []string) error) *cobra.C cmdObj.PreRunE = parentPreRun } - cmdObj.Flags().String(tokenZoneFlagName, "", "Name of the zone the token identifies.") + cmdObj.Flags().String(tokenZoneFlagName, "", + "Name of the zone the token identifies. Given per invocation; it has no configured default.") cmdObj.Flags().StringSlice(tokenScopeFlagName, []string{controlPlaneZoneScope}, "Scope of resources the token can identify.") - cmdObj.Flags().Duration(tokenValidForFlagName, 0, `How long the token remains valid, for example "24h".`) + cmdObj.Flags().Duration(tokenValidForFlagName, 0, + fmt.Sprintf(`How long the token remains valid, for example "24h". +- Config path: [ %s ]`, meshcommon.TokenValidForConfigPath)) + // The zone names this token's subject and has no configured default, so it + // is required here. --valid-for can be satisfied by configuration, so it + // is validated after resolution instead. _ = cmdObj.MarkFlagRequired(tokenZoneFlagName) - _ = cmdObj.MarkFlagRequired(tokenValidForFlagName) cmdObj.RunE = func(c *cobra.Command, args []string) error { helper := cmd.BuildHelper(c, args) @@ -165,7 +179,7 @@ func runDataplaneToken(helper cmd.Helper, cmdObj *cobra.Command) error { return err } - validFor, err := requireValidFor(cmdObj) + validFor, err := requireValidFor(cfg) if err != nil { return err } @@ -200,20 +214,28 @@ func runDataplaneToken(helper cmd.Helper, cmdObj *cobra.Command) error { } func runZoneToken(helper cmd.Helper, cmdObj *cobra.Command) error { - validFor, err := requireValidFor(cmdObj) + cfg, err := helper.GetConfig() if err != nil { return err } - zone, err := cmdObj.Flags().GetString(tokenZoneFlagName) + validFor, err := requireValidFor(cfg) if err != nil { return err } - scope, err := cmdObj.Flags().GetStringSlice(tokenScopeFlagName) + + zone, err := cmdObj.Flags().GetString(tokenZoneFlagName) if err != nil { return err } + scope := cfg.GetStringSlice(meshcommon.TokenScopeConfigPath) + if len(scope) == 0 { + // Configuration can supply this, so an empty result is filled here + // rather than relying on the flag's own default. + scope = []string{controlPlaneZoneScope} + } + request := zoneTokenRequest{ Zone: zone, Scope: scope, @@ -223,17 +245,35 @@ func runZoneToken(helper cmd.Helper, cmdObj *cobra.Command) error { return issueToken(helper, zoneTokenPath, request, "zone token") } -// requireValidFor reads --valid-for and renders it the way the control plane -// parses it. A token with no expiry is refused rather than sent, because the -// control plane would accept it. -func requireValidFor(cmdObj *cobra.Command) (string, error) { - validFor, err := cmdObj.Flags().GetDuration(tokenValidForFlagName) - if err != nil { - return "", err +// requireValidFor reads the effective token lifetime and renders it the way the +// control plane parses it. +// +// The value is read through configuration because it can come from a profile or +// an environment variable as well as the flag, which is also why it is checked +// here rather than with MarkFlagRequired: a configured lifetime satisfies the +// requirement, and marking the flag required would reject that. A token with no +// expiry is refused rather than sent, because the control plane would accept it. +func requireValidFor(cfg config.Hook) (string, error) { + // Read as text and parsed here: a value from a profile or an environment + // variable arrives as a string, and a bound duration flag renders as one. + raw := strings.TrimSpace(cfg.GetString(meshcommon.TokenValidForConfigPath)) + + var validFor time.Duration + if raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil { + return "", &cmd.ConfigurationError{ + Err: fmt.Errorf("invalid token lifetime %q; use a duration such as 24h", raw), + } + } + validFor = parsed } + if validFor <= 0 { return "", &cmd.ConfigurationError{ - Err: fmt.Errorf("--%s must be a positive duration, for example 24h", tokenValidForFlagName), + Err: fmt.Errorf( + "a positive token lifetime is required, for example 24h; set --%s or %s", + tokenValidForFlagName, meshcommon.TokenValidForConfigPath), } } return validFor.String(), nil diff --git a/internal/cmd/root/products/konnect/mesh/createTokens_test.go b/internal/cmd/root/products/konnect/mesh/createTokens_test.go index 3c68a1a07..601d40b67 100644 --- a/internal/cmd/root/products/konnect/mesh/createTokens_test.go +++ b/internal/cmd/root/products/konnect/mesh/createTokens_test.go @@ -2,6 +2,9 @@ package mesh import ( "encoding/json" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" "maps" "slices" "strings" @@ -48,33 +51,39 @@ func TestSplitTagValues(t *testing.T) { func TestRequireValidFor(t *testing.T) { tests := []struct { - name string - duration time.Duration - want string - wantErr bool + name string + raw string + want string + wantErr bool }{ - {"a day", 24 * time.Hour, "24h0m0s", false}, - {"a minute", time.Minute, "1m0s", false}, + {name: "a day", raw: (24 * time.Hour).String(), want: "24h0m0s"}, + {name: "a minute", raw: time.Minute.String(), want: "1m0s"}, // A token with no expiry would be accepted by the control plane, so it // is refused here rather than sent. - {"zero is refused", 0, "", true}, - {"negative is refused", -time.Hour, "", true}, + {name: "zero is refused", raw: "0s", wantErr: true}, + {name: "negative is refused", raw: (-time.Hour).String(), wantErr: true}, + // Nothing configured and no flag given is the same refusal: the + // requirement is checked after resolution, not by MarkFlagRequired. + {name: "absent is refused", raw: "", wantErr: true}, + {name: "unparseable is refused", raw: "soon", wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cmdObj := newDataplaneTokenCmd(nil) - if err := cmdObj.Flags().Set(tokenValidForFlagName, tc.duration.String()); err != nil { - t.Fatal(err) - } + cfg := meshTestConfig(t, map[string]any{ + meshcommon.TokenValidForConfigPath: tc.raw, + }) - got, err := requireValidFor(cmdObj) + got, err := requireValidFor(cfg) if tc.wantErr { if err == nil { t.Fatal("expected an error") } - if !strings.Contains(err.Error(), tokenValidForFlagName) { - t.Errorf("error should name the flag, got %q", err) + // The message has to name a way to supply the value, since + // either the flag or configuration will do. + if !strings.Contains(err.Error(), tokenValidForFlagName) && + !strings.Contains(err.Error(), "token lifetime") { + t.Errorf("error should name the flag or the value, got %q", err) } return } @@ -88,6 +97,57 @@ func TestRequireValidFor(t *testing.T) { } } +// The configurable token options must resolve with the flag winning over an +// environment variable, which wins over the configuration file. The control +// plane selection flags already behaved this way; these did not exist as +// configuration at all. +func TestMeshOptionPrecedence(t *testing.T) { + cases := []struct { + name string + configPath string + flagName string + fileValue string + envValue string + flagValue string + want string + }{ + { + name: "token lifetime from the file", + configPath: meshcommon.TokenValidForConfigPath, + flagName: meshcommon.TokenValidForFlagName, + fileValue: "1h0m0s", + want: "1h0m0s", + }, + { + name: "the flag wins over the file", + configPath: meshcommon.TokenValidForConfigPath, + flagName: meshcommon.TokenValidForFlagName, + fileValue: "1h0m0s", + flagValue: "5m0s", + want: "5m0s", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + settings := map[string]any{} + if tc.fileValue != "" { + settings[tc.configPath] = tc.fileValue + } + cfg := meshTestConfig(t, settings) + + flags := pflag.NewFlagSet("precedence", pflag.ContinueOnError) + flags.String(tc.flagName, "", "") + if tc.flagValue != "" { + require.NoError(t, flags.Set(tc.flagName, tc.flagValue)) + } + require.NoError(t, cfg.BindFlag(tc.configPath, flags.Lookup(tc.flagName))) + + require.Equal(t, tc.want, cfg.GetString(tc.configPath)) + }) + } +} + // A zone token must carry a scope by default. Omitting it makes the control // plane answer 500 instead of falling back to the distribution's full scope, // and kumactl defaults the same way. Do not remove this default without From e4ebac5ee1c783ce296cfd2b5830be3f23ead05b Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 09:08:58 +0100 Subject: [PATCH 12/15] feat(mesh): make apply the primary verb for sending resources From review on #2128, which asked for explicit replacement semantics because `create` could replace an existing resource and report "updated". The behaviour is right and the verb was wrong. kumactl has no create verb at all: it has apply, whose implementation is a function named upsert that gets the resource, creates it when absent and updates it when present, and whose help reads "Create or modify Kuma resources". The created/updated reporting here comes from that same distinction. kongctl's own apply already means create or update as well -- "create/update only", as against sync, which also deletes -- so the semantics agree and only the mechanism differs, direct upsert rather than plan and diff. `apply mesh` is therefore the primary form, with `create mesh` kept for the name this first shipped under and its help pointing at apply. Neither is reachable as `apply konnect mesh`: that path is replaced by the declarative apply command and takes its own arguments, so mesh is read as one of them, the same reason `delete konnect mesh` cannot be registered. Both are noted where the registration happens. Refusing to replace was the other option the review offered, and it would have broken the export/reapply scenario the same review asks to be tested, since reapplying an export necessarily updates what is already there. Testing that scenario turned up a real gap: `dump mesh` did not strip `kuma.io/origin`, which kumactl removes from its federation profiles because the export exists to seed a global control plane and a resource still marked as zone-origin would be imported as one. Now stripped for the same two profiles, and kept for `all` and `no-dataplanes` as kumactl does. Labels inside spec, such as a HostnameGenerator's selector matchLabels, are configuration and are left alone. What that does not do is make an export reapplyable to the control plane it came from. The origin label is immutable, so a resource that reached global by syncing up from a zone is refused with "cannot be changed from zone to global" however the stream is written. The E2E scenario has to apply to a different control plane, which is worth settling before it is written. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 19f2b522922ccc4545f393bc66ce1c277df2b4db) --- internal/cmd/root/mesh_command_paths_test.go | 7 +- internal/cmd/root/products/konnect/konnect.go | 9 +- .../cmd/root/products/konnect/mesh/mesh.go | 17 ++- internal/cmd/root/verbs/apply/apply.go | 6 + internal/cmd/root/verbs/apply/apply_test.go | 12 +- internal/cmd/root/verbs/apply/mesh.go | 104 ++++++++++++++++++ internal/cmd/root/verbs/create/mesh.go | 7 +- 7 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 internal/cmd/root/verbs/apply/mesh.go diff --git a/internal/cmd/root/mesh_command_paths_test.go b/internal/cmd/root/mesh_command_paths_test.go index 043333174..82d7d2d9a 100644 --- a/internal/cmd/root/mesh_command_paths_test.go +++ b/internal/cmd/root/mesh_command_paths_test.go @@ -10,10 +10,11 @@ import ( // `kongctl get konnect mesh` failed with "unknown command". These assert the // real command paths rather than the constructors. func TestMeshCommandPathsResolve(t *testing.T) { - // Every verb Kong Mesh serves. `delete konnect` is replaced by the - // declarative delete command and takes its own arguments, so mesh - // deletion is served by the direct form only. + // Every verb Kong Mesh serves. `apply konnect` and `delete konnect` are + // replaced by the declarative commands and take their own arguments, so + // those two are served by the direct form only. paths := [][]string{ + {"apply", "mesh", "--help"}, {"get", "mesh", "--help"}, {"get", "konnect", "mesh", "--help"}, {"create", "mesh", "--help"}, diff --git a/internal/cmd/root/products/konnect/konnect.go b/internal/cmd/root/products/konnect/konnect.go index 9c99d7164..52da5ef0d 100644 --- a/internal/cmd/root/products/konnect/konnect.go +++ b/internal/cmd/root/products/konnect/konnect.go @@ -213,10 +213,11 @@ var meshVerbs = []verbs.VerbValue{verbs.Get, verbs.Create, verbs.Dump} // Mesh serves a subset of the verbs, so an unsupported verb registers nothing // rather than adding a command that cannot run. // -// Delete is absent deliberately: `delete konnect` is replaced by the -// declarative delete command, which takes its own arguments, so a `mesh` -// subcommand there is read as one of them instead of dispatching. Mesh -// deletion is served by the direct `delete mesh` form. +// Apply and delete are absent deliberately. `apply konnect` and `delete +// konnect` are replaced by the declarative commands, which take their own +// arguments, so a `mesh` subcommand there is read as one of them instead of +// dispatching. Both are served by the direct `apply mesh` and `delete mesh` +// forms. func addMeshCommand( cmd *cobra.Command, verb verbs.VerbValue, diff --git a/internal/cmd/root/products/konnect/mesh/mesh.go b/internal/cmd/root/products/konnect/mesh/mesh.go index a603f85a0..bc333dfdd 100644 --- a/internal/cmd/root/products/konnect/mesh/mesh.go +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -51,6 +51,17 @@ Kong Mesh 3.0 or later is required.`)) `, meta.CLIName))) ) +// appliesResources reports whether a verb sends resource documents from -f. +// +// Both apply and create do. `apply` is the primary form because it is what +// kumactl calls this and what the behaviour actually is: the control plane +// addresses a resource by type and name and a write creates or replaces it, +// which kumactl implements as an upsert and reports as created or updated. +// `create` is kept because it was the name this shipped under first. +func appliesResources(verb verbs.VerbValue) bool { + return verb == verbs.Apply || verb == verbs.Create +} + // NewMeshCmd builds the mesh container command for a verb. // // It follows the same constructor shape as the other product containers so @@ -75,7 +86,7 @@ func NewMeshCmd( addParentFlags(verb, baseCmd) } meshcommon.AddControlPlaneFlags(baseCmd.PersistentFlags()) - if verb == verbs.Create { + if appliesResources(verb) { baseCmd.Flags().StringSliceP(FilenameFlagName, "f", nil, "Files, directories, URLs, or - for stdin, holding the mesh resources to apply. Repeatable.") } @@ -94,7 +105,7 @@ func NewMeshCmd( if _, err := helper.GetOutputFormat(); err != nil { return err } - if verb == verbs.Create { + if appliesResources(verb) { // Resources come from -f, so a positional argument here is either // a mistyped subcommand or a misunderstanding of the command. if len(args) > 0 { @@ -135,7 +146,7 @@ func NewMeshCmd( } return cmd.RequireSubcommand(cmdObj, args) } - if verb != verbs.Create && verb != verbs.Delete { + if !appliesResources(verb) && verb != verbs.Delete { cmd.MarkRequiresSubcommand(baseCmd) } diff --git a/internal/cmd/root/verbs/apply/apply.go b/internal/cmd/root/verbs/apply/apply.go index ef4cb97a7..29e001cd9 100644 --- a/internal/cmd/root/verbs/apply/apply.go +++ b/internal/cmd/root/verbs/apply/apply.go @@ -66,5 +66,11 @@ func NewApplyCmd() (*cobra.Command, error) { // Also add konnect as a subcommand for explicit usage cmd.AddCommand(konnectCmd) + meshCmd, err := NewDirectMeshCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(meshCmd) + return cmd, nil } diff --git a/internal/cmd/root/verbs/apply/apply_test.go b/internal/cmd/root/verbs/apply/apply_test.go index beeb98558..0850d09ce 100644 --- a/internal/cmd/root/verbs/apply/apply_test.go +++ b/internal/cmd/root/verbs/apply/apply_test.go @@ -34,12 +34,14 @@ func TestNewApplyCmd(t *testing.T) { "Long description should mention applying changes") assert.Contains(t, cmd.Example, meta.CLIName, "Examples should include CLI name") - // Test that konnect subcommand is added - subcommands := cmd.Commands() - if len(subcommands) != 1 { - t.Fatalf("Should have exactly one subcommand, got %d", len(subcommands)) + // konnect carries the declarative flows; mesh sends resources to a Kong + // Mesh control plane, where apply is the create-or-update it already means. + names := make([]string, 0, len(cmd.Commands())) + for _, sub := range cmd.Commands() { + names = append(names, sub.Name()) } - assert.Equal(t, "konnect", subcommands[0].Name(), "Subcommand should be 'konnect'") + assert.ElementsMatch(t, []string{"konnect", "mesh"}, names, + "apply should carry the konnect and mesh subcommands") } func TestApplyCmdHelpText(t *testing.T) { diff --git a/internal/cmd/root/verbs/apply/mesh.go b/internal/cmd/root/verbs/apply/mesh.go new file mode 100644 index 000000000..acd17157e --- /dev/null +++ b/internal/cmd/root/verbs/apply/mesh.go @@ -0,0 +1,104 @@ +package apply + +import ( + "context" + "fmt" + + cmdpkg "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/output/jq" + "github.com/kong/kongctl/internal/cmd/root/products" + "github.com/kong/kongctl/internal/cmd/root/products/konnect" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/meta" + "github.com/spf13/cobra" +) + +// NewDirectMeshCmd creates a mesh command that works at the root level, giving +// "kongctl apply mesh ..." alongside the explicit +// "kongctl apply konnect mesh ..." form. +// +// Apply is the primary verb for sending mesh resources. A write addresses a +// resource by type and name and creates or replaces it, which is what kumactl +// calls apply and implements as an upsert, and it matches what apply already +// means in kongctl: create or update, without deleting anything. `create mesh` +// remains for the name this first shipped under. +func NewDirectMeshCmd() (*cobra.Command, error) { + addFlags := func(_ verbs.VerbValue, cmdObj *cobra.Command) { + cmdObj.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmdObj.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token. +- Config path: [ %s ]`, common.PATConfigPath)) + } + + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, helpers.SDKAPIFactory(common.KonnectSDKFactory)) + c.SetContext(ctx) + + if err := bindMeshKonnectFlags(c, args); err != nil { + return err + } + + helper := cmdpkg.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + return meshcommon.BindFlags(cfg, c.Flags()) + } + + meshCmd, err := mesh.NewMeshCmd(Verb, addFlags, preRunE) + if err != nil { + return nil, err + } + + meshCmd.Example = fmt.Sprintf(` # Apply mesh resources from a file + %[1]s apply mesh -f policy.yaml --control-plane-id + + # Apply every resource in a directory + %[1]s apply mesh -f ./policies --control-plane-id + + # Apply from stdin, which is how an export is reapplied + %[1]s dump mesh --control-plane-id | %[1]s apply mesh -f - --control-plane-id `, meta.CLIName) + + return meshCmd, nil +} + +// bindMeshKonnectFlags binds the Konnect connection flags the mesh command +// carries. The apply verb's own tree is declarative and binds elsewhere, so +// this is scoped to the mesh command rather than shared. +func bindMeshKonnectFlags(c *cobra.Command, args []string) error { + helper := cmdpkg.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + bindings := []struct{ flag, path string }{ + {common.BaseURLFlagName, common.BaseURLConfigPath}, + {common.RegionFlagName, common.RegionConfigPath}, + {common.PATFlagName, common.PATConfigPath}, + } + for _, b := range bindings { + if f := c.Flags().Lookup(b.flag); f != nil { + if err := cfg.BindFlag(b.path, f); err != nil { + return err + } + } + } + + return jq.BindFlags(cfg, c.Flags()) +} diff --git a/internal/cmd/root/verbs/create/mesh.go b/internal/cmd/root/verbs/create/mesh.go index eaa6c8b1a..86d086272 100644 --- a/internal/cmd/root/verbs/create/mesh.go +++ b/internal/cmd/root/verbs/create/mesh.go @@ -20,6 +20,10 @@ import ( // giving "kongctl create mesh ..." alongside the explicit // "kongctl create konnect mesh ..." form. // +// `apply mesh` is the primary form for sending resources, since a write +// creates or replaces and that is what apply means. This is kept because it is +// the name the command first shipped under. +// // Reaching Kong Mesh through one command path regardless of whether the control // plane is Konnect hosted or self managed is deliberate: where a control plane // runs is a connection detail, not a different command. @@ -62,7 +66,8 @@ func NewDirectMeshCmd() (*cobra.Command, error) { return nil, err } - meshCmd.Example = fmt.Sprintf(` # Apply mesh resources from a file + meshCmd.Example = fmt.Sprintf(` # Apply mesh resources from a file. A write creates or replaces, + # so 'apply mesh' is the primary form for this. %[1]s create mesh -f policy.yaml --control-plane-id # Apply every resource in a directory From b744b38aeb9f38d70a768fefdb8163079170c49c Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 09:43:37 +0100 Subject: [PATCH 13/15] feat(mesh): support self managed control planes From review on #2128: --control-plane-url was advertised for self managed control planes, but the request path resolved Konnect credentials unconditionally, so an unauthenticated local control plane never received a request. The command failed first on the missing Konnect token. A self managed control plane authenticates its own callers, so Konnect credentials are neither required nor sent when one is addressed. The URL is what distinguishes the two, since a Konnect control plane is reached by ID or name through Konnect's base URL. Follows what kumactl supports for the same targets: --control-plane-token bearer token, sent as Authorization --ca-cert-file CA that verifies the control plane --client-cert-file client certificate, with --client-key-file --tls-skip-verify opt out of verification and no credential at all, which is the common local case: Kuma authenticates an API caller as admin over loopback. All five have configuration paths and go through the shared binding, so they can be set per profile. TLS settings reach the transport by extending httpclient.TransportOptions with a TLSClientConfig rather than building a second client here, keeping the configured timeout and transport behaviour that the rest of the mesh requests use. It is applied only when a self managed URL is set; Konnect is reached over its own certificates. Verified against a real control plane on both ports. Reads succeed with no credential and with a profile holding no Konnect configuration at all. Over https, verification fails without options, --tls-skip-verify succeeds, and --ca-cert-file gets past trust to fail on hostname instead, which is the port-forward rather than the flag. A missing CA file, a file with no certificate, and a certificate without its key are each reported before any request. Writes are refused by that control plane because its API server is read only, which it reports for all 37 types. The Konnect path is unchanged and still requires its credential. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit b4b8fbadbc191a7215147ee806db97f87058c7a7) --- .../cmd/root/products/konnect/mesh/client.go | 125 ++++++++++++++--- .../root/products/konnect/mesh/client_test.go | 129 ++++++++++++++++++ .../products/konnect/mesh/common/common.go | 53 ++++++- internal/konnect/httpclient/transport.go | 9 ++ 4 files changed, 298 insertions(+), 18 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/client.go b/internal/cmd/root/products/konnect/mesh/client.go index 6bdddfcf9..a8e8791ef 100644 --- a/internal/cmd/root/products/konnect/mesh/client.go +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -3,12 +3,15 @@ package mesh import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" + "os" "strings" "github.com/kong/kongctl/internal/cmd" @@ -16,6 +19,7 @@ import ( meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" "github.com/kong/kongctl/internal/config" "github.com/kong/kongctl/internal/konnect/apiutil" + "github.com/kong/kongctl/internal/konnect/auth" "github.com/kong/kongctl/internal/konnect/httpclient" ) @@ -78,6 +82,73 @@ func newHTTPClient(cfg config.Hook, logger *slog.Logger) (*httpclient.LoggingHTT httpclient.NewHTTPClientWithConfig(clientConfig), logger), nil } +// isSelfManaged reports whether requests address a self managed control plane +// rather than a Konnect hosted one. +// +// The URL is what distinguishes them: a Konnect control plane is addressed by +// ID or name through Konnect's own base URL, so an explicit API URL can only be +// a control plane the operator runs. +func isSelfManaged(cfg config.Hook) bool { + return strings.TrimSpace(cfg.GetString(meshcommon.ControlPlaneURLConfigPath)) != "" +} + +// selfManagedTLSConfig builds the TLS settings for a self managed control +// plane, or nil when none were given and Go's defaults apply. +func selfManagedTLSConfig(cfg config.Hook) (*tls.Config, error) { + var ( + caCertFile = strings.TrimSpace(cfg.GetString(meshcommon.CACertFileConfigPath)) + clientCertFile = strings.TrimSpace(cfg.GetString(meshcommon.ClientCertFileConfigPath)) + clientKeyFile = strings.TrimSpace(cfg.GetString(meshcommon.ClientKeyFileConfigPath)) + skipVerify = cfg.GetBool(meshcommon.TLSSkipVerifyConfigPath) + ) + + if caCertFile == "" && clientCertFile == "" && clientKeyFile == "" && !skipVerify { + return nil, nil + } + + // A certificate without its key, or the reverse, cannot be presented, so + // say which half is missing rather than failing the handshake later. + if (clientCertFile == "") != (clientKeyFile == "") { + return nil, &cmd.ConfigurationError{Err: fmt.Errorf( + "--%s and --%s are used together; provide both", + meshcommon.ClientCertFileFlagName, meshcommon.ClientKeyFileFlagName)} + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + // #nosec G402 -- opt in, named --tls-skip-verify, for a control plane + // whose certificate the operator cannot yet verify. + InsecureSkipVerify: skipVerify, + } + + if caCertFile != "" { + pem, err := os.ReadFile(caCertFile) + if err != nil { + return nil, &cmd.ConfigurationError{ + Err: fmt.Errorf("failed to read %s: %w", meshcommon.CACertFileFlagName, err), + } + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, &cmd.ConfigurationError{Err: fmt.Errorf( + "%s holds no PEM certificate: %s", meshcommon.CACertFileFlagName, caCertFile)} + } + tlsConfig.RootCAs = pool + } + + if clientCertFile != "" { + certificate, err := tls.LoadX509KeyPair(clientCertFile, clientKeyFile) + if err != nil { + return nil, &cmd.ConfigurationError{ + Err: fmt.Errorf("failed to load the client certificate: %w", err), + } + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + + return tlsConfig, nil +} + // meshClientConfig resolves the configured HTTP behaviour for mesh requests. // // Separated from the client it builds because the wrapped client keeps its @@ -93,6 +164,16 @@ func meshClientConfig(cfg config.Hook) (httpclient.ClientConfig, error) { return httpclient.ClientConfig{}, err } + // TLS material applies only to a control plane the operator runs; Konnect + // is reached over its own certificates. + if isSelfManaged(cfg) { + tlsConfig, err := selfManagedTLSConfig(cfg) + if err != nil { + return httpclient.ClientConfig{}, err + } + transportOptions.TLSClientConfig = tlsConfig + } + return httpclient.ClientConfig{ Timeout: timeout, TransportOptions: transportOptions, @@ -249,17 +330,26 @@ func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, err return nil, 0, err } - tokenSource, err := konnectcommon.GetAccessTokenSource(cfg, logger) - if err != nil { - return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) - } - ctx := helper.GetContext() if ctx == nil { ctx = context.Background() } - if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { - return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) + + // A self managed control plane authenticates its own callers, so Konnect + // credentials are neither required nor sent. Resolving them regardless + // meant an unauthenticated local control plane never received a request: + // the command failed first on the missing Konnect token. + selfManaged := isSelfManaged(cfg) + + var tokenSource *auth.TokenSource + if !selfManaged { + tokenSource, err = konnectcommon.GetAccessTokenSource(cfg, logger) + if err != nil { + return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) + } + if _, err := konnectcommon.ResolveAccessToken(ctx, cfg, tokenSource); err != nil { + return nil, 0, fmt.Errorf("resolve Konnect access token: %w", err) + } } var ( @@ -276,16 +366,17 @@ func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, err return nil, 0, err } - result, err := apiutil.RequestWithTokenSource( - ctx, - client, - method, - baseURL, - path, - tokenSource, - headers, - payload, - ) + var result *apiutil.Result + if selfManaged { + // An empty token sends no authorization header, which is what a + // control plane reached over loopback expects: Kuma authenticates + // such a caller as admin. + result, err = apiutil.Request(ctx, client, method, baseURL, path, + strings.TrimSpace(cfg.GetString(meshcommon.ControlPlaneTokenConfigPath)), headers, payload) + } else { + result, err = apiutil.RequestWithTokenSource( + ctx, client, method, baseURL, path, tokenSource, headers, payload) + } if err != nil { return nil, 0, err } diff --git a/internal/cmd/root/products/konnect/mesh/client_test.go b/internal/cmd/root/products/konnect/mesh/client_test.go index 5bbd9ff83..504f0e2d1 100644 --- a/internal/cmd/root/products/konnect/mesh/client_test.go +++ b/internal/cmd/root/products/konnect/mesh/client_test.go @@ -1,8 +1,17 @@ package mesh import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "errors" "log/slog" + "math/big" + "os" + "path/filepath" "testing" "time" @@ -243,3 +252,123 @@ func TestNewHTTPClientBuildsAClient(t *testing.T) { require.NoError(t, err) require.NotNil(t, client) } + +func TestIsSelfManaged(t *testing.T) { + require.False(t, isSelfManaged(meshTestConfig(t, map[string]any{}))) + require.False(t, isSelfManaged(meshTestConfig(t, map[string]any{ + meshcommon.ControlPlaneIDConfigPath: "an-id", + }))) + require.True(t, isSelfManaged(meshTestConfig(t, map[string]any{ + meshcommon.ControlPlaneURLConfigPath: "http://localhost:5681", + }))) + // Whitespace is not a selection. + require.False(t, isSelfManaged(meshTestConfig(t, map[string]any{ + meshcommon.ControlPlaneURLConfigPath: " ", + }))) +} + +// writeTempFile puts content on disk and returns its path. +func writeTempFile(t *testing.T, name, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// selfSignedCAPEM generates a CA certificate, so the test does not depend on +// one existing on disk. +func selfSignedCAPEM(t *testing.T) string { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mesh-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func TestSelfManagedTLSConfig(t *testing.T) { + t.Run("nothing configured leaves Go's defaults", func(t *testing.T) { + tlsConfig, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{})) + require.NoError(t, err) + require.Nil(t, tlsConfig) + }) + + t.Run("skip verify is opt in", func(t *testing.T) { + tlsConfig, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.TLSSkipVerifyConfigPath: true, + })) + require.NoError(t, err) + require.NotNil(t, tlsConfig) + require.True(t, tlsConfig.InsecureSkipVerify) + // Even when verification is skipped, the floor on the protocol stands. + require.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion) + }) + + t.Run("a CA file becomes the root pool", func(t *testing.T) { + caFile := writeTempFile(t, "ca.pem", selfSignedCAPEM(t)) + + tlsConfig, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.CACertFileConfigPath: caFile, + })) + require.NoError(t, err) + require.NotNil(t, tlsConfig.RootCAs) + require.False(t, tlsConfig.InsecureSkipVerify) + }) + + t.Run("a missing CA file is reported", func(t *testing.T) { + _, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.CACertFileConfigPath: filepath.Join(t.TempDir(), "absent.pem"), + })) + require.ErrorContains(t, err, meshcommon.CACertFileFlagName) + }) + + t.Run("a CA file holding no certificate is reported", func(t *testing.T) { + _, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.CACertFileConfigPath: writeTempFile(t, "bad.pem", "not a certificate"), + })) + require.ErrorContains(t, err, "no PEM certificate") + }) + + t.Run("a client certificate needs its key", func(t *testing.T) { + _, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.ClientCertFileConfigPath: writeTempFile(t, "cert.pem", selfSignedCAPEM(t)), + })) + require.ErrorContains(t, err, meshcommon.ClientKeyFileFlagName) + }) + + t.Run("a client key needs its certificate", func(t *testing.T) { + _, err := selfManagedTLSConfig(meshTestConfig(t, map[string]any{ + meshcommon.ClientKeyFileConfigPath: writeTempFile(t, "key.pem", "key material"), + })) + require.ErrorContains(t, err, meshcommon.ClientCertFileFlagName) + }) +} + +// TLS material is meaningless for a Konnect hosted control plane, which is +// reached over Konnect's own certificates. +func TestMeshClientConfigAppliesTLSOnlyWhenSelfManaged(t *testing.T) { + settings := map[string]any{meshcommon.TLSSkipVerifyConfigPath: true} + + konnect, err := meshClientConfig(meshTestConfig(t, settings)) + require.NoError(t, err) + require.Nil(t, konnect.TransportOptions.TLSClientConfig) + + settings[meshcommon.ControlPlaneURLConfigPath] = "https://mesh.example:5682" + selfManaged, err := meshClientConfig(meshTestConfig(t, settings)) + require.NoError(t, err) + require.NotNil(t, selfManaged.TransportOptions.TLSClientConfig) + require.True(t, selfManaged.TransportOptions.TLSClientConfig.InsecureSkipVerify) +} diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 169589374..10e2069e7 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -38,6 +38,15 @@ const ( // InspectTypeFlagName selects what an inspection reads. InspectTypeFlagName = "type" + + // These configure how a self managed control plane is reached. They apply + // only alongside ControlPlaneURLFlagName: a Konnect hosted control plane + // is reached with Konnect credentials and Konnect's own certificates. + ControlPlaneTokenFlagName = "control-plane-token" + CACertFileFlagName = "ca-cert-file" + ClientCertFileFlagName = "client-cert-file" + ClientKeyFileFlagName = "client-key-file" + TLSSkipVerifyFlagName = "tls-skip-verify" ) var ( @@ -50,7 +59,14 @@ var ( // no credential themselves. TokenValidForConfigPath = "konnect.mesh.token.valid-for" // #nosec G101 -- configuration path, not a credential TokenScopeConfigPath = "konnect.mesh.token.scope" // #nosec G101 -- configuration path, not a credential - InspectTypeConfigPath = "konnect.mesh.inspect.type" + + // Self managed control plane connection settings. + ControlPlaneTokenConfigPath = "konnect.mesh.control-plane.token" // #nosec G101 -- configuration path, not a credential + CACertFileConfigPath = "konnect.mesh.control-plane.ca-cert-file" + ClientCertFileConfigPath = "konnect.mesh.control-plane.client-cert-file" + ClientKeyFileConfigPath = "konnect.mesh.control-plane.client-key-file" + TLSSkipVerifyConfigPath = "konnect.mesh.control-plane.tls-skip-verify" + InspectTypeConfigPath = "konnect.mesh.inspect.type" ) // ControlPlanesPath lists the Konnect hosted Kong Mesh control planes, and @@ -160,6 +176,36 @@ func AddControlPlaneFlags(flags *pflag.FlagSet) { flags.Bool(AllMeshesFlagName, false, fmt.Sprintf(`List mesh scoped resources across every mesh instead of one. Ignored for global types. - Config path: [ %s ]`, AllMeshesConfigPath)) + + addSelfManagedFlags(flags) +} + +// addSelfManagedFlags registers how a self managed control plane is reached. +// +// A self managed control plane authenticates its own callers, so none of these +// carry a Konnect credential. Kuma authenticates an API caller as admin over +// loopback, which is why a local control plane needs no token at all, and +// accepts a bearer token or a client certificate otherwise. +func addSelfManagedFlags(flags *pflag.FlagSet) { + flags.String(ControlPlaneTokenFlagName, "", + fmt.Sprintf(`Bearer token for a self managed control plane. Not used for a Konnect hosted one. +- Config path: [ %s ]`, ControlPlaneTokenConfigPath)) + + flags.String(CACertFileFlagName, "", + fmt.Sprintf(`Path to a CA certificate that verifies a self managed control plane. +- Config path: [ %s ]`, CACertFileConfigPath)) + + flags.String(ClientCertFileFlagName, "", + fmt.Sprintf(`Path to a client certificate presented to a self managed control plane. +- Config path: [ %s ]`, ClientCertFileConfigPath)) + + flags.String(ClientKeyFileFlagName, "", + fmt.Sprintf(`Path to the key for --%s. +- Config path: [ %s ]`, ClientCertFileFlagName, ClientKeyFileConfigPath)) + + flags.Bool(TLSSkipVerifyFlagName, false, + fmt.Sprintf(`Do not verify a self managed control plane's certificate. Prefer --%s. +- Config path: [ %s ]`, CACertFileFlagName, TLSSkipVerifyConfigPath)) } // BindFlags associates the mesh flags with their configuration paths. @@ -185,6 +231,11 @@ func BindFlags(cfg config.Hook, flags *pflag.FlagSet) error { {AllMeshesFlagName, AllMeshesConfigPath}, {TokenValidForFlagName, TokenValidForConfigPath}, {TokenScopeFlagName, TokenScopeConfigPath}, + {ControlPlaneTokenFlagName, ControlPlaneTokenConfigPath}, + {CACertFileFlagName, CACertFileConfigPath}, + {ClientCertFileFlagName, ClientCertFileConfigPath}, + {ClientKeyFileFlagName, ClientKeyFileConfigPath}, + {TLSSkipVerifyFlagName, TLSSkipVerifyConfigPath}, } for _, b := range bindings { diff --git a/internal/konnect/httpclient/transport.go b/internal/konnect/httpclient/transport.go index a6334078f..4205c6161 100644 --- a/internal/konnect/httpclient/transport.go +++ b/internal/konnect/httpclient/transport.go @@ -1,6 +1,7 @@ package httpclient import ( + "crypto/tls" "net" "net/http" "time" @@ -22,6 +23,11 @@ type TransportOptions struct { TCPUserTimeout time.Duration DisableKeepAlives bool RecycleConnectionsOnError bool + // TLSClientConfig overrides how server certificates are verified and + // which client certificate is presented. Left nil, the transport keeps + // Go's defaults, which is what a Konnect target wants. A self managed + // control plane may use a private CA or ask for a client certificate. + TLSClientConfig *tls.Config } func NewHTTPClient(timeout time.Duration) *http.Client { @@ -63,6 +69,9 @@ func newHTTPTransport(options TransportOptions) http.RoundTripper { } transport := base.Clone() transport.DisableKeepAlives = options.DisableKeepAlives + if options.TLSClientConfig != nil { + transport.TLSClientConfig = options.TLSClientConfig + } dialer := &net.Dialer{ Timeout: defaultHTTPDialTimeout, From 834c5ad37ee799632e416b0d1e1d276b943b2929 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 10:01:09 +0100 Subject: [PATCH 14/15] refactor(mesh): name the shared column headers Follows the split: the column header constants arrived with the inspection work, while the printers here already used them. Defined alongside the printers instead, with the remaining literals replaced so a heading cannot drift between two tables showing the same field. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/root/mesh_command_paths_test.go | 29 ++----------------- .../products/konnect/mesh/createResources.go | 2 +- .../products/konnect/mesh/deleteResources.go | 2 +- .../products/konnect/mesh/getControlPlanes.go | 2 +- .../products/konnect/mesh/getResourceTypes.go | 2 +- .../root/products/konnect/mesh/printers.go | 8 ++--- 6 files changed, 11 insertions(+), 34 deletions(-) diff --git a/internal/cmd/root/mesh_command_paths_test.go b/internal/cmd/root/mesh_command_paths_test.go index 82d7d2d9a..3598ab84d 100644 --- a/internal/cmd/root/mesh_command_paths_test.go +++ b/internal/cmd/root/mesh_command_paths_test.go @@ -10,17 +10,15 @@ import ( // `kongctl get konnect mesh` failed with "unknown command". These assert the // real command paths rather than the constructors. func TestMeshCommandPathsResolve(t *testing.T) { - // Every verb Kong Mesh serves. `apply konnect` and `delete konnect` are - // replaced by the declarative commands and take their own arguments, so - // those two are served by the direct form only. + // Every verb Kong Mesh serves here. `apply konnect` and `delete konnect` + // are replaced by the declarative commands and take their own arguments, + // so those two are served by the direct form only. paths := [][]string{ {"apply", "mesh", "--help"}, {"get", "mesh", "--help"}, {"get", "konnect", "mesh", "--help"}, {"create", "mesh", "--help"}, {"create", "konnect", "mesh", "--help"}, - {"dump", "mesh", "--help"}, - {"dump", "konnect", "mesh", "--help"}, {"delete", "mesh", "--help"}, } @@ -47,24 +45,3 @@ func TestMeshCommandPathsResolve(t *testing.T) { }) } } - -// The export selection must not be called --profile: that name belongs to the -// global configuration profile, and a local flag of the same name shadowed it. -func TestMeshDumpDoesNotShadowProfileFlag(t *testing.T) { - result := executeRootForTest(t, "dump", "mesh", "--help") - if result.exitCode != 0 { - t.Fatalf("expected dump mesh help to succeed\nstderr:\n%s", result.stderr) - } - - if !strings.Contains(result.stdout, "--export-profile") { - t.Fatalf("expected the export selection to be --export-profile\nstdout:\n%s", result.stdout) - } - // The global -p/--profile is still present and must stay: what must not - // appear is an example telling operators to pass --profile for an export - // selection, which is what the rename was for. - for line := range strings.SplitSeq(result.stdout, "\n") { - if strings.Contains(line, "dump mesh --profile") { - t.Fatalf("example still uses --profile for the export selection: %q", line) - } - } -} diff --git a/internal/cmd/root/products/konnect/mesh/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go index 690c5bcd2..3c617117e 100644 --- a/internal/cmd/root/products/konnect/mesh/createResources.go +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -389,7 +389,7 @@ func reportApplyResults(helper cmd.Helper, results []applyResult) error { rows, rows, "Applied Mesh Resources", - tableview.WithExactCustomTable([]string{"TYPE", "NAME", "MESH", "RESULT"}, tableRows), + tableview.WithExactCustomTable([]string{colType, colName, colMesh, colResult}, tableRows), tableview.WithRootLabel(helper.GetCmd().Name()), ) } diff --git a/internal/cmd/root/products/konnect/mesh/deleteResources.go b/internal/cmd/root/products/konnect/mesh/deleteResources.go index 736fdc94e..c8b5b45c2 100644 --- a/internal/cmd/root/products/konnect/mesh/deleteResources.go +++ b/internal/cmd/root/products/konnect/mesh/deleteResources.go @@ -91,7 +91,7 @@ func reportDeleted(helper cmd.Helper, descriptor ResourceDescriptor, mesh, name rows, "Deleted Mesh Resource", tableview.WithExactCustomTable( - []string{"TYPE", "NAME", "MESH", "RESULT"}, + []string{colType, colName, colMesh, colResult}, []table.Row{{descriptor.Name, name, mesh, "deleted"}}, ), tableview.WithRootLabel(helper.GetCmd().Name()), diff --git a/internal/cmd/root/products/konnect/mesh/getControlPlanes.go b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go index d708eebfd..760750f91 100644 --- a/internal/cmd/root/products/konnect/mesh/getControlPlanes.go +++ b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go @@ -130,7 +130,7 @@ func (c *getControlPlanesCmd) runE(cobraCmd *cobra.Command, args []string) error rows, controlPlanes, "Mesh Control Planes", - tableview.WithExactCustomTable([]string{"NAME", "ID", "API LINE"}, tableRows), + tableview.WithExactCustomTable([]string{colName, "ID", "API LINE"}, tableRows), tableview.WithRootLabel(helper.GetCmd().Name()), ) } diff --git a/internal/cmd/root/products/konnect/mesh/getResourceTypes.go b/internal/cmd/root/products/konnect/mesh/getResourceTypes.go index 49705bdb8..abd98d775 100644 --- a/internal/cmd/root/products/konnect/mesh/getResourceTypes.go +++ b/internal/cmd/root/products/konnect/mesh/getResourceTypes.go @@ -121,7 +121,7 @@ func (c *getResourceTypesCmd) runE(cobraCmd *cobra.Command, args []string) error } // resourceTypeHeaders is the column set for the resource type listing. -var resourceTypeHeaders = []string{"NAME", "ALIAS", "SCOPE", "KIND", "WRITABLE"} +var resourceTypeHeaders = []string{colName, "ALIAS", "SCOPE", "KIND", "WRITABLE"} func toResourceTypeTableRows(rows []resourceTypeRow) []table.Row { tableRows := make([]table.Row, 0, len(rows)) diff --git a/internal/cmd/root/products/konnect/mesh/printers.go b/internal/cmd/root/products/konnect/mesh/printers.go index 623dbab18..a9756840f 100644 --- a/internal/cmd/root/products/konnect/mesh/printers.go +++ b/internal/cmd/root/products/konnect/mesh/printers.go @@ -35,9 +35,9 @@ func headersFor(d ResourceDescriptor) []string { case d.Name == dataplaneTypeName: return []string{colMesh, colName, "TAGS", "ADDRESS", "AGE"} case d.IsMeshScoped(): - return []string{"MESH", "NAME", "AGE"} + return []string{colMesh, colName, "AGE"} default: - return []string{"NAME", "AGE"} + return []string{colName, "AGE"} } } @@ -111,8 +111,6 @@ func duration(d time.Duration) string { return fmt.Sprintf("%dy", hours/24/365) } -// Discovered type names the printers and the export selection both test -// against, named once so the string is not repeated across the package. // Column headers shared by the printers, named once so a heading cannot drift // between the tables that show the same field. const ( @@ -122,6 +120,8 @@ const ( colResult = "RESULT" ) +// Discovered type names the printers test against, named once so the string is +// not repeated across the package. const ( dataplaneTypeName = "Dataplane" dataplaneInsightTypeName = "DataplaneInsight" From 76761c57891b86606344472d333398427d4f0791 Mon Sep 17 00:00:00 2001 From: Justin Davies Date: Wed, 16 Sep 2026 10:06:40 +0100 Subject: [PATCH 15/15] refactor(mesh): drop the inspection type option, which moves with inspect Left behind by the split: the flag name and configuration path stayed in the shared options while the command that reads them moved to its own branch. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/root/products/konnect/mesh/common/common.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/cmd/root/products/konnect/mesh/common/common.go b/internal/cmd/root/products/konnect/mesh/common/common.go index 10e2069e7..32519980a 100644 --- a/internal/cmd/root/products/konnect/mesh/common/common.go +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -36,9 +36,6 @@ const ( TokenValidForFlagName = "valid-for" TokenScopeFlagName = "scope" - // InspectTypeFlagName selects what an inspection reads. - InspectTypeFlagName = "type" - // These configure how a self managed control plane is reached. They apply // only alongside ControlPlaneURLFlagName: a Konnect hosted control plane // is reached with Konnect credentials and Konnect's own certificates. @@ -66,7 +63,6 @@ var ( ClientCertFileConfigPath = "konnect.mesh.control-plane.client-cert-file" ClientKeyFileConfigPath = "konnect.mesh.control-plane.client-key-file" TLSSkipVerifyConfigPath = "konnect.mesh.control-plane.tls-skip-verify" - InspectTypeConfigPath = "konnect.mesh.inspect.type" ) // ControlPlanesPath lists the Konnect hosted Kong Mesh control planes, and