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..14350948c --- /dev/null +++ b/internal/cmd/root/mesh_command_paths_test.go @@ -0,0 +1,149 @@ +package root + +import ( + "strings" + "testing" + + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" +) + +// 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 here. For Plan, Sync, Diff, Export, Apply + // and Delete, konnect.NewKonnectCmd replaces the konnect command with the + // declarative command and returns before any product is added, so + // `apply konnect mesh` and `delete konnect mesh` cannot be registered + // without restructuring that subtree. Those two are served by the direct + // form only, and fall through to the declarative command rather than + // erroring -- asserted below so the fall-through stays deliberate. + paths := [][]string{ + {"apply", "mesh", "--help"}, + {"get", "mesh", "--help"}, + {"get", "konnect", "mesh", "--help"}, + {"create", "mesh", "--help"}, + {"create", "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 declarative verbs claim ` konnect`, so mesh is not reachable +// there. That is a property of the konnect subtree rather than a mesh bug, but +// it is silent -- the declarative command accepts "mesh" as a positional +// argument and prints its own help -- so it is pinned here. If either of these +// ever resolves to the mesh command, meshVerbs and this test disagree. +func TestMeshIsNotReachableUnderTheDeclarativeVerbs(t *testing.T) { + for _, verb := range []string{"apply", "delete"} { + t.Run(verb, func(t *testing.T) { + result := executeRootForTest(t, verb, "konnect", "mesh", "--help") + + if strings.Contains(result.stdout, "Kong Mesh control plane") { + t.Fatalf("%s konnect mesh now resolves to the mesh command; "+ + "add %q to meshVerbs and move it into TestMeshCommandPathsResolve\nstdout:\n%s", + verb, verb, result.stdout) + } + }) + } +} + +// A selector given on the command line must reach configuration on both +// command trees. +// +// The explicit path passed the general konnect pre-run, which binds only the +// konnect-common flags, so every mesh flag there was registered but unbound. +// The failure was silent in the worst way: with a control plane already in +// configuration, `get konnect mesh meshes --control-plane-url ` ignored +// the flag and listed the configured control plane instead. +func TestMeshFlagsBindOnBothCommandTrees(t *testing.T) { + // A port nothing listens on. The request must be attempted and fail to + // connect, which proves the flag reached configuration. --help would not: + // it returns before the pre-run binds anything, which is why a help-only + // test cannot catch this. + const wantURL = "http://127.0.0.1:1" + + paths := [][]string{ + {"get", "mesh", "meshes"}, + {"get", "konnect", "mesh", "meshes"}, + } + + for _, path := range paths { + name := strings.Join(path, " ") + + t.Run(name, func(t *testing.T) { + args := append(append([]string{}, path...), "--control-plane-url", wantURL) + result := executeRootForTest(t, args...) + + if currConfig == nil { + t.Fatal("expected config to be initialized") + } + if got := currConfig.GetString(meshcommon.ControlPlaneURLConfigPath); got != wantURL { + t.Fatalf("%s: control plane URL = %q, want %q (flag registered but not bound)", + name, got, wantURL) + } + // The selector must also decide the request. Binding it while + // sending the request elsewhere is the failure this guards. + if !strings.Contains(result.stderr, "127.0.0.1:1") { + t.Fatalf("%s: expected the request to address %s\nstderr:\n%s", + name, wantURL, result.stderr) + } + }) + } +} + +// Resource writes belong to apply. `create mesh -f` reached the same upsert +// and so replaced an existing resource while reporting "updated", without the +// operator asking for a replacement, so it was removed. Token issuance stays +// under create. +func TestCreateMeshOffersTokensNotResourceWrites(t *testing.T) { + t.Run("the -f flag is gone", func(t *testing.T) { + result := executeRootForTest(t, "create", "mesh", "-f", "policy.yaml") + + if result.exitCode == 0 { + t.Fatalf("expected create mesh -f to be rejected\nstdout:\n%s", result.stdout) + } + if !strings.Contains(result.stderr, "shorthand flag: 'f'") { + t.Fatalf("expected an unknown-flag error for -f\nstderr:\n%s", result.stderr) + } + }) + + t.Run("token subcommands remain", func(t *testing.T) { + for _, sub := range []string{"zone-token", "dataplane-token"} { + result := executeRootForTest(t, "create", "mesh", sub, "--help") + if result.exitCode != 0 { + t.Fatalf("create mesh %s should still resolve\nstderr:\n%s", sub, result.stderr) + } + } + }) + + t.Run("apply still takes -f", func(t *testing.T) { + result := executeRootForTest(t, "apply", "mesh", "--help") + if !strings.Contains(result.stdout, "-f") { + t.Fatalf("expected apply mesh to offer -f\nstdout:\n%s", result.stdout) + } + }) +} diff --git a/internal/cmd/root/mesh_http_test.go b/internal/cmd/root/mesh_http_test.go new file mode 100644 index 000000000..def673dbb --- /dev/null +++ b/internal/cmd/root/mesh_http_test.go @@ -0,0 +1,346 @@ +package root + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" +) + +// These drive the whole root command against a fake control plane, so the +// pre-run, flag binding, target resolution, credential choice, HTTP request, +// pagination and error rendering all run. +// +// A self managed target resolves no Konnect credential, so pointing +// --control-plane-url at an httptest server exercises the real request path +// with no authentication set up at all. Before this, no mesh test issued an +// HTTP request of any kind. + +// meshDescriptors is the /_resources payload: the control plane reports the +// types it serves, and kongctl renders whatever comes back. +func meshDescriptors() map[string]any { + return map[string]any{ + "resources": []map[string]any{ + {"name": "Mesh", "path": "meshes", "scope": "Global", "shortName": "m", "readOnly": false}, + {"name": "MeshTimeout", "path": "meshtimeouts", "scope": "Mesh", "shortName": "mt", "readOnly": false}, + {"name": "Dataplane", "path": "dataplanes", "scope": "Mesh", "shortName": "dp", "readOnly": true}, + }, + } +} + +// meshInputFile puts a resource document on disk and returns its path. +func meshInputFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "resource.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write input: %v", err) + } + return path +} + +func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Errorf("encode response: %v", err) + } +} + +// A listing is returned across two pages, so a client that reads only the +// first page silently drops half the results. +func TestMeshGetPaginatesOverHTTP(t *testing.T) { + const total = 150 + + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_resources": + writeJSON(t, w, http.StatusOK, meshDescriptors()) + case "/meshes": + requests.Add(1) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + items := []map[string]any{} + for i := offset; i < total && len(items) < 100; i++ { + items = append(items, map[string]any{"name": fmt.Sprintf("mesh-%03d", i)}) + } + writeJSON(t, w, http.StatusOK, map[string]any{"total": total, "items": items}) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + result := executeRootForTest(t, + "get", "mesh", "meshes", "--control-plane-url", server.URL, "--output", "json") + if result.exitCode != 0 { + t.Fatalf("expected success\nstderr:\n%s", result.stderr) + } + + var payload struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal([]byte(result.stdout), &payload); err != nil { + t.Fatalf("decode output: %v\nstdout:\n%s", err, result.stdout) + } + if len(payload.Items) != total { + t.Fatalf("collected %d of %d items; a single-page read would stop at 100", len(payload.Items), total) + } + if got := requests.Load(); got != 2 { + t.Fatalf("expected 2 page requests, got %d", got) + } +} + +// A self managed control plane authenticates its own callers. No Konnect +// credential is resolved, and none is sent; the control plane token is sent +// only when one was given. +func TestMeshSelfManagedAuthorization(t *testing.T) { + cases := []struct { + name string + extraArgs []string + wantAuthHeader string + }{ + {name: "no token sends no authorization header", wantAuthHeader: ""}, + { + name: "the control plane token is sent when given", + extraArgs: []string{"--control-plane-token", "cp-secret"}, + wantAuthHeader: "Bearer cp-secret", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var seen string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/meshes" { + seen = r.Header.Get("Authorization") + } + if r.URL.Path == "/_resources" { + writeJSON(t, w, http.StatusOK, meshDescriptors()) + return + } + writeJSON(t, w, http.StatusOK, map[string]any{"total": 0, "items": []any{}}) + })) + defer server.Close() + + args := append([]string{"get", "mesh", "meshes", "--control-plane-url", server.URL}, tc.extraArgs...) + result := executeRootForTest(t, args...) + if result.exitCode != 0 { + t.Fatalf("expected success\nstderr:\n%s", result.stderr) + } + if seen != tc.wantAuthHeader { + t.Fatalf("Authorization = %q, want %q", seen, tc.wantAuthHeader) + } + }) + } +} + +// A write that the control plane rejects must fail the command and surface +// what the control plane said, rather than reporting a successful apply. +func TestMeshApplyReportsWriteFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_resources" { + writeJSON(t, w, http.StatusOK, meshDescriptors()) + return + } + writeJSON(t, w, http.StatusBadRequest, map[string]any{ + "title": "Resource is not valid", + "invalid_parameters": []map[string]any{ + {"field": "spec.targetRef.name", "reason": "unknown field"}, + }, + }) + })) + defer server.Close() + + // JSON output, so the reason is not truncated to a table column. + result := executeRootForTest(t, + "apply", "mesh", "-f", meshInputFile(t, "type: Mesh\nname: rejected\n"), + "--control-plane-url", server.URL, "--output", "json") + + if result.exitCode == 0 { + t.Fatalf("expected a rejected write to fail\nstdout:\n%s", result.stdout) + } + + var rows []struct { + Name string `json:"name"` + Result string `json:"result"` + } + if err := json.Unmarshal([]byte(result.stdout), &rows); err != nil { + t.Fatalf("decode output: %v\nstdout:\n%s", err, result.stdout) + } + if len(rows) != 1 { + t.Fatalf("expected one reported resource, got %d", len(rows)) + } + // The row must say it failed, and carry what the control plane objected + // to, rather than reporting the write as applied. + if !strings.HasPrefix(rows[0].Result, "failed:") { + t.Fatalf("result = %q, want a failure", rows[0].Result) + } + for _, want := range []string{"Resource is not valid", "spec.targetRef.name", "unknown field"} { + if !strings.Contains(rows[0].Result, want) { + t.Fatalf("result %q does not surface %q", rows[0].Result, want) + } + } +} + +// A read-only type is refused before a request is sent, naming the type. +func TestMeshApplyRefusesReadOnlyType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_resources" { + writeJSON(t, w, http.StatusOK, meshDescriptors()) + return + } + t.Errorf("no request should reach %s for a read only type", r.URL.Path) + })) + defer server.Close() + + result := executeRootForTest(t, + "apply", "mesh", "-f", meshInputFile(t, "type: Dataplane\nname: dp-1\nmesh: default\n"), + "--control-plane-url", server.URL) + + if result.exitCode == 0 { + t.Fatal("expected a read only type to be refused") + } + if !strings.Contains(result.stdout+result.stderr, "read only") { + t.Fatalf("expected the refusal to say the type is read only\nstderr:\n%s", result.stderr) + } +} + +// A remote input named by `-f ` is not the control plane, so the control +// plane's trust policy must not reach it. +// +// One client builder installed the control plane's client certificate, private +// CA and tls-skip-verify on every destination, so `apply mesh -f https://...` +// presented the operator's control plane certificate to whatever server held +// the file and accepted that server's certificate through a skip-verify meant +// for the control plane. Omitting the bearer token did not isolate TLS. +// +// The input server here is self signed. The control plane is configured with +// --tls-skip-verify, so if that setting leaked the download would succeed. +func TestMeshInputDownloadDoesNotInheritControlPlaneTrust(t *testing.T) { + inputServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + if _, err := w.Write([]byte("type: Mesh\nname: from-the-input-server\n")); err != nil { + t.Errorf("write input: %v", err) + } + })) + defer inputServer.Close() + + var reachedControlPlane bool + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_resources" { + writeJSON(t, w, http.StatusOK, meshDescriptors()) + return + } + reachedControlPlane = true + writeJSON(t, w, http.StatusOK, map[string]any{}) + })) + defer controlPlane.Close() + + result := executeRootForTest(t, + "apply", "mesh", "-f", inputServer.URL+"/resource.yaml", + "--control-plane-url", controlPlane.URL, + "--tls-skip-verify") + + if result.exitCode == 0 { + t.Fatalf("the input download inherited the control plane's skip-verify\nstdout:\n%s", result.stdout) + } + if reachedControlPlane { + t.Fatal("a resource fetched over an unverified connection reached the control plane") + } + // The failure must be the input download's certificate, not something else. + combined := result.stdout + result.stderr + if !strings.Contains(combined, "certificate") && !strings.Contains(combined, "x509") { + t.Fatalf("expected a certificate verification failure\nstdout:\n%s\nstderr:\n%s", + result.stdout, result.stderr) + } +} + +// A selector named on the command line decides the target, even when +// configuration names a different one. +// +// A configured control plane id used to short-circuit the resolver before +// --control-plane-name was considered, so an explicit name silently addressed +// the configured id instead. This resolver also serves writes and deletes, so +// that meant operating on a control plane the operator did not choose. +// +// The whole flow runs here: Konnect is faked so the name can be looked up, +// and the assertion is the path the mesh request actually took. +func TestMeshExplicitNameBeatsConfiguredID(t *testing.T) { + const ( + configuredID = "00000000-0000-4000-8000-000000000001" + namedID = "00000000-0000-4000-8000-000000000002" + ) + + var meshRequestPaths []string + konnect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v3/mesh/control-planes": + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": []map[string]any{ + {"id": configuredID, "name": "old", "version": "v3"}, + {"id": namedID, "name": "new", "version": "v3"}, + }, + "meta": map[string]any{"page": map[string]any{"total": 2, "size": 100, "number": 1}}, + }) + case strings.HasSuffix(r.URL.Path, "/_resources"): + meshRequestPaths = append(meshRequestPaths, r.URL.Path) + writeJSON(t, w, http.StatusOK, meshDescriptors()) + default: + meshRequestPaths = append(meshRequestPaths, r.URL.Path) + writeJSON(t, w, http.StatusOK, map[string]any{"total": 0, "items": []any{}}) + } + })) + defer konnect.Close() + + result := executeRootForTest(t, + "get", "mesh", "meshes", + // Configuration names one control plane; the command line names another. + "--control-plane-id", configuredID, + "--control-plane-name", "new", + "--base-url", konnect.URL, + "--pat", "test-pat") + + // Two selectors at once is rejected rather than resolved by precedence, + // because they name two different control planes. + if result.exitCode == 0 { + t.Fatalf("expected conflicting selectors to be rejected\nstdout:\n%s", result.stdout) + } + if !strings.Contains(result.stderr, "control-plane") { + t.Fatalf("expected the conflict to name the selectors\nstderr:\n%s", result.stderr) + } + + // With only the name given, and the id supplied through configuration + // rather than a flag, the name must win. + t.Setenv("KONGCTL_DEFAULT_KONNECT_MESH_CONTROL_PLANE_ID", configuredID) + meshRequestPaths = nil + + result = executeRootForTest(t, + "get", "mesh", "meshes", + "--control-plane-name", "new", + "--base-url", konnect.URL, + "--pat", "test-pat") + + if result.exitCode != 0 { + t.Fatalf("expected success\nstderr:\n%s", result.stderr) + } + if len(meshRequestPaths) == 0 { + t.Fatal("no mesh request was made") + } + for _, path := range meshRequestPaths { + if strings.Contains(path, configuredID) { + t.Fatalf("request went to the configured id %s, not the named control plane: %s", + configuredID, path) + } + if !strings.Contains(path, namedID) { + t.Fatalf("request did not address the named control plane %s: %s", namedID, path) + } + } +} diff --git a/internal/cmd/root/products/konnect/konnect.go b/internal/cmd/root/products/konnect/konnect.go index 96af1cd37..9164fed2f 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,49 @@ func preRunE(c *cobra.Command, args []string) error { return bindFlags(c, args) } +// meshVerbs are the verbs Kong Mesh serves under the explicit product path. +// +// Get and Create are the only ones reachable. For Plan, Sync, Diff, Export, +// Apply and Delete, NewKonnectCmd replaces the konnect command with the +// declarative command and returns before any product is added, so mesh cannot +// be registered under `apply konnect` or `delete konnect` without +// restructuring how the declarative verbs claim that subtree. Those two are +// served by the direct `apply mesh` and `delete mesh` forms. +// +// Dump is not listed because the dump verb never builds the konnect subtree, +// which made an earlier Dump entry here inert. +var meshVerbs = []verbs.VerbValue{verbs.Get, verbs.Create} + +// 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. +// +// 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, + 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 +279,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 +468,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 new file mode 100644 index 000000000..2d58a4f73 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/client.go @@ -0,0 +1,618 @@ +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" + 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/auth" + "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 +} + +// Mesh talks to three kinds of destination, and only one of them may be +// presented the control plane's TLS identity. Timeout and transport behaviour +// are shared by all three, so they resolve through the same helpers the rest +// of the Konnect operations use; TLS identity and trust are scoped per +// destination. +// +// A single builder installed the control plane's client certificate, private +// CA and tls-skip-verify on every destination. A remote `-f https://...` input +// therefore presented the operator's control plane certificate to whatever +// server held the file, and accepted that server's certificate through a +// skip-verify meant for the control plane. Omitting the bearer token was not +// enough: TLS is its own credential. + +// newControlPlaneClient builds the client for control plane requests. Only a +// self managed target's TLS material is installed; Konnect is reached over its +// own certificates. +func newControlPlaneClient( + cfg config.Hook, target meshTarget, logger *slog.Logger, +) (*httpclient.LoggingHTTPClient, error) { + clientConfig, err := controlPlaneClientConfig(cfg, target) + if err != nil { + return nil, err + } + return newLoggingClient(clientConfig, logger), nil +} + +// controlPlaneClientConfig is the control plane's client settings, separated +// from the client itself so that what TLS material a target receives can be +// asserted directly. +func controlPlaneClientConfig( + cfg config.Hook, target meshTarget, +) (httpclient.ClientConfig, error) { + clientConfig, err := baseClientConfig(cfg) + if err != nil { + return httpclient.ClientConfig{}, err + } + + if target.selfManaged { + tlsConfig, err := selfManagedTLSConfig(cfg) + if err != nil { + return httpclient.ClientConfig{}, err + } + clientConfig.TransportOptions.TLSClientConfig = tlsConfig + } + + return clientConfig, nil +} + +// newKonnectClient builds the client for Konnect's own API, such as listing +// control planes. Konnect presents its own certificates, so no control plane +// TLS material applies. +func newKonnectClient(cfg config.Hook, logger *slog.Logger) (*httpclient.LoggingHTTPClient, error) { + clientConfig, err := baseClientConfig(cfg) + if err != nil { + return nil, err + } + return newLoggingClient(clientConfig, logger), nil +} + +// newInputClient builds the client that downloads resource documents named by +// `-f `. That URL is not the control plane, so it is sent neither the +// control plane's credential nor its TLS identity, and it is not trusted +// through the control plane's skip-verify option. +func newInputClient(cfg config.Hook, logger *slog.Logger) (*httpclient.LoggingHTTPClient, error) { + clientConfig, err := baseClientConfig(cfg) + if err != nil { + return nil, err + } + return newLoggingClient(clientConfig, logger), nil +} + +func newLoggingClient( + clientConfig httpclient.ClientConfig, logger *slog.Logger, +) *httpclient.LoggingHTTPClient { + return httpclient.NewLoggingHTTPClientWithClient( + httpclient.NewHTTPClientWithConfig(clientConfig), logger) +} + +// meshTarget is the control plane an invocation addresses, resolved once. +// +// Whether a target is self managed decides three things at once: the base URL, +// which credential is sent, and whose TLS material applies. Deciding it twice +// from different inputs is how a self managed token reached a hosted endpoint: +// the URL resolver honoured an explicit --control-plane-id while a separate +// check asked only whether a URL happened to sit in configuration. One value, +// resolved once, keeps the three consistent. +type meshTarget struct { + baseURL string + // selfManaged is true when the resolved target is a control plane the + // operator runs, which authenticates its own callers. + selfManaged bool +} + +// 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 +} + +// baseClientConfig resolves the configured HTTP behaviour shared by every +// mesh destination. It carries no TLS identity: that is destination specific. +// +// Separated from the client it builds because the wrapped client keeps its +// settings private, so this is where the resolution can be asserted. +func baseClientConfig(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. +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) { + 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 +} + +// 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. +// +// Every mesh call goes through here so that control plane resolution, +// credentials, and error rendering behave identically across commands. +func send(helper cmd.Helper, method, path string, body []byte) ([]byte, int, error) { + cfg, err := helper.GetConfig() + if err != nil { + return nil, 0, err + } + + logger, err := helper.GetLogger() + if err != nil { + return nil, 0, err + } + + target, err := resolveTarget(helper, cfg) + if err != nil { + return nil, 0, err + } + + ctx := helper.GetContext() + if ctx == nil { + ctx = context.Background() + } + + // 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. + // + // This reads the resolved target rather than asking configuration again, + // so an explicit --control-plane-id carries the Konnect credential even + // when a self managed URL is also configured. + var tokenSource *auth.TokenSource + if !target.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 ( + payload io.Reader + headers map[string]string + ) + if body != nil { + payload = bytes.NewReader(body) + headers = map[string]string{"Content-Type": "application/json"} + } + + client, err := newControlPlaneClient(cfg, target, logger) + if err != nil { + return nil, 0, err + } + + var result *apiutil.Result + if target.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, target.baseURL, path, + strings.TrimSpace(cfg.GetString(meshcommon.ControlPlaneTokenConfigPath)), headers, payload) + } else { + result, err = apiutil.RequestWithTokenSource( + ctx, client, method, target.baseURL, path, tokenSource, headers, payload) + } + if err != nil { + return nil, 0, err + } + + 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, result.StatusCode, buildAPIError(result.StatusCode, result.Body) + } + + return result.Body, result.StatusCode, 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) +} + +// resolveTarget determines which control plane a command addresses, and +// whether it is one the operator runs. +// +// 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 resolveTarget(helper cmd.Helper, cfg config.Hook) (meshTarget, 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 meshTarget{}, err + } + + // Which selector won decides self managed, not whether a URL exists in + // configuration. An explicit ID or name addresses Konnect even when a self + // managed URL is also configured, and it must then carry the Konnect + // credential rather than the self managed one. + switch selector { + case meshcommon.ControlPlaneURLFlagName: + baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + if err != nil { + return meshTarget{}, err + } + return meshTarget{baseURL: baseURL, selfManaged: true}, nil + case meshcommon.ControlPlaneIDFlagName: + baseURL, err := meshcommon.ControlPlaneAPIURLForID( + cfg, cfg.GetString(meshcommon.ControlPlaneIDConfigPath)) + if err != nil { + return meshTarget{}, err + } + return meshTarget{baseURL: baseURL}, nil + case meshcommon.ControlPlaneNameFlagName: + baseURL, err := resolveBaseURLByName( + helper, cfg, cfg.GetString(meshcommon.ControlPlaneNameConfigPath)) + if err != nil { + return meshTarget{}, err + } + return meshTarget{baseURL: baseURL}, nil + } + + // Nothing was named on the command line, so configuration decides in the + // documented order: URL, then ID, then name. Only the URL branch is self + // managed. + baseURL, err := meshcommon.ResolveControlPlaneAPIURL(cfg) + if err == nil { + return meshTarget{ + baseURL: baseURL, + selfManaged: strings.TrimSpace(cfg.GetString(meshcommon.ControlPlaneURLConfigPath)) != "", + }, 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 meshTarget{}, err + } + + byName, err := resolveBaseURLByName(helper, cfg, name) + if err != nil { + return meshTarget{}, err + } + return meshTarget{baseURL: byName}, nil +} + +// 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.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..f2d394cfd --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/client_test.go @@ -0,0 +1,497 @@ +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" + + "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" + utilviper "github.com/kong/kongctl/internal/util/viper" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "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) +} + +// 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"]) +} + +// 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) +} + +// meshTestConfigWithEnv is meshTestConfig with environment variable handling +// wired up, which plain viper.New() does not do. Use it when a test needs the +// KONGCTL__... rung of the precedence chain. +func meshTestConfigWithEnv(t *testing.T, settings map[string]any) config.Hook { + t.Helper() + + const path = "/tmp/kongctl-mesh-test-config.yaml" + main := utilviper.NewViper(path) + main.Set("default", settings) + return config.BuildProfiledConfig("default", path, 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 := baseClientConfig(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 := baseClientConfig(meshTestConfig(t, map[string]any{})) + + require.NoError(t, err) + require.Equal(t, httpclient.DefaultHTTPClientTimeout, clientConfig.Timeout) +} + +func TestClientBuildersBuildAClient(t *testing.T) { + cfg := meshTestConfig(t, map[string]any{}) + logger := slog.New(slog.DiscardHandler) + + cpClient, err := newControlPlaneClient(cfg, meshTarget{baseURL: "https://cp.example"}, logger) + require.NoError(t, err) + require.NotNil(t, cpClient) + + konnectClient, err := newKonnectClient(cfg, logger) + require.NoError(t, err) + require.NotNil(t, konnectClient) + + inputClient, err := newInputClient(cfg, logger) + require.NoError(t, err) + require.NotNil(t, inputClient) +} + +// 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. It is also meaningless for the two +// destinations that are not the control plane at all. +func TestClientTLSAppliesOnlyToASelfManagedControlPlane(t *testing.T) { + // Configured as though a self managed control plane were in use, so the + // material is available to leak if a builder installs it. + cfg := meshTestConfig(t, map[string]any{ + meshcommon.TLSSkipVerifyConfigPath: true, + meshcommon.ControlPlaneURLConfigPath: "https://mesh.example:5682", + }) + selfManaged, err := controlPlaneClientConfig( + cfg, meshTarget{baseURL: "https://mesh.example:5682", selfManaged: true}) + require.NoError(t, err) + require.NotNil(t, selfManaged.TransportOptions.TLSClientConfig) + require.True(t, selfManaged.TransportOptions.TLSClientConfig.InsecureSkipVerify) + + // A hosted target, even with self managed TLS sitting in configuration. + hosted, err := controlPlaneClientConfig( + cfg, meshTarget{baseURL: "https://global.api.konghq.com"}) + require.NoError(t, err) + require.Nil(t, hosted.TransportOptions.TLSClientConfig) + + // Konnect's own API and a remote `-f` input are never the control plane, + // so neither may be handed the control plane's identity or trust policy. + shared, err := baseClientConfig(cfg) + require.NoError(t, err) + require.Nil(t, shared.TransportOptions.TLSClientConfig) + + // The timeout and transport settings are still shared by all of them. + require.Equal(t, shared.Timeout, selfManaged.Timeout) + require.Equal(t, shared.Timeout, hosted.Timeout) +} + +// An explicit hosted selector must win over a self managed URL left in +// configuration, and the classification must follow it. +// +// These were decided separately: the URL resolver honoured the explicit +// selector while a second check asked only whether a URL sat in +// configuration. A profile holding a self managed URL and token therefore sent +// that token to a Konnect hosted endpoint, and applied the self managed TLS +// policy to it. +func TestResolveTargetClassifiesFromTheWinningSelector(t *testing.T) { + const ( + selfManagedURL = "https://mesh.example:5682" + hostedID = "8f1c2d3e-0000-4000-8000-000000000001" + ) + + cases := []struct { + name string + settings map[string]any + given []string + wantSelfManaged bool + wantURLContains string + }{ + { + name: "explicit id beats a configured self managed url", + settings: map[string]any{ + meshcommon.ControlPlaneURLConfigPath: selfManagedURL, + meshcommon.ControlPlaneIDConfigPath: hostedID, + }, + given: []string{meshcommon.ControlPlaneIDFlagName}, + wantSelfManaged: false, + wantURLContains: hostedID, + }, + { + name: "explicit url is self managed", + settings: map[string]any{ + meshcommon.ControlPlaneURLConfigPath: selfManagedURL, + }, + given: []string{meshcommon.ControlPlaneURLFlagName}, + wantSelfManaged: true, + wantURLContains: "mesh.example", + }, + { + name: "a configured url with no explicit selector is self managed", + settings: map[string]any{ + meshcommon.ControlPlaneURLConfigPath: selfManagedURL, + }, + given: nil, + wantSelfManaged: true, + wantURLContains: "mesh.example", + }, + { + name: "a configured id with no explicit selector is hosted", + settings: map[string]any{ + meshcommon.ControlPlaneIDConfigPath: hostedID, + }, + given: nil, + wantSelfManaged: false, + wantURLContains: hostedID, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + helper := &cmd.MockHelper{} + helper.EXPECT().GetCmd().Return(selectorCmd(t, tc.given...)) + + target, err := resolveTarget(helper, meshTestConfig(t, tc.settings)) + require.NoError(t, err) + require.Equal(t, tc.wantSelfManaged, target.selfManaged, + "selfManaged for %q", tc.name) + require.Contains(t, target.baseURL, tc.wantURLContains) + }) + } +} + +// The classification decides which credential is sent, so a hosted target +// resolved over a configured self managed URL must not be handed the self +// managed TLS policy either. +func TestResolveTargetHostedOverSelfManagedURLGetsNoSelfManagedTLS(t *testing.T) { + cfg := meshTestConfig(t, map[string]any{ + meshcommon.ControlPlaneURLConfigPath: "https://mesh.example:5682", + meshcommon.ControlPlaneIDConfigPath: "8f1c2d3e-0000-4000-8000-000000000001", + meshcommon.TLSSkipVerifyConfigPath: true, + }) + + helper := &cmd.MockHelper{} + helper.EXPECT().GetCmd().Return(selectorCmd(t, meshcommon.ControlPlaneIDFlagName)) + + target, err := resolveTarget(helper, cfg) + require.NoError(t, err) + require.False(t, target.selfManaged) + + clientConfig, err := controlPlaneClientConfig(cfg, target) + require.NoError(t, err) + require.Nil(t, clientConfig.TransportOptions.TLSClientConfig, + "a hosted target must not receive the self managed TLS policy") +} 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..32519980a --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/common/common.go @@ -0,0 +1,245 @@ +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" + + // 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" + + // 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" + + // 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 ( + ControlPlaneIDConfigPath = "konnect.mesh.control-plane.id" + ControlPlaneNameConfigPath = "konnect.mesh.control-plane.name" + 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 + + // 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" +) + +// 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 +// 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 ControlPlanesPath = "/v3/mesh/control-planes" + +// ControlPlaneAPIPath returns the Konnect path prefix for a hosted Kong Mesh +// 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 ControlPlanesPath + "/" + 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 "", 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 + } + + return strings.TrimRight(konnectBaseURL, "/") + ControlPlaneAPIPath(controlPlaneID), nil +} + +// 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 { + 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)) + + 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. +// +// 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 + } + + bindings := []struct{ flag, config string }{ + {ControlPlaneIDFlagName, ControlPlaneIDConfigPath}, + {ControlPlaneNameFlagName, ControlPlaneNameConfigPath}, + {ControlPlaneURLFlagName, ControlPlaneURLConfigPath}, + {MeshFlagName, MeshConfigPath}, + {AllMeshesFlagName, AllMeshesConfigPath}, + {TokenValidForFlagName, TokenValidForConfigPath}, + {TokenScopeFlagName, TokenScopeConfigPath}, + {ControlPlaneTokenFlagName, ControlPlaneTokenConfigPath}, + {CACertFileFlagName, CACertFileConfigPath}, + {ClientCertFileFlagName, ClientCertFileConfigPath}, + {ClientKeyFileFlagName, ClientKeyFileConfigPath}, + {TLSSkipVerifyFlagName, TLSSkipVerifyConfigPath}, + } + + 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..1e839909e --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/common/common_test.go @@ -0,0 +1,130 @@ +package common + +import ( + "errors" + "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/v3/mesh/control-planes/5bf706d9-1e96-4a3a-bee4-cbf806d1dc1a" + 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/v3/mesh/control-planes/cp-1" { + 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) + } + } +} + +// 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 !errors.Is(err, ErrNoControlPlaneSelected) { + t.Errorf("expected ErrNoControlPlaneSelected, got %v", err) + } +} + +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) + } + } +} + +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 != "/v3/mesh/control-planes/cp-1" { + t.Errorf("unexpected path: %s", got) + } +} 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..f5016b5e0 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/controlPlanes.go @@ -0,0 +1,158 @@ +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" +) + +// 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, err := newKonnectClient(cfg, logger) + if err != nil { + return nil, err + } + + 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/createResources.go b/internal/cmd/root/products/konnect/mesh/createResources.go new file mode 100644 index 000000000..aaa65ca5f --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createResources.go @@ -0,0 +1,408 @@ +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/segmentio/cli" + "go.yaml.in/yaml/v4" +) + +// 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 + // Warnings are the notices the control plane returned for a write it + // accepted, such as a deprecated field. + Warnings []string + Err error +} + +// runApplyResources serves `apply 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, so every write is an upsert and is reported as created or updated. That +// is what kumactl apply does and what this is named after. It is not kongctl's +// declarative apply and carries no plan-and-diff step. +func runApplyResources(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, warnings, err := applyResource(helper, descriptors, resource) + if err != nil { + failed = true + } + // 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 { + 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, []string, error) { + descriptor, err := ResolveType(descriptors, resource.Type) + if err != nil { + 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, nil, 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, warnings, err := sendForWrite( + helper, http.MethodPut, descriptor.ItemPath(mesh, resource.Name), resource.Body) + if err != nil { + 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) + } +} + +// 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 + } + + cfg, err := helper.GetConfig() + if err != nil { + return nil, err + } + + // The configured timeout and transport apply here too. What stays separate + // is everything that identifies the caller to a control plane: the + // credential, which apiutil.Request does not attach, and the TLS identity + // and trust policy, which this client does not carry. + client, err := newInputClient(cfg, logger) + if err != nil { + return nil, err + } + + ctx := helper.GetContext() + result, err := apiutil.Request( + ctx, client, 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"` + // 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 +// 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), + Warnings: result.Warnings, + } + 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{colType, colName, colMesh, colResult}, 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..b35b640de --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createResources_test.go @@ -0,0 +1,214 @@ +package mesh + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/iostreams" +) + +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) + } +} + +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) + } +} 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..3f3d72346 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createTokens.go @@ -0,0 +1,319 @@ +package mesh + +import ( + "encoding/json" + "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" + "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" + tokenTagFlagName = "tag" + tokenProxyTypeFlagName = "proxy-type" + tokenWorkloadFlagName = "workload" + tokenZoneFlagName = "zone" + + // 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 +// 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, + } + cmdObj.PreRunE = chainMeshPreRun(parentPreRun) + + 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. "+ + "Given per invocation; it has no configured default.") + 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) + 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, + } + cmdObj.PreRunE = chainMeshPreRun(parentPreRun) + + 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}, + fmt.Sprintf(`Scope of resources the token can identify. +- Config path: [ %s ]`, meshcommon.TokenScopeConfigPath)) + 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.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(cfg) + 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 { + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + validFor, err := requireValidFor(cfg) + if err != nil { + return err + } + + 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, + ValidFor: validFor, + } + + return issueToken(helper, zoneTokenPath, request, "zone token") +} + +// 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( + "a positive token lifetime is required, for example 24h; set --%s or %s", + tokenValidForFlagName, meshcommon.TokenValidForConfigPath), + } + } + 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..e2a4fd4f1 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/createTokens_test.go @@ -0,0 +1,258 @@ +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" + "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 + raw string + want string + wantErr bool + }{ + {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. + {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) { + cfg := meshTestConfig(t, map[string]any{ + meshcommon.TokenValidForConfigPath: tc.raw, + }) + + got, err := requireValidFor(cfg) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + // 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 + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("validFor = %q, want %q", got, tc.want) + } + }) + } +} + +// 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. +// Mesh options resolve through configuration, so the file, the environment +// and the flag must layer in that order. +// +// This claimed environment coverage with an envValue field that no case set +// and nothing read, so the environment rung was never exercised. +func TestMeshOptionPrecedence(t *testing.T) { + cases := []struct { + name string + configPath string + flagName string + envVar 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", + }, + { + name: "token lifetime from the environment", + configPath: meshcommon.TokenValidForConfigPath, + flagName: meshcommon.TokenValidForFlagName, + envVar: "KONGCTL_DEFAULT_KONNECT_MESH_TOKEN_VALID_FOR", + envValue: "30m0s", + want: "30m0s", + }, + { + name: "the environment wins over the file", + configPath: meshcommon.TokenValidForConfigPath, + flagName: meshcommon.TokenValidForFlagName, + envVar: "KONGCTL_DEFAULT_KONNECT_MESH_TOKEN_VALID_FOR", + fileValue: "1h0m0s", + envValue: "30m0s", + want: "30m0s", + }, + { + name: "the flag wins over the environment", + configPath: meshcommon.TokenValidForConfigPath, + flagName: meshcommon.TokenValidForFlagName, + envVar: "KONGCTL_DEFAULT_KONNECT_MESH_TOKEN_VALID_FOR", + fileValue: "1h0m0s", + envValue: "30m0s", + flagValue: "5m0s", + want: "5m0s", + }, + { + name: "control plane id from the environment", + configPath: meshcommon.ControlPlaneIDConfigPath, + flagName: meshcommon.ControlPlaneIDFlagName, + envVar: "KONGCTL_DEFAULT_KONNECT_MESH_CONTROL_PLANE_ID", + envValue: "from-the-environment", + want: "from-the-environment", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.envVar != "" { + t.Setenv(tc.envVar, tc.envValue) + } + + settings := map[string]any{} + if tc.fileValue != "" { + settings[tc.configPath] = tc.fileValue + } + cfg := meshTestConfigWithEnv(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 +// 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/deleteResources.go b/internal/cmd/root/products/konnect/mesh/deleteResources.go new file mode 100644 index 000000000..c8b5b45c2 --- /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{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/discovery.go b/internal/cmd/root/products/konnect/mesh/discovery.go new file mode 100644 index 000000000..cbf947948 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/discovery.go @@ -0,0 +1,144 @@ +package mesh + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kong/kongctl/internal/cmd" + meshcommon "github.com/kong/kongctl/internal/cmd/root/products/konnect/mesh/common" +) + +// 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) { + body, err := fetch(helper, discoveryPath) + if err != nil { + return nil, err + } + return decodeDiscoveryResponse(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 +} 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..4e4f78911 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/discovery_test.go @@ -0,0 +1,180 @@ +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 TestBuildAPIError(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: "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", + 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 := buildAPIError(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/getControlPlanes.go b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go new file mode 100644 index 000000000..6cbe3e3aa --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getControlPlanes.go @@ -0,0 +1,134 @@ +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 + c.PreRunE = chainMeshPreRun(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{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 new file mode 100644 index 000000000..1f51fc11e --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getResourceTypes.go @@ -0,0 +1,164 @@ +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 + c.PreRunE = chainMeshPreRun(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{colName, "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/getResources.go b/internal/cmd/root/products/konnect/mesh/getResources.go new file mode 100644 index 000000000..d99a6f11a --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/getResources.go @@ -0,0 +1,172 @@ +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} + } + + 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). + 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 + } + } 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()) + 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 new file mode 100644 index 000000000..f527e05f9 --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/mesh.go @@ -0,0 +1,206 @@ +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 + +// FilenameFlagName names the -f flag that supplies resources to apply. +const FilenameFlagName = "filename" + +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 3.0 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 + + # 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))) +) + +// appliesResources reports whether a verb sends resource documents from -f. +// +// Only apply does. The control plane addresses a resource by type and name and +// a write creates or replaces it, so every resource write is an upsert; apply +// is both what that means and what kumactl calls it. +// +// `create mesh -f` reached the same write and so replaced an existing resource +// while reporting "updated", without the operator asking for a replacement. +// Kuma has no create-only write to implement a conflict check against, and +// detecting the conflict client-side would be a racy read-then-write, so the +// alias is gone rather than given surprising semantics. Token issuance stays +// under create, where a create is what it is. +func appliesResources(verb verbs.VerbValue) bool { + return verb == verbs.Apply +} + +// 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, + } + + baseCmd.PreRunE = chainMeshPreRun(parentPreRun) + if addParentFlags != nil { + addParentFlags(verb, baseCmd) + } + meshcommon.AddControlPlaneFlags(baseCmd.PersistentFlags()) + if appliesResources(verb) { + 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 + // 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 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 { + 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. + filenames, err := cmdObj.Flags().GetStringSlice(FilenameFlagName) + if err != nil { + return err + } + return runApplyResources(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 + // 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) + } + if !appliesResources(verb) && verb != verbs.Delete { + cmd.MarkRequiresSubcommand(baseCmd) + } + + if verb == verbs.Get { + baseCmd.AddCommand(newGetResourceTypesCmd(verb, addParentFlags, parentPreRun)) + baseCmd.AddCommand(newGetControlPlanesCmd(verb, addParentFlags, parentPreRun)) + } + if verb == verbs.Create { + baseCmd.AddCommand(newDataplaneTokenCmd(parentPreRun)) + baseCmd.AddCommand(newZoneTokenCmd(parentPreRun)) + } + + return baseCmd, nil +} + +// bindOwnFlags binds the mesh command's own flags to configuration, in the +// signature the other product binders use so it can sit in a pre-run chain. +func bindOwnFlags(c *cobra.Command, args []string) error { + if c == nil { + return nil + } + + helper := cmd.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + return meshcommon.BindFlags(cfg, c.Flags()) +} + +// chainMeshPreRun runs the caller's pre-run, then binds the mesh command's own +// flags. +// +// The mesh command used to assign the caller's pre-run outright and bind +// nothing itself, which left binding to whoever registered it. The four direct +// verb paths each called meshcommon.BindFlags in their own closure; the +// explicit konnect path passed the general konnect pre-run, which knows +// nothing about mesh, so every mesh flag there was registered but unbound and +// `get konnect mesh --control-plane-url ...` reported no control plane +// selected. Binding here means the two trees cannot diverge again, and +// matches how every other product owns both halves. +func chainMeshPreRun( + parentPreRun func(*cobra.Command, []string) error, +) func(*cobra.Command, []string) error { + return func(c *cobra.Command, args []string) error { + if parentPreRun != nil { + if err := parentPreRun(c, args); err != nil { + return err + } + } + return bindOwnFlags(c, args) + } +} 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..a9756840f --- /dev/null +++ b/internal/cmd/root/products/konnect/mesh/printers.go @@ -0,0 +1,188 @@ +package mesh + +import ( + "fmt" + "maps" + "slices" + "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 == dataplaneTypeName: + return []string{colMesh, colName, "TAGS", "ADDRESS", "AGE"} + case d.IsMeshScoped(): + return []string{colMesh, colName, "AGE"} + default: + return []string{colName, "AGE"} + } +} + +// cellsFor projects a row onto the resolved column set. +func cellsFor(d ResourceDescriptor, row resourceRow) []string { + switch { + 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} + default: + return []string{row.Name, row.Age} + } +} + +// buildRows projects control plane items into rows. +// +// 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{ + 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) +} + +// 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" +) + +// Discovered type names the printers test against, named once so the string is +// not repeated across the package. +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{} + + for key, value := range mapField(item, "labels") { + if s, ok := value.(string); ok { + 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] = 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)) { + rendered = append(rendered, fmt.Sprintf("%s=%s", key, tags[key])) + } + 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 +} 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..b8caf3786 --- /dev/null +++ b/internal/cmd/root/verbs/apply/mesh.go @@ -0,0 +1,100 @@ +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" + "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 + } + + // Mesh flags are bound by the mesh command itself, which chains this + // pre-run, so both command trees bind identically. + return nil + } + + 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 + cat policy.yaml | %[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/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..aaaf244de --- /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/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" + "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. +// +// Under create, mesh serves token issuance only. Resources are written with +// `apply mesh -f`, because a write creates or replaces and that is what apply +// means; a create that silently replaced an existing resource was the reason +// the -f form is not offered here. +// +// 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 + } + + // Mesh flags are bound by the mesh command itself, which chains this + // pre-run, so both command trees bind identically. + return nil + } + + meshCmd, err := mesh.NewMeshCmd(Verb, addFlags, preRunE) + if err != nil { + return nil, err + } + + meshCmd.Example = fmt.Sprintf(` # Issue a token that proves a zone's identity to the global control plane + %[1]s create mesh zone-token --zone zone1 --valid-for 720h --control-plane-id + + # Issue a token that proves a dataplane's identity + %[1]s create mesh dataplane-token --mesh default --name backend-01 --control-plane-id + + # Mesh resources are written with apply, since a write creates or replaces + %[1]s apply mesh -f policy.yaml --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..90be7701e --- /dev/null +++ b/internal/cmd/root/verbs/del/mesh.go @@ -0,0 +1,67 @@ +package del + +import ( + "context" + "fmt" + + "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" + "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 + } + + // Mesh flags are bound by the mesh command itself, which chains this + // pre-run, so both command trees bind identically. + return nil + } + + 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 +} 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..43da54cab --- /dev/null +++ b/internal/cmd/root/verbs/get/mesh.go @@ -0,0 +1,67 @@ +package get + +import ( + "context" + "fmt" + + "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" + "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 + } + + // Mesh flags are bound by the mesh command itself, which chains this + // pre-run, so both command trees bind identically. + return nil + } + + 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 +} 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, diff --git a/test/e2e/scenarios/mesh/apply-get-delete/scenario.yaml b/test/e2e/scenarios/mesh/apply-get-delete/scenario.yaml new file mode 100644 index 000000000..7ed624a67 --- /dev/null +++ b/test/e2e/scenarios/mesh/apply-get-delete/scenario.yaml @@ -0,0 +1,207 @@ +# The resource write lifecycle: apply, read back, delete. +# +# Everything created here is deleted in the final step. The org reset that other +# suites rely on does not sweep mesh control planes, so a scenario that leaves a +# mesh behind leaves it behind for every later run. +# +# Names are fixed and prefixed rather than randomised, matching the other +# suites; isolation comes from the org matrix and the explicit cleanup. +test: + enabledByEnvVar: KONGCTL_E2E_RUN_MESH + requiredEnvVars: + - KONGCTL_E2E_MESH_CONTROL_PLANE_ID + info: > + Set KONGCTL_E2E_RUN_MESH=1 and KONGCTL_E2E_MESH_CONTROL_PLANE_ID to the id + of a Kong Mesh control plane to run the mesh scenarios. + +vars: + mesh: kongctl-e2e-mesh-lifecycle + policy: kongctl-e2e-timeouts + +steps: + # A previous run that failed before its cleanup step would leave these + # behind, so remove them first and tolerate their absence. + - name: 000-pre-clean + skipInputs: true + commands: + - name: 000-delete-policy-if-present + run: + - delete + - mesh + - meshtimeouts + - "{{ .vars.policy }}" + - --mesh + - "{{ .vars.mesh }}" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + # Absent is the expected case on a clean org. + expectFailure: + exitCode: 1 + - name: 001-delete-mesh-if-present + run: + - delete + - mesh + - meshes + - "{{ .vars.mesh }}" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + expectFailure: + exitCode: 1 + + - name: 001-apply-mesh + skipInputs: true + commands: + - name: 000-apply + run: + - apply + - mesh + - -f + - "-" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + stdin: | + type: Mesh + name: {{ .vars.mesh }} + assertions: + - select: "@" + expect: + fields: + "length([?type=='Mesh' && name=='{{ .vars.mesh }}' && result=='created']) == `1`": true + + # A write addresses a resource by type and name and creates or replaces it, + # so applying the same document again is an update rather than a conflict. + - name: 002-apply-is-an-upsert + skipInputs: true + commands: + - name: 000-reapply + run: + - apply + - mesh + - -f + - "-" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + stdin: | + type: Mesh + name: {{ .vars.mesh }} + assertions: + - select: "@" + expect: + fields: + "length([?name=='{{ .vars.mesh }}' && result=='updated']) == `1`": true + + - name: 003-apply-a-policy-into-the-mesh + skipInputs: true + commands: + - name: 000-apply-policy + run: + - apply + - mesh + - -f + - "-" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + stdin: | + type: MeshTimeout + name: {{ .vars.policy }} + mesh: {{ .vars.mesh }} + spec: + targetRef: + kind: Mesh + to: + - targetRef: + kind: Mesh + default: + connectionTimeout: 5s + idleTimeout: 1h + assertions: + - select: "@" + expect: + fields: + "length([?type=='MeshTimeout' && result=='created']) == `1`": true + + - name: 004-read-back + skipInputs: true + commands: + - name: 000-get-the-mesh + run: + - get + - mesh + - meshes + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + "length(items[?name=='{{ .vars.mesh }}']) == `1`": true + - name: 001-get-the-policy + run: + - get + - mesh + - meshtimeouts + - "{{ .vars.policy }}" + - --mesh + - "{{ .vars.mesh }}" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + name: "{{ .vars.policy }}" + mesh: "{{ .vars.mesh }}" + # Written through this control plane, so it owns the resource. + 'labels."kuma.io/origin"': global + + - name: 005-delete-cleanup + skipInputs: true + # A write or delete against a remote control plane occasionally takes long + # enough to look like a failure. Retrying matters most here: a cleanup that + # gives up leaves a mesh behind for every later run, because the org reset + # the other suites rely on does not sweep mesh control planes. + retry: + attempts: 3 + interval: 5s + backoffFactor: 2 + commands: + - name: 000-delete-policy + run: + - delete + - mesh + - meshtimeouts + - "{{ .vars.policy }}" + - --mesh + - "{{ .vars.mesh }}" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + "length([?result=='deleted']) == `1`": true + - name: 001-delete-mesh + run: + - delete + - mesh + - meshes + - "{{ .vars.mesh }}" + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + "length([?result=='deleted']) == `1`": true + - name: 002-confirm-gone + run: + - get + - mesh + - meshes + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + "length(items[?name=='{{ .vars.mesh }}'])": 0 diff --git a/test/e2e/scenarios/mesh/discovery/scenario.yaml b/test/e2e/scenarios/mesh/discovery/scenario.yaml new file mode 100644 index 000000000..c008881f6 --- /dev/null +++ b/test/e2e/scenarios/mesh/discovery/scenario.yaml @@ -0,0 +1,75 @@ +# Discovery and read against a Kong Mesh control plane. +# +# kongctl carries no built-in list of Kong Mesh resource types: it asks the +# control plane and renders what comes back, so a newer Kong Mesh release +# serves new policy types without a kongctl change. That contract is what this +# asserts. +test: + enabledByEnvVar: KONGCTL_E2E_RUN_MESH + requiredEnvVars: + - KONGCTL_E2E_MESH_CONTROL_PLANE_ID + info: > + Set KONGCTL_E2E_RUN_MESH=1 and KONGCTL_E2E_MESH_CONTROL_PLANE_ID to the id + of a Kong Mesh control plane to run the mesh scenarios. The shared e2e orgs + do not have a mesh control plane by default. + +steps: + - name: 000-list-control-planes + skipInputs: true + commands: + - name: 000-control-planes + run: + - get + - mesh + - control-planes + assertions: + # The selected control plane must be among those listed. + - select: "@" + expect: + fields: + "length([?id=='{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}']) == `1`": true + + - name: 001-discover-resource-types + skipInputs: true + commands: + - name: 000-resource-types + run: + - get + - mesh + - resource-types + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + - select: "@" + expect: + fields: + # The control plane reports its own types, so assert the + # contract rather than an exact list that a release may grow. + "length(@) > `0`": true + # Every type carries the fields the printers and the generic + # read depend on. + "length([?name==''])": 0 + "length([?path==''])": 0 + # Scope drives whether --mesh applies, so it must be one of + # the two the client understands. + "length([?scope!='Mesh' && scope!='Global'])": 0 + # Meshes are global and writable; dataplanes are read only. + "length([?path=='meshes' && scope=='Global']) == `1`": true + "length([?path=='dataplanes' && readOnly]) == `1`": true + + - name: 002-read-meshes + skipInputs: true + commands: + - name: 000-get-meshes + run: + - get + - mesh + - meshes + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + assertions: + # Every control plane has a default mesh. + - select: "@" + expect: + fields: + "length(items[?name=='default']) == `1`": true diff --git a/test/e2e/scenarios/mesh/tokens/scenario.yaml b/test/e2e/scenarios/mesh/tokens/scenario.yaml new file mode 100644 index 000000000..e8d8d6af7 --- /dev/null +++ b/test/e2e/scenarios/mesh/tokens/scenario.yaml @@ -0,0 +1,90 @@ +# Token issuance. +# +# A zone token proves a zone control plane's identity when it connects to the +# global control plane over KDS; a dataplane token proves a proxy's identity. +# Both are issued by the control plane and returned as a signed JWT, so the +# assertion is on the shape of what comes back rather than its contents. +# +# Nothing is created that needs deleting: a token is not a stored resource. +test: + enabledByEnvVar: KONGCTL_E2E_RUN_MESH + requiredEnvVars: + - KONGCTL_E2E_MESH_CONTROL_PLANE_ID + info: > + Set KONGCTL_E2E_RUN_MESH=1 and KONGCTL_E2E_MESH_CONTROL_PLANE_ID to the id + of a Kong Mesh control plane to run the mesh scenarios. + +steps: + - name: 000-zone-token + skipInputs: true + commands: + - name: 000-issue + run: + - create + - mesh + - zone-token + - --zone + - kongctl-e2e-zone + - --valid-for + - 1h + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + # The token is printed as a bare JWT, not structured output. + parseAs: text + assertions: + - select: "@" + expect: + fields: + # A compact JWS: base64url segments joined by dots, so the + # header decodes from the leading 'eyJ'. + "starts_with(text, 'eyJ')": true + "contains(text, '.')": true + "length(text) > `100`": true + + - name: 001-dataplane-token + skipInputs: true + commands: + - name: 000-issue + run: + - create + - mesh + - dataplane-token + - --mesh + - default + - --name + - kongctl-e2e-dp + - --valid-for + - 1h + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + parseAs: text + assertions: + - select: "@" + expect: + fields: + "starts_with(text, 'eyJ')": true + "length(text) > `100`": true + + # A lifetime is required, and configuration can satisfy it, so the resolved + # value is validated rather than the flag's presence. + - name: 002-lifetime-from-the-environment + skipInputs: true + commands: + - name: 000-issue-without-the-flag + env: + KONGCTL_DEFAULT_KONNECT_MESH_TOKEN_VALID_FOR: 1h + run: + - create + - mesh + - zone-token + - --zone + - kongctl-e2e-zone + - --control-plane-id + - "{{ .env.KONGCTL_E2E_MESH_CONTROL_PLANE_ID }}" + parseAs: text + assertions: + - select: "@" + expect: + fields: + "starts_with(text, 'eyJ')": true + "length(text) > `100`": true