From 785659c4b24c610f9568b408f8c7c6ee2a86bcb9 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:47:38 +0200 Subject: [PATCH 01/21] docs: design connectivity CLI commands --- .../2026-08-07-connectivity-cli-design.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-connectivity-cli-design.md diff --git a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md new file mode 100644 index 00000000..c323580e --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md @@ -0,0 +1,259 @@ +# Connectivity CLI Design + +**Date:** 2026-08-07 +**Status:** Approved in conversation; pending written-spec review +**Upstream contract:** `formancehq/connectivity@develop` commit `2ec12564c02c040b3f9da5fbdf40c880e67c1137`, Connectivity API `0.1.0` + +## Goal + +Add a first-class `fctl connectivity` module that exposes the Connectivity +plugin catalog and instance lifecycle as domain-oriented commands. The module +must feel native to `fctl`, use the selected stack and existing authentication, +and provide reliable context-aware shell completion. + +Connectivity is a new module and may eventually replace Payments. This change +does not remove, alias, or modify any `fctl payments` command. + +## Scope + +The upstream API provides health endpoints, read-only plugin catalog endpoints, +and CRUD endpoints for instances. The CLI exposes the user workflows, not the +HTTP verbs: + +```text +fctl connectivity plugins list +fctl connectivity plugins show + +fctl connectivity instances list +fctl connectivity instances show +fctl connectivity instances install +fctl connectivity instances configure +fctl connectivity instances uninstall +``` + +The health probes remain operational endpoints and do not receive CLI commands. +The API's full-replacement `PUT /instances/{name}` is not exposed in this first +version because there is no corresponding `fctl` workflow. A future declarative +`apply` command can add that behavior without changing the commands above. + +## Command conventions + +The hierarchy follows the dominant `fctl ` grammar. +The root uses `fctl.NewStackCommand`, so `--organization` and `--stack` behave +like they do for Ledger and Payments. + +### Plugins + +`plugins list` calls `GET /plugins`. It supports the standard `--page-size` and +`--cursor` flags and maps them to the API's `limit` and `continue` parameters. +Plain output contains Name, Default Version, Description, Capabilities, and +Phase. JSON output preserves the complete plugin objects and continuation data +inside the standard `fctl` data envelope. + +`plugins show ` calls `GET /plugins/{name}`. Plain output includes the +plugin metadata, image, version choices, capabilities, status, documentation +URL, and a readable summary of its configuration schema. JSON output preserves +the complete plugin object. + +Aliases: + +- `plugins`: `plugin`, `p` +- `list`: `ls`, `l` +- `show`: `get`, `g`, `sh`, `s` + +### Instances + +`instances list` calls `GET /instances`. It supports `--plugin`, `--page-size`, +and `--cursor`. Plain output contains Name, Plugin, Version, Ledger, Phase, +State, Current Sequence, Source Tip Sequence, and Last Error. JSON output +preserves the complete instances and continuation data. + +`instances show ` calls `GET /instances/{name}`. Plain output shows +metadata, desired specification, effective image, lifecycle state, ingestion +progress, and error information. It lists configuration keys and their source +types but never prints inline configuration values. JSON output preserves the +API response, consistent with the existing `fctl -o json` contract. + +`instances install ` calls `GET /plugins/{plugin}` to obtain defaults +and the configuration schema, validates the requested configuration, and then +calls `POST /instances`. It accepts: + +- `--name`, defaulting to the plugin name; +- required `--ledger`; +- optional `--version`, `--start-sequence`, and `--poll-interval`; +- optional `--config `; +- repeatable `--env-file `; +- repeatable `--set KEY=VALUE`; +- the standard confirmation flag. + +`instances configure ` calls `GET /instances/{name}`, then +`GET /plugins/{plugin}` for schema-aware parsing, and finally +`PATCH /instances/{name}` with `application/merge-patch+json`. It accepts the +same configuration inputs as install. It also accepts optional `--version`, +`--ledger`, `--start-sequence`, and `--poll-interval`; only explicitly supplied +fields are patched. At least one change must be supplied. + +`instances uninstall ` calls `DELETE /instances/{name}` after the +standard `fctl` confirmation flow. + +Aliases: + +- `instances`: `instance`, `i` +- `list`: `ls`, `l` +- `show`: `get`, `g`, `sh`, `s` +- `install`: `create`, `in` +- `configure`: `config`, `update`, `c` +- `uninstall`: `delete`, `remove`, `rm`, `u` + +## Configuration inputs + +The configuration model follows Connectivity's `InstanceConfig` schema and the +UX already proven by the experimental `cctl` on `connectivity@develop`. + +`--set` accepts these value sources: + +```text +--set KEY=value +--set KEY=@path/to/local/file +--set KEY=@- +--set KEY=secret://secret-name/key +--set KEY=configmap://config-map-name/key +``` + +For `@path`, the local file contents become the inline value. `@-` reads stdin +through the existing `fctl.ReadFile` behavior and therefore requires +`--confirm`. Secret and ConfigMap forms create references and do not read the +referenced Kubernetes objects. + +`--env-file` accepts dotenv-style `KEY=VALUE` records, ignores blank lines and +comments, and is repeatable. It represents environment entries only. + +`--config` accepts YAML or JSON. A flat mapping is interpreted as environment +values. A structured document may contain `env` and `files` matching the +Connectivity API's `InstanceConfig` shape. + +Values merge in this exact order, from lowest to highest precedence: + +1. plugin defaults; +2. `--config`; +3. `--env-file` values, with later files winning; +4. `--set` values, with later occurrences winning. + +The plugin's `configSchema` determines whether a key is an environment entry or +a file mount. Required keys are validated before the write request. Invalid +assignments, malformed references, unreadable files, unknown schema keys, and +missing required values return actionable errors. Password-formatted values are +never included in plain terminal output. + +## Client architecture + +A focused internal Connectivity client owns the API models and the seven +non-health operations used by the commands. It is handwritten rather than +generated because the contract is small, the upstream API is still `0.1.0`, and +generated code would add substantial repository and toolchain surface. + +The client accepts a base URL and `http.Client`, exposes an interface used by +the commands, escapes path and query values, and decodes the API's structured +`{code,message,details}` errors. Non-success responses include the HTTP status +and structured API error when available. + +Production wiring uses `fctl.NewStackClientsFromFlags`. Requests target: + +```text +/api/connectivity/plugins +/api/connectivity/instances +``` + +This reuses the existing OAuth token source, refresh behavior, transport, +debugging, TLS settings, and `fctl/` user agent. No Connectivity-specific +profile, context, token, or API URL flag is introduced. + +## Package boundaries + +- `internal/connectivityclient` contains HTTP models, the client interface, + implementation, and structured errors. +- `cmd/connectivity` wires the stack-scoped root and shared client factory. +- `cmd/connectivity/plugins` owns plugin commands, stores, renderers, and plugin + completion. +- `cmd/connectivity/instances` owns instance lifecycle commands, stores, + renderers, configuration assembly, validation, and instance completion. +- Small shared completion and file helpers stay inside `cmd/connectivity` unless + both resource packages require them; no unrelated `pkg` refactor is included. + +Commands receive the client through an overridable factory. Production uses the +authenticated stack client, while unit tests inject mocks without real network, +authentication, filesystem, or Kubernetes dependencies. + +## Completion contract + +Completion is part of the feature, not a follow-up: + +- plugin arguments complete from `GET /plugins?limit=500`; +- instance arguments complete from `GET /instances?limit=500`; +- `--version` completes from the selected plugin's declared versions; +- `--set` completes schema keys as `KEY=`, includes descriptions, prioritizes + required keys, and omits keys already supplied; +- after `KEY=@`, completion returns local file and directory candidates while + preserving the `KEY=@` prefix; +- `--config` and `--env-file` retain normal shell file completion; +- static commands, flags, aliases, and resource names continue to use Cobra's + generated completion scripts. + +Dynamic completion uses a two-second context timeout. Missing profiles, +authentication failures, unsupported Connectivity deployments, and network +errors produce no candidates and no terminal noise. Completion never triggers a +confirmation prompt. + +## Rendering and errors + +All commands use the existing controller/store/render flow. Plain output uses +`pterm` tables and detail views consistent with nearby `fctl` modules. JSON uses +the global `--output json` path and does not introduce a Connectivity-only +output flag. + +Mutating commands use `fctl.WithConfirmFlag` and +`fctl.CheckStackApprobation`. Client-side validation errors name the offending +flag or key. API errors preserve the upstream code, message, details, and HTTP +status. Empty or malformed success responses are treated as errors instead of +being rendered as zero-value resources. + +## Testing and verification + +Development follows red-green-refactor. Every new production behavior begins +with a focused failing test. + +Unit tests use Go's test tooling, `testify`, and mocks: + +- client request method, path, query, content type, response decoding, and API + error decoding through a mocked `RoundTripper`; +- command argument and flag validation through a mocked client interface; +- configuration parsing, source routing, precedence, schema validation, and + secret redaction; +- plain and JSON rendering; +- every dynamic completion path, including timeout/error silence and `@` file + candidates; +- root command registration and aliases. + +HTTP integration tests may use an in-process `httptest.Server`. Their names use +Given/When/Then and verify complete command-to-API flows without external +services. New handwritten packages must maintain at least 80% statement +coverage. + +Verification runs inside the repository's Nix development shell: + +```text +nix develop -c just tests +nix develop -c just pre-commit +``` + +The second command is the repository's actual precommit recipe and includes +tidy, generation, linting, and completion generation. + +## Non-goals + +- Removing or deprecating `fctl payments`. +- Reusing `cctl`'s separate contexts, login flow, or output subsystem. +- Exposing Kubernetes objects or kubeconfig directly. +- Adding Connectivity health commands. +- Adding declarative `apply` or full-replacement instance updates. +- Generating and committing an OpenAPI client in this PR. From 0db22fd9d4063370f14cf7e5b609bf8d5ee2f63c Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:57:25 +0200 Subject: [PATCH 02/21] docs: plan connectivity CLI implementation --- .../plans/2026-08-07-connectivity-cli.md | 595 ++++++++++++++++++ .../2026-08-07-connectivity-cli-design.md | 15 +- 2 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-07-connectivity-cli.md diff --git a/docs/superpowers/plans/2026-08-07-connectivity-cli.md b/docs/superpowers/plans/2026-08-07-connectivity-cli.md new file mode 100644 index 00000000..4ee6face --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-connectivity-cli.md @@ -0,0 +1,595 @@ +# Fctl Connectivity CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a stack-authenticated, domain-oriented `fctl connectivity` command tree for discovering plugins and managing Connectivity instances, including schema-aware configuration and dynamic shell completion. + +**Architecture:** A handwritten `internal/connectivityclient` implements the seven Connectivity API operations used by the CLI. Resource-specific Cobra controllers receive a mockable client factory and reuse `fctl` authentication, rendering, pagination, confirmation, and JSON output. + +**Tech Stack:** Go 1.25, Cobra, pterm, testify, YAML v3, `net/http`, Nix, Just. + +## Global Constraints + +- Use `formancehq/connectivity@develop` commit `2ec12564c02c040b3f9da5fbdf40c880e67c1137`, API `0.1.0`, as the contract. +- Keep `fctl payments` unchanged and use `fctl connectivity `. +- Call `/api/connectivity` through existing stack authentication. +- Add no Connectivity profile, token, context, API URL flag, generated client, or new dependency. +- Never print inline configuration values in plain output; JSON retains the API model. +- Dynamic completion has a two-second timeout, fails silently, and never prompts. +- Unit tests use mocks; HTTP integration test names use Given/When/Then. +- New handwritten packages maintain at least 80% statement coverage. +- Run all tooling through Nix and Just, including tests and precommit before each commit. + +Approved design: `docs/superpowers/specs/2026-08-07-connectivity-cli-design.md`. + +## File map + +- `internal/connectivityclient/{models,client}.go`: API contract and HTTP adapter. +- `cmd/connectivity/internal/client.go`: authenticated client factory. +- `cmd/connectivity/plugins`: list/show/render/complete plugins. +- `cmd/connectivity/instances/config.go`: config sources, schema, merge, validation. +- `cmd/connectivity/instances`: list/show/install/configure/uninstall and completion. +- `cmd/connectivity/root.go`, `cmd/root.go`: public command wiring. +- `completions/fctl.{bash,zsh,fish}`: regenerated shell scripts. + +--- + +### Task 1: Connectivity API client + +**Files:** +- Create: `internal/connectivityclient/models.go` +- Create: `internal/connectivityclient/client.go` +- Test: `internal/connectivityclient/client_test.go` +- Test: `internal/connectivityclient/integration_test.go` + +**Interfaces:** +- Consumes: stack URI and authenticated `*http.Client`. +- Produces: models, `ListOptions`, `InstancePatch`, `APIError`, `Client`, and `New`. + +- [ ] **Step 1: Write failing HTTP contract tests** + +Use a mocked `RoundTripper` to assert method, escaped path, query, headers, body, response decoding, empty success, and structured errors. Include this representative test and table cases for all seven methods: + +```go +func TestListPluginsBuildsStackConnectivityRequest(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"img"}}],"continue":"next"}`), nil + })} + got, err := New("https://stack.example/base", httpClient).ListPlugins(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) + require.NoError(t, err) + require.Equal(t, "/base/api/connectivity/plugins", seen.URL.Path) + require.Equal(t, "25", seen.URL.Query().Get("limit")) + require.Equal(t, "cursor", seen.URL.Query().Get("continue")) + require.Equal(t, "stripe", *got.Items[0].Metadata.Name) + require.Equal(t, "next", got.Continue) +} +``` + +Define these test-only helpers in `client_test.go`: + +```go +type roundTripperFunc func(*http.Request) (*http.Response, error) +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } +func jsonResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} +} +``` + +Add `TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected` with `httptest.Server` and Given/When/Then comment sections. + +- [ ] **Step 2: Verify RED** + +Run `nix develop -c go test ./internal/connectivityclient -v`. + +Expected: compilation fails because client symbols do not exist. + +- [ ] **Step 3: Add exact public models and interface** + +```go +type ObjectMeta struct { Name *string `json:"name,omitempty"`; Namespace *string `json:"namespace,omitempty"`; ResourceVersion *string `json:"resourceVersion,omitempty"`; UID *string `json:"uid,omitempty"`; CreationTimestamp *time.Time `json:"creationTimestamp,omitempty"`; Labels map[string]string `json:"labels,omitempty"`; Annotations map[string]string `json:"annotations,omitempty"` } +type KeyRef struct { Name string `json:"name"`; Key string `json:"key"` } +type EnvValue struct { Value *string `json:"value,omitempty"`; SecretRef *KeyRef `json:"secretRef,omitempty"`; ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` } +type FileMount struct { Path string `json:"path"`; Value *string `json:"value,omitempty"`; SecretRef *KeyRef `json:"secretRef,omitempty"`; ConfigMapRef *KeyRef `json:"configMapRef,omitempty"`; Mode *int32 `json:"mode,omitempty"` } +type InstanceConfig struct { Env map[string]EnvValue `json:"env,omitempty"`; Files []FileMount `json:"files,omitempty"` } +type VersionEntry struct { Version string `json:"version"`; Digest *string `json:"digest,omitempty"`; Image *string `json:"image,omitempty"` } +type PluginSpec struct { Image string `json:"image"`; Version *string `json:"version,omitempty"`; Description *string `json:"description,omitempty"`; DocsURL *string `json:"docsURL,omitempty"`; Capabilities []string `json:"capabilities,omitempty"`; ConfigSchema map[string]any `json:"configSchema,omitempty"`; DefaultVersion *string `json:"defaultVersion,omitempty"`; Versions []VersionEntry `json:"versions,omitempty"`; Defaults *InstanceConfig `json:"defaults,omitempty"` } +type PluginStatus struct { Phase *string `json:"phase,omitempty"`; Message *string `json:"message,omitempty"` } +type Plugin struct { Metadata ObjectMeta `json:"metadata"`; Spec PluginSpec `json:"spec"`; Status *PluginStatus `json:"status,omitempty"` } +type PluginList struct { Items []Plugin `json:"items"`; Continue string `json:"continue,omitempty"` } +type InstanceSpec struct { Plugin string `json:"plugin"`; Version *string `json:"version,omitempty"`; ConnectivityRef *string `json:"connectivityRef,omitempty"`; Ledger string `json:"ledger"`; StartSequence *int64 `json:"startSequence,omitempty"`; PollInterval *string `json:"pollInterval,omitempty"`; Config *InstanceConfig `json:"config,omitempty"` } +type InstanceStatus struct { Phase *string `json:"phase,omitempty"`; State *string `json:"state,omitempty"`; PluginAddress *string `json:"pluginAddress,omitempty"`; ResolvedImage *string `json:"resolvedImage,omitempty"`; CurrentSequence *int64 `json:"currentSequence,omitempty"`; SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"`; LastError *string `json:"lastError,omitempty"`; Message *string `json:"message,omitempty"` } +type Instance struct { Metadata ObjectMeta `json:"metadata"`; Spec InstanceSpec `json:"spec"`; Status *InstanceStatus `json:"status,omitempty"` } +type InstanceList struct { Items []Instance `json:"items"`; Continue string `json:"continue,omitempty"` } +type InstanceCreate struct { Name string `json:"name"`; Labels map[string]string `json:"labels,omitempty"`; Annotations map[string]string `json:"annotations,omitempty"`; Spec InstanceSpec `json:"spec"` } +type InstancePatch map[string]any +type ListOptions struct { Limit int32; Continue string; Plugin string } +type APIError struct { StatusCode int; Code string; Message string; Details map[string]any } + +type Client interface { + ListPlugins(context.Context, ListOptions) (*PluginList, error) + GetPlugin(context.Context, string) (*Plugin, error) + ListInstances(context.Context, ListOptions) (*InstanceList, error) + CreateInstance(context.Context, InstanceCreate) (*Instance, error) + GetInstance(context.Context, string) (*Instance, error) + PatchInstance(context.Context, string, InstancePatch) (*Instance, error) + DeleteInstance(context.Context, string) error +} +func New(stackURI string, httpClient *http.Client) Client +``` + +- [ ] **Step 4: Implement minimal HTTP wrappers** + +Use one JSON request helper. Join `/api/connectivity`, escape names with `url.PathEscape`, use `url.Values`, set `Accept`, set POST content type to `application/json`, PATCH to `application/merge-patch+json`, parse `{code,message,details}` into `APIError`, and reject malformed or empty object responses. Accept 200 for reads and patch, 201 for create, and 204 for delete. + +- [ ] **Step 5: Verify GREEN, coverage, repository gates, and commit** + +```text +nix develop -c go test ./internal/connectivityclient -coverprofile=/tmp/connectivity-client.cover -v +nix develop -c go tool cover -func=/tmp/connectivity-client.cover +nix develop -c just tests +nix develop -c just pre-commit +git add internal/connectivityclient +git commit -m "feat(connectivity): add API client" +``` + +Expected: tests pass and package coverage is at least 80%. + +--- + +### Task 2: Schema-aware configuration + +**Files:** +- Create: `cmd/connectivity/instances/config.go` +- Test: `cmd/connectivity/instances/test_helpers_test.go` +- Test: `cmd/connectivity/instances/config_test.go` + +**Interfaces:** +- Consumes: plugin schema/defaults, current instance config, Cobra stdin, and injected file reader. +- Produces: `SchemaFields`, `BuildInstallConfig`, and `BuildConfigureConfig`. + +- [ ] **Step 1: Write failing parser and merge tests** + +Use a map-backed `ReadFileFunc`, never the real filesystem. Test flat/structured YAML, dotenv, later-source precedence, later `--set`, inline values, `@file`, `@-`, secret/configmap refs, env/file routing, missing required keys, malformed refs, unknown keys, and configure preserving untouched files. + +```go +func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { + current := &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("a")}, + {Path: "/etc/b", Value: stringPtr("b")}, + }} + got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, + InputOptions{SetValues: []string{"/etc/a=@new"}}, + func(cmd *cobra.Command, name string) (string, error) { return "changed", nil }) + require.NoError(t, err) + require.Len(t, got.Files, 2) + require.Equal(t, "changed", *got.Files[0].Value) + require.Equal(t, "b", *got.Files[1].Value) +} +``` + +Define `stringPtr`, `pluginWithFileSchema`, and `pluginWithSchemaAndDefaults` in `test_helpers_test.go`. The first returns `&value`; the plugin fixtures return API models with explicit env/file properties, required arrays, password formats, defaults, and two version entries. + +- [ ] **Step 2: Verify RED** + +Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(Build|Parse|Schema)' -v`. + +Expected: compilation fails because configuration symbols do not exist. + +- [ ] **Step 3: Implement exact configuration contracts** + +```go +type ConfigKind string +const ( ConfigEnv ConfigKind = "env"; ConfigFile ConfigKind = "file" ) +type SchemaField struct { Key string; Kind ConfigKind; Required bool; Password bool; Description string } +type InputOptions struct { ConfigFile string; EnvFiles []string; SetValues []string } +type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) +func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, error) +func BuildInstallConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) +func BuildConfigureConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, current *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) +``` + +Install merges defaults โ†’ config โ†’ env files in order โ†’ set values in order. Configure replaces defaults with a deep copy of current config. Replace file mounts by path without losing untouched mounts. Required-key and unknown-key errors sort keys for deterministic output. + +- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** + +```text +nix develop -c go test ./cmd/connectivity/instances -run 'Test(Build|Parse|Schema)' -coverprofile=/tmp/connectivity-config.cover -v +nix develop -c go tool cover -func=/tmp/connectivity-config.cover +nix develop -c just tests +nix develop -c just pre-commit +git add cmd/connectivity/instances/config.go cmd/connectivity/instances/config_test.go docs/superpowers/specs/2026-08-07-connectivity-cli-design.md +git commit -m "feat(connectivity): assemble instance configuration" +``` + +--- + +### Task 3: Plugin catalog commands + +**Files:** +- Create: `cmd/connectivity/internal/client.go` +- Create: `cmd/connectivity/plugins/{root,list,show,completion}.go` +- Test: `cmd/connectivity/plugins/test_helpers_test.go` +- Test: `cmd/connectivity/plugins/{list,show,completion}_test.go` + +**Interfaces:** +- Consumes: client API, stack clients, pagination, renderer. +- Produces: `ClientFactory`, plugin root, list/show, `CompletePluginNames`. + +- [ ] **Step 1: Write failing mocked command tests** + +Embed `connectivityclient.Client` in mocks and override only called methods. Assert pagination, continuation rendering, JSON store, versions/capabilities/schema detail rendering, prefix filtering, completion descriptions, two-second deadline, and silent errors. + +```go +type pluginClientMock struct { connectivityclient.Client; list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error); get func(context.Context, string) (*connectivityclient.Plugin, error) } +func (m pluginClientMock) ListPlugins(ctx context.Context, opts connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { return m.list(ctx, opts) } +func (m pluginClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { return m.get(ctx, name) } +func pluginFixture(name string) connectivityclient.Plugin { return connectivityclient.Plugin{Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, Spec: connectivityclient.PluginSpec{Image: "registry/plugin", Description: fctl.Ptr("Plugin description")}} } +``` + +- [ ] **Step 2: Verify RED** + +Run `nix develop -c go test ./cmd/connectivity/plugins -v`. + +Expected: compilation fails because plugin packages do not exist. + +- [ ] **Step 3: Implement authenticated factory and commands** + +```go +type ClientFactory func(*cobra.Command) (connectivityclient.Client, error) +func NewClientFactory() ClientFactory +func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command +func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc +``` + +The factory calls `LoadAndAuthenticateCurrentProfile` and `NewStackClientsFromFlags`, then constructs the API client. Use aliases `plugins: plugin,p`, `list: ls,l`, `show: get,g,sh,s`. `ListStore` carries plugins and `fctl.Cursor`; `ShowStore` carries the complete plugin. Render the approved columns and schema summary. + +- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** + +```text +nix develop -c go test ./cmd/connectivity/plugins -coverprofile=/tmp/connectivity-plugins.cover -v +nix develop -c go tool cover -func=/tmp/connectivity-plugins.cover +nix develop -c just tests +nix develop -c just pre-commit +git add cmd/connectivity/internal cmd/connectivity/plugins +git commit -m "feat(connectivity): add plugin catalog commands" +``` + +--- + +### Task 4: Instance read commands and completion + +**Files:** +- Create: `cmd/connectivity/instances/{root,list,show,completion}.go` +- Modify: `cmd/connectivity/instances/test_helpers_test.go` +- Test: `cmd/connectivity/instances/{list,show,completion}_test.go` + +**Interfaces:** +- Consumes: shared client factory, API models, cursor flags, schema fields. +- Produces: instance root, list/show, instance/version/set/path completion. + +- [ ] **Step 1: Write failing list/show tests** + +Mock `ListInstances` and `GetInstance`. Assert `--plugin`, cursor/page size, approved columns, progression fields, JSON store, and that plain output names config keys/source types but never inline values. + +```go +func TestShowPlainOutputNeverPrintsInlineConfigValues(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("must-not-leak")}, + }} + cmd := NewShowCommand(factoryWithInstance(instance)) + cmd.SetArgs([]string{"stripe-eu"}) + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Execute()) + require.Contains(t, out.String(), "API_KEY") + require.Contains(t, out.String(), "inline") + require.NotContains(t, out.String(), "must-not-leak") +} +``` + +- [ ] **Step 2: Write failing completion tests** + +Assert instance-name and version candidates, required schema keys before optional keys, descriptions after a tab, already supplied keys omitted, `KEY=@` candidates preserving the prefix, `NoSpace` for `KEY=`, and silence on timeout/error. Inject a mock path completer rather than reading disk. + +Define reusable test helpers with these signatures in `test_helpers_test.go`: + +```go +func stringPtr(value string) *string +func instanceFixture(name string) connectivityclient.Instance +func instanceWithTwoFiles() *connectivityclient.Instance +func pluginWithFileSchema() *connectivityclient.Plugin +func pluginWithSchemaAndDefaults() *connectivityclient.Plugin +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory +func factoryWithInstance(instance connectivityclient.Instance) connectivityinternal.ClientFactory +func mockReadFile(files map[string]string) ReadFileFunc +func mockPathCompleter(paths []string) PathCompleter +``` + +The package-specific mock structs embed `connectivityclient.Client` and implement only the methods exercised by their test file; every function-valued field is invoked directly by its matching method. + +- [ ] **Step 3: Verify RED** + +Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(ListInstances|Show|Complete)' -v`. + +Expected: compilation fails because read commands and completion functions do not exist. + +- [ ] **Step 4: Implement read commands and renderers** + +```go +func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command +``` + +Use aliases `instances: instance,i`, `list: ls,l`, and `show: get,g,sh,s`. `ListStore` contains instances and `fctl.Cursor`; `ShowStore` contains the complete instance. Render config sources as `inline`, `secret:name/key`, or `configmap:name/key` without rendering `Value` fields. + +- [ ] **Step 5: Implement exact completion API** + +```go +type PathCompleter func(prefix string) ([]string, error) +func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc +func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func(*cobra.Command, []string) string) cobra.CompletionFunc +func CompleteSetValues(factory connectivityinternal.ClientFactory, resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), paths PathCompleter) cobra.CompletionFunc +func OSPathCompleter(prefix string) ([]string, error) +``` + +Use `context.WithTimeout(cmd.Context(), 2*time.Second)`, list limit 500, prefix filtering, sorted candidates, and silent `NoFileComp` errors. Preserve `KEY=@` when completing paths. + +- [ ] **Step 6: Verify GREEN, coverage, gates, and commit** + +```text +nix develop -c go test ./cmd/connectivity/instances -run 'Test(ListInstances|Show|Complete)' -coverprofile=/tmp/connectivity-read.cover -v +nix develop -c go tool cover -func=/tmp/connectivity-read.cover +nix develop -c just tests +nix develop -c just pre-commit +git add cmd/connectivity/instances +git commit -m "feat(connectivity): list and show instances" +``` + +--- + +### Task 5: Install instances + +**Files:** +- Create: `cmd/connectivity/instances/install.go` +- Test: `cmd/connectivity/instances/install_test.go` +- Modify: `cmd/connectivity/instances/root.go` +- Modify: `cmd/connectivity/instances/completion.go` + +**Interfaces:** +- Consumes: install config builder and plugin/version/set completion. +- Produces: `NewInstallCommand` and POST workflow. + +- [ ] **Step 1: Write failing install tests** + +Test required ledger, default name, scalar flags, config assembly, confirmation rejection, API error, success renderer, plugin positional completion, version completion, set completion, and file completion for config/env files. + +```go +func TestInstallBuildsInstanceFromPluginSchema(t *testing.T) { + client := mutationClientMock{ + getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { return pluginWithSchemaAndDefaults(), nil }, + create: func(ctx context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + require.Equal(t, "stripe-eu", body.Name) + require.Equal(t, "main", body.Spec.Ledger) + require.Equal(t, "api-key", *body.Spec.Config.Env["API_KEY"].Value) + return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + cmd := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + cmd.SetArgs([]string{"stripe", "--name=stripe-eu", "--ledger=main", "--set=API_KEY=api-key", "--confirm"}) + require.NoError(t, cmd.Execute()) +} +``` + +- [ ] **Step 2: Verify RED** + +Run `nix develop -c go test ./cmd/connectivity/instances -run TestInstall -v`. + +Expected: compilation fails because `NewInstallCommand` does not exist. + +- [ ] **Step 3: Implement flags, completions, controller, and renderer** + +Build `install ` with aliases `create,in`, exact one argument, `--name`, required `--ledger`, optional `--version`, `--start-sequence`, `--poll-interval`, `--config`, repeatable `--env-file`, repeatable `--set`, and confirmation. Fetch plugin, call `BuildInstallConfig`, approve, POST, store the returned instance, and render: + +```text +Instance "stripe-eu" installed with plugin "stripe" for ledger "main". +``` + +Use `Flags().Changed` for optional scalar fields. Send config only when defaults or user inputs produce one. + +- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** + +```text +nix develop -c go test ./cmd/connectivity/instances -run TestInstall -v +nix develop -c go test ./cmd/connectivity/instances -coverprofile=/tmp/connectivity-instances.cover +nix develop -c go tool cover -func=/tmp/connectivity-instances.cover +nix develop -c just tests +nix develop -c just pre-commit +git add cmd/connectivity/instances +git commit -m "feat(connectivity): install plugin instances" +``` + +--- + +### Task 6: Configure and uninstall instances + +**Files:** +- Create: `cmd/connectivity/instances/configure.go` +- Test: `cmd/connectivity/instances/configure_test.go` +- Create: `cmd/connectivity/instances/uninstall.go` +- Test: `cmd/connectivity/instances/uninstall_test.go` +- Modify: `cmd/connectivity/instances/root.go` +- Modify: `cmd/connectivity/instances/completion.go` + +**Interfaces:** +- Consumes: current instance config, plugin schema, configure builder, completion, confirmation. +- Produces: PATCH `configure` and DELETE `uninstall` workflows. + +- [ ] **Step 1: Write failing configure tests** + +Assert current-config precedence, all untouched file mounts preserved, changed scalar fields included, unchanged scalars omitted, no-change rejection, confirmation rejection, API errors, instance/version/set completion, and success rendering. + +```go +func TestConfigureBuildsNestedPatch(t *testing.T) { + client := configureClientMock{instance: instanceWithTwoFiles(), plugin: pluginWithFileSchema(), patch: func(ctx context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + spec := patch["spec"].(map[string]any) + require.Equal(t, "15s", spec["pollInterval"]) + config := spec["config"].(*connectivityclient.InstanceConfig) + require.Len(t, config.Files, 2) + return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}}, nil + }} + cmd := NewConfigureCommand(factoryReturning(client), mockReadFile(map[string]string{"new": "changed"}), mockPathCompleter(nil)) + cmd.SetArgs([]string{"stripe-eu", "--set=/etc/a=@new", "--poll-interval=15s", "--confirm"}) + require.NoError(t, cmd.Execute()) +} +``` + +- [ ] **Step 2: Write failing uninstall tests and verify RED** + +Test confirmed delete, rejected confirmation, API error, output, and instance completion. Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(Configure|Uninstall)' -v`. + +Expected: compilation fails because mutation commands do not exist. + +- [ ] **Step 3: Implement configure** + +Build `configure ` with aliases `config,update,c`, shared config flags, optional scalar flags, exact one argument, and confirmation. Fetch instance then plugin. Merge on current config. Build exactly: + +```go +specPatch := map[string]any{} +if configChanged { specPatch["config"] = mergedConfig } +if cmd.Flags().Changed("version") { specPatch["version"] = version } +if cmd.Flags().Changed("ledger") { specPatch["ledger"] = ledger } +if cmd.Flags().Changed("start-sequence") { specPatch["startSequence"] = startSequence } +if cmd.Flags().Changed("poll-interval") { specPatch["pollInterval"] = pollInterval } +patch := connectivityclient.InstancePatch{"spec": specPatch} +``` + +Reject an empty map, approve, PATCH, and render the returned instance name. + +- [ ] **Step 4: Implement uninstall** + +Build `uninstall ` with aliases `delete,remove,rm,u`, exact one argument, instance completion, and confirmation. Delete only after approval and render `Instance "" uninstalled.` + +- [ ] **Step 5: Verify GREEN, coverage, gates, and commit** + +```text +nix develop -c go test ./cmd/connectivity/instances -v +nix develop -c go test ./cmd/connectivity/instances -coverprofile=/tmp/connectivity-instances.cover +nix develop -c go tool cover -func=/tmp/connectivity-instances.cover +nix develop -c just tests +nix develop -c just pre-commit +git add cmd/connectivity/instances +git commit -m "feat(connectivity): configure and uninstall instances" +``` + +--- + +### Task 7: Root wiring and final PR verification + +**Files:** +- Create: `cmd/connectivity/root.go` +- Test: `cmd/connectivity/root_test.go` +- Modify: `cmd/root.go` +- Modify: `completions/fctl.bash` +- Modify: `completions/fctl.zsh` +- Modify: `completions/fctl.fish` + +**Interfaces:** +- Consumes: plugin/instance roots and production dependencies. +- Produces: public `fctl connectivity` tree and shell scripts. + +- [ ] **Step 1: Write failing tree tests** + +```go +func TestCommandTreeMatchesConnectivityUX(t *testing.T) { + cmd := NewCommand() + require.Equal(t, "connectivity", cmd.Name()) + for _, path := range [][]string{{"plugins", "list"}, {"plugins", "show"}, {"instances", "list"}, {"instances", "show"}, {"instances", "install"}, {"instances", "configure"}, {"instances", "uninstall"}} { + found, _, err := cmd.Find(path) + require.NoError(t, err) + require.Equal(t, path[len(path)-1], found.Name()) + } + require.NotNil(t, cmd.PersistentFlags().Lookup("stack")) + require.NotNil(t, cmd.PersistentFlags().Lookup("organization")) +} +``` + +Add a `cmd` package test that `NewRootCommand().Find([]string{"connectivity"})` succeeds. + +- [ ] **Step 2: Verify RED** + +Run `nix develop -c go test ./cmd/connectivity ./cmd -run 'Test(CommandTree|RootCommand)' -v`. + +Expected: compilation or assertion failure because the root is not wired. + +- [ ] **Step 3: Implement and register root** + +```go +func NewCommand() *cobra.Command { + factory := connectivityinternal.NewClientFactory() + return fctl.NewStackCommand("connectivity", + fctl.WithShortDescription("Manage Connectivity plugins and instances"), + fctl.WithChildCommands( + plugins.NewCommand(factory), + instances.NewCommand(factory, fctl.ReadFile, instances.OSPathCompleter), + ), + ) +} +``` + +Import Connectivity in `cmd/root.go` and add it without moving existing children. + +- [ ] **Step 4: Format, inspect help, and regenerate completions** + +```text +nix develop -c gofmt -w internal/connectivityclient cmd/connectivity cmd/root.go +nix develop -c go run main.go connectivity --help +nix develop -c go run main.go connectivity plugins --help +nix develop -c go run main.go connectivity instances --help +nix develop -c just completions +``` + +Expected: help matches the approved hierarchy and all three scripts contain Connectivity commands and flags. + +- [ ] **Step 5: Run final verification** + +```text +nix develop -c just tests +nix develop -c go test -race ./internal/connectivityclient ./cmd/connectivity/... +nix develop -c go test -coverprofile=/tmp/connectivity-all.cover ./internal/connectivityclient ./cmd/connectivity/... +nix develop -c go tool cover -func=/tmp/connectivity-all.cover +nix develop -c just pre-commit +git diff --check +git status --short +``` + +Expected: all commands exit 0, race detector is clean, new code coverage is at least 80%, and no `cmd/payments` file changed. + +- [ ] **Step 6: Commit and prepare PR** + +```text +git add cmd/root.go cmd/connectivity/root.go cmd/connectivity/root_test.go completions/fctl.bash completions/fctl.zsh completions/fctl.fish docs/superpowers/plans/2026-08-07-connectivity-cli.md +git commit -m "feat(connectivity): expose fctl command tree" +``` + +PR title and body: + +```text +feat(connectivity): add plugin and instance commands + +- add a stack-authenticated fctl connectivity module +- expose plugin discovery and instance lifecycle workflows +- support schema-aware --set, --env-file, --config, and @file inputs +- add dynamic completion for plugins, instances, versions, schema keys, and paths + +Testing: +- nix develop -c just tests +- nix develop -c go test -race ./internal/connectivityclient ./cmd/connectivity/... +- nix develop -c just completions +- nix develop -c just pre-commit +``` diff --git a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md index c323580e..c7bda2eb 100644 --- a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md +++ b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md @@ -132,13 +132,20 @@ comments, and is repeatable. It represents environment entries only. values. A structured document may contain `env` and `files` matching the Connectivity API's `InstanceConfig` shape. -Values merge in this exact order, from lowest to highest precedence: +During install, values merge in this exact order, from lowest to highest +precedence: 1. plugin defaults; 2. `--config`; 3. `--env-file` values, with later files winning; 4. `--set` values, with later occurrences winning. +During configure, the instance's current configuration replaces plugin +defaults as the lowest-precedence base. This preserves every untouched env +entry and file mount. The command sends the resulting complete `config` object +inside the merge patch, so changing one file mount cannot accidentally replace +the other mounts in the API's array-valued `files` field. + The plugin's `configSchema` determines whether a key is an environment entry or a file mount. Required keys are validated before the write request. Invalid assignments, malformed references, unreadable files, unknown schema keys, and @@ -243,11 +250,13 @@ Verification runs inside the repository's Nix development shell: ```text nix develop -c just tests +nix develop -c just completions nix develop -c just pre-commit ``` -The second command is the repository's actual precommit recipe and includes -tidy, generation, linting, and completion generation. +The last command is the repository's actual precommit recipe and includes tidy, +generated API clients, and linting. Completion scripts are regenerated +explicitly because the precommit recipe does not include them. ## Non-goals From c1ffaeab354c82acb7080f940f560bbc01233c26 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:07:09 +0200 Subject: [PATCH 03/21] feat(connectivity): add API client --- internal/connectivityclient/client.go | 197 +++++++++++++++ internal/connectivityclient/client_test.go | 237 ++++++++++++++++++ .../connectivityclient/integration_test.go | 69 +++++ internal/connectivityclient/models.go | 143 +++++++++++ 4 files changed, 646 insertions(+) create mode 100644 internal/connectivityclient/client.go create mode 100644 internal/connectivityclient/client_test.go create mode 100644 internal/connectivityclient/integration_test.go create mode 100644 internal/connectivityclient/models.go diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go new file mode 100644 index 00000000..4437eb0c --- /dev/null +++ b/internal/connectivityclient/client.go @@ -0,0 +1,197 @@ +package connectivityclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const connectivityPath = "/api/connectivity" + +type client struct { + stackURI string + httpClient *http.Client +} + +func (e *APIError) Error() string { + return fmt.Sprintf("connectivity API error: status %d, code %s: %s", e.StatusCode, e.Code, e.Message) +} + +func (c *client) ListPlugins(ctx context.Context, options ListOptions) (*PluginList, error) { + result := &PluginList{} + if err := c.requestJSON(ctx, http.MethodGet, "plugins", "", listQuery(options), nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetPlugin(ctx context.Context, name string) (*Plugin, error) { + result := &Plugin{} + if err := c.requestJSON(ctx, http.MethodGet, "plugins", name, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) ListInstances(ctx context.Context, options ListOptions) (*InstanceList, error) { + result := &InstanceList{} + if err := c.requestJSON(ctx, http.MethodGet, "instances", "", listQuery(options), nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) CreateInstance(ctx context.Context, instance InstanceCreate) (*Instance, error) { + result := &Instance{} + if err := c.requestJSON(ctx, http.MethodPost, "instances", "", nil, instance, "application/json", http.StatusCreated, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetInstance(ctx context.Context, name string) (*Instance, error) { + result := &Instance{} + if err := c.requestJSON(ctx, http.MethodGet, "instances", name, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) PatchInstance(ctx context.Context, name string, patch InstancePatch) (*Instance, error) { + result := &Instance{} + if err := c.requestJSON(ctx, http.MethodPatch, "instances", name, nil, patch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) DeleteInstance(ctx context.Context, name string) error { + return c.requestJSON(ctx, http.MethodDelete, "instances", name, nil, nil, "", http.StatusNoContent, nil, false) +} + +func listQuery(options ListOptions) url.Values { + query := url.Values{} + if options.Limit != 0 { + query.Set("limit", strconv.FormatInt(int64(options.Limit), 10)) + } + if options.Continue != "" { + query.Set("continue", options.Continue) + } + if options.Plugin != "" { + query.Set("plugin", options.Plugin) + } + return query +} + +func (c *client) requestJSON(ctx context.Context, method, resource, name string, query url.Values, body any, contentType string, expectedStatus int, destination any, requireObject bool) error { + endpoint, err := endpointURL(c.stackURI, resource, name) + if err != nil { + return err + } + endpoint.RawQuery = query.Encode() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal connectivity request: %w", err) + } + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return fmt.Errorf("create connectivity request: %w", err) + } + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + httpClient := c.httpClient + if httpClient == nil { + httpClient = http.DefaultClient + } + response, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("send connectivity request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != expectedStatus { + return decodeAPIError(response) + } + if destination == nil { + return nil + } + return decodeResponse(response.Body, destination, requireObject) +} + +func endpointURL(stackURI, resource, name string) (*url.URL, error) { + endpoint, err := url.Parse(stackURI) + if err != nil { + return nil, fmt.Errorf("parse stack URI: %w", err) + } + if endpoint.Scheme == "" || endpoint.Host == "" { + return nil, fmt.Errorf("parse stack URI: expected absolute URI") + } + + basePath := strings.TrimSuffix(endpoint.Path, "/") + baseRawPath := strings.TrimSuffix(endpoint.EscapedPath(), "/") + if baseRawPath == "" { + baseRawPath = basePath + } + path := connectivityPath + "/" + resource + rawPath := path + if name != "" { + path += "/" + name + rawPath += "/" + url.PathEscape(name) + } + + endpoint.Path = basePath + path + endpoint.RawPath = baseRawPath + rawPath + endpoint.RawQuery = "" + endpoint.Fragment = "" + return endpoint, nil +} + +func decodeResponse(body io.Reader, destination any, requireObject bool) error { + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("read connectivity response: %w", err) + } + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return fmt.Errorf("decode connectivity response: empty response body") + } + if requireObject && (bytes.Equal(trimmed, []byte("{}")) || bytes.Equal(trimmed, []byte("null"))) { + return fmt.Errorf("decode connectivity response: empty object response") + } + if err := json.Unmarshal(trimmed, destination); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + return nil +} + +func decodeAPIError(response *http.Response) error { + payload := struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + }{} + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return &APIError{StatusCode: response.StatusCode, Message: response.Status} + } + return &APIError{ + StatusCode: response.StatusCode, + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + } +} diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go new file mode 100644 index 00000000..82303064 --- /dev/null +++ b/internal/connectivityclient/client_test.go @@ -0,0 +1,237 @@ +package connectivityclient + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestListPluginsBuildsStackConnectivityRequest(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"img"}}],"continue":"next"}`), nil + })} + + got, err := New("https://stack.example/base", httpClient).ListPlugins(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) + + require.NoError(t, err) + require.Equal(t, "/base/api/connectivity/plugins", seen.URL.Path) + require.Equal(t, "25", seen.URL.Query().Get("limit")) + require.Equal(t, "cursor", seen.URL.Query().Get("continue")) + require.Equal(t, "stripe", *got.Items[0].Metadata.Name) + require.Equal(t, "next", got.Continue) +} + +func TestClientMethodsRespectHTTPContracts(t *testing.T) { + pluginName := "stripe/primary" + instanceName := "worker/one" + + tests := []struct { + name string + wantMethod string + wantPath string + wantQuery map[string]string + wantContentType string + wantBody string + responseStatus int + responseBody string + call func(Client) error + }{ + { + name: "lists plugins", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/plugins", + wantQuery: map[string]string{"plugin": "stripe", "limit": "5", "continue": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"items":[]}`, + call: func(client Client) error { + _, err := client.ListPlugins(context.Background(), ListOptions{Plugin: "stripe", Limit: 5, Continue: "after"}) + return err + }, + }, + { + name: "gets a plugin using an escaped name", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/plugins/stripe%2Fprimary", + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"stripe/primary"},"spec":{"image":"image"}}`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), pluginName) + return err + }, + }, + { + name: "lists instances", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/instances", + wantQuery: map[string]string{"plugin": "stripe", "limit": "5", "continue": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"items":[]}`, + call: func(client Client) error { + _, err := client.ListInstances(context.Background(), ListOptions{Plugin: "stripe", Limit: 5, Continue: "after"}) + return err + }, + }, + { + name: "creates an instance", + wantMethod: http.MethodPost, + wantPath: "/base/api/connectivity/instances", + wantContentType: "application/json", + wantBody: `{"name":"worker","spec":{"plugin":"stripe","ledger":"ledger"}}`, + responseStatus: http.StatusCreated, + responseBody: `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.CreateInstance(context.Background(), InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "ledger"}}) + return err + }, + }, + { + name: "gets an instance using an escaped name", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/instances/worker%2Fone", + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.GetInstance(context.Background(), instanceName) + return err + }, + }, + { + name: "patches an instance", + wantMethod: http.MethodPatch, + wantPath: "/base/api/connectivity/instances/worker%2Fone", + wantContentType: "application/merge-patch+json", + wantBody: `{"spec":{"pollInterval":"1m"}}`, + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.PatchInstance(context.Background(), instanceName, InstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + return err + }, + }, + { + name: "deletes an instance", + wantMethod: http.MethodDelete, + wantPath: "/base/api/connectivity/instances/worker%2Fone", + responseStatus: http.StatusNoContent, + call: func(client Client) error { + return client.DeleteInstance(context.Background(), instanceName) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(tt.responseStatus, tt.responseBody), nil + })} + + err := tt.call(New("https://stack.example/base", httpClient)) + + require.NoError(t, err) + require.Equal(t, tt.wantMethod, seen.Method) + require.Equal(t, tt.wantPath, seen.URL.EscapedPath()) + require.Equal(t, "application/json", seen.Header.Get("Accept")) + for key, want := range tt.wantQuery { + require.Equal(t, want, seen.URL.Query().Get(key), "query %q", key) + } + if tt.wantContentType != "" { + require.Equal(t, tt.wantContentType, seen.Header.Get("Content-Type")) + } + if tt.wantBody != "" { + body, readErr := io.ReadAll(seen.Body) + require.NoError(t, readErr) + require.JSONEq(t, tt.wantBody, string(body)) + } + }) + } +} + +func TestClientReturnsStructuredAPIError(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadRequest, `{"code":"invalid","message":"plugin is required","details":{"field":"plugin"}}`), nil + })} + + _, err := New("https://stack.example", httpClient).GetPlugin(context.Background(), "missing") + + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + require.Equal(t, "invalid", apiErr.Code) + require.Equal(t, "plugin is required", apiErr.Message) + require.Equal(t, map[string]any{"field": "plugin"}, apiErr.Details) +} + +func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { + tests := []struct { + name string + body string + call func(Client) error + }{ + { + name: "malformed JSON", + body: `{`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "empty plugin object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "empty plugin list object", + body: `{}`, + call: func(client Client) error { + _, err := client.ListPlugins(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "empty instance list object", + body: "null", + call: func(client Client) error { + _, err := client.ListInstances(context.Background(), ListOptions{}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, tt.body), nil + })} + + err := tt.call(New("https://stack.example", httpClient)) + + require.Error(t, err) + }) + } +} diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go new file mode 100644 index 00000000..fb7dc10e --- /dev/null +++ b/internal/connectivityclient/integration_test.go @@ -0,0 +1,69 @@ +package connectivityclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected(t *testing.T) { + // Given a connectivity server with the public lifecycle routes. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/plugins": + require.Equal(t, "application/json", req.Header.Get("Accept")) + _, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"image"}}]}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/plugins/stripe": + _, _ = io.WriteString(w, `{"metadata":{"name":"stripe"},"spec":{"image":"image"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/instances": + _, _ = io.WriteString(w, `{"items":[]}`) + case req.Method == http.MethodPost && req.URL.Path == "/api/connectivity/instances": + require.Equal(t, "application/json", req.Header.Get("Content-Type")) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/instances/worker": + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodPatch && req.URL.Path == "/api/connectivity/instances/worker": + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodDelete && req.URL.Path == "/api/connectivity/instances/worker": + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, req) + } + })) + defer server.Close() + + client := New(server.URL, server.Client()) + ctx := context.Background() + + // When lifecycle methods use the server. + plugins, err := client.ListPlugins(ctx, ListOptions{}) + require.NoError(t, err) + plugin, err := client.GetPlugin(ctx, "stripe") + require.NoError(t, err) + instances, err := client.ListInstances(ctx, ListOptions{}) + require.NoError(t, err) + created, err := client.CreateInstance(ctx, InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "ledger"}}) + require.NoError(t, err) + fetched, err := client.GetInstance(ctx, "worker") + require.NoError(t, err) + patched, err := client.PatchInstance(ctx, "worker", InstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + require.NoError(t, err) + err = client.DeleteInstance(ctx, "worker") + require.NoError(t, err) + + // Then each response is decoded from the API contract. + require.Len(t, plugins.Items, 1) + require.Equal(t, "stripe", *plugins.Items[0].Metadata.Name) + require.Equal(t, "stripe", *plugin.Metadata.Name) + require.Empty(t, instances.Items) + require.Equal(t, "worker", *created.Metadata.Name) + require.Equal(t, "worker", *fetched.Metadata.Name) + require.Equal(t, "worker", *patched.Metadata.Name) +} diff --git a/internal/connectivityclient/models.go b/internal/connectivityclient/models.go new file mode 100644 index 00000000..60b775ef --- /dev/null +++ b/internal/connectivityclient/models.go @@ -0,0 +1,143 @@ +package connectivityclient + +import ( + "context" + "net/http" + "time" +) + +type ObjectMeta struct { + Name *string `json:"name,omitempty"` + Namespace *string `json:"namespace,omitempty"` + ResourceVersion *string `json:"resourceVersion,omitempty"` + UID *string `json:"uid,omitempty"` + CreationTimestamp *time.Time `json:"creationTimestamp,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type KeyRef struct { + Name string `json:"name"` + Key string `json:"key"` +} + +type EnvValue struct { + Value *string `json:"value,omitempty"` + SecretRef *KeyRef `json:"secretRef,omitempty"` + ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` +} + +type FileMount struct { + Path string `json:"path"` + Value *string `json:"value,omitempty"` + SecretRef *KeyRef `json:"secretRef,omitempty"` + ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` + Mode *int32 `json:"mode,omitempty"` +} + +type InstanceConfig struct { + Env map[string]EnvValue `json:"env,omitempty"` + Files []FileMount `json:"files,omitempty"` +} + +type VersionEntry struct { + Version string `json:"version"` + Digest *string `json:"digest,omitempty"` + Image *string `json:"image,omitempty"` +} + +type PluginSpec struct { + Image string `json:"image"` + Version *string `json:"version,omitempty"` + Description *string `json:"description,omitempty"` + DocsURL *string `json:"docsURL,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + ConfigSchema map[string]any `json:"configSchema,omitempty"` + DefaultVersion *string `json:"defaultVersion,omitempty"` + Versions []VersionEntry `json:"versions,omitempty"` + Defaults *InstanceConfig `json:"defaults,omitempty"` +} + +type PluginStatus struct { + Phase *string `json:"phase,omitempty"` + Message *string `json:"message,omitempty"` +} + +type Plugin struct { + Metadata ObjectMeta `json:"metadata"` + Spec PluginSpec `json:"spec"` + Status *PluginStatus `json:"status,omitempty"` +} + +type PluginList struct { + Items []Plugin `json:"items"` + Continue string `json:"continue,omitempty"` +} + +type InstanceSpec struct { + Plugin string `json:"plugin"` + Version *string `json:"version,omitempty"` + ConnectivityRef *string `json:"connectivityRef,omitempty"` + Ledger string `json:"ledger"` + StartSequence *int64 `json:"startSequence,omitempty"` + PollInterval *string `json:"pollInterval,omitempty"` + Config *InstanceConfig `json:"config,omitempty"` +} + +type InstanceStatus struct { + Phase *string `json:"phase,omitempty"` + State *string `json:"state,omitempty"` + PluginAddress *string `json:"pluginAddress,omitempty"` + ResolvedImage *string `json:"resolvedImage,omitempty"` + CurrentSequence *int64 `json:"currentSequence,omitempty"` + SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` + LastError *string `json:"lastError,omitempty"` + Message *string `json:"message,omitempty"` +} + +type Instance struct { + Metadata ObjectMeta `json:"metadata"` + Spec InstanceSpec `json:"spec"` + Status *InstanceStatus `json:"status,omitempty"` +} + +type InstanceList struct { + Items []Instance `json:"items"` + Continue string `json:"continue,omitempty"` +} + +type InstanceCreate struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Spec InstanceSpec `json:"spec"` +} + +type InstancePatch map[string]any + +type ListOptions struct { + Limit int32 + Continue string + Plugin string +} + +type APIError struct { + StatusCode int + Code string + Message string + Details map[string]any +} + +type Client interface { + ListPlugins(context.Context, ListOptions) (*PluginList, error) + GetPlugin(context.Context, string) (*Plugin, error) + ListInstances(context.Context, ListOptions) (*InstanceList, error) + CreateInstance(context.Context, InstanceCreate) (*Instance, error) + GetInstance(context.Context, string) (*Instance, error) + PatchInstance(context.Context, string, InstancePatch) (*Instance, error) + DeleteInstance(context.Context, string) error +} + +func New(stackURI string, httpClient *http.Client) Client { + return &client{stackURI: stackURI, httpClient: httpClient} +} From 524e5cd79932722ec0de00c0c191744b0175dabd Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:25:57 +0200 Subject: [PATCH 04/21] feat(connectivity): assemble instance configuration --- cmd/connectivity/instances/config.go | 598 ++++++++++++++++++ cmd/connectivity/instances/config_test.go | 328 ++++++++++ .../instances/test_helpers_test.go | 86 +++ 3 files changed, 1012 insertions(+) create mode 100644 cmd/connectivity/instances/config.go create mode 100644 cmd/connectivity/instances/config_test.go create mode 100644 cmd/connectivity/instances/test_helpers_test.go diff --git a/cmd/connectivity/instances/config.go b/cmd/connectivity/instances/config.go new file mode 100644 index 00000000..dcfc290b --- /dev/null +++ b/cmd/connectivity/instances/config.go @@ -0,0 +1,598 @@ +package instances + +import ( + "bufio" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type ConfigKind string + +const ( + ConfigEnv ConfigKind = "env" + ConfigFile ConfigKind = "file" +) + +type SchemaField struct { + Key string + Kind ConfigKind + Required bool + Password bool + Description string +} + +type InputOptions struct { + ConfigFile string + EnvFiles []string + SetValues []string +} + +type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) + +func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, error) { + if plugin == nil { + return nil, fmt.Errorf("plugin is required") + } + if len(plugin.Spec.ConfigSchema) == 0 { + return map[string]SchemaField{}, nil + } + + fields := make(map[string]SchemaField) + sections := []struct { + name string + kind ConfigKind + raw any + }{ + {name: "env", kind: ConfigEnv, raw: plugin.Spec.ConfigSchema["env"]}, + {name: "files", kind: ConfigFile, raw: plugin.Spec.ConfigSchema["files"]}, + } + if sections[0].raw == nil { + if _, hasLegacyProperties := plugin.Spec.ConfigSchema["properties"]; hasLegacyProperties { + sections[0].raw = plugin.Spec.ConfigSchema + } + } + + for _, section := range sections { + rawSection := section.raw + exists := rawSection != nil + if !exists { + continue + } + sectionSchema, ok := stringMap(rawSection) + if !ok { + return nil, fmt.Errorf("plugin config schema %s must be an object", section.name) + } + sectionProperties, ok := stringMap(sectionSchema["properties"]) + if !ok { + if sectionSchema["properties"] == nil { + sectionProperties = map[string]any{} + } else { + return nil, fmt.Errorf("plugin config schema %s properties must be an object", section.name) + } + } + required, err := requiredSet(sectionSchema["required"]) + if err != nil { + return nil, fmt.Errorf("plugin config schema %s: %w", section.name, err) + } + + for key, rawDefinition := range sectionProperties { + if _, exists := fields[key]; exists { + return nil, fmt.Errorf("configuration key %q is declared in both env and files", key) + } + definition, ok := stringMap(rawDefinition) + if !ok { + return nil, fmt.Errorf("plugin config schema field %q must be an object", key) + } + format, _ := definition["format"].(string) + legacySecret, _ := definition["x-secret"].(bool) + description, _ := definition["description"].(string) + fields[key] = SchemaField{ + Key: key, + Kind: section.kind, + Required: required[key], + Password: format == "password" || legacySecret, + Description: description, + } + } + } + + return fields, nil +} + +func BuildInstallConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { + var base *connectivityclient.InstanceConfig + if plugin != nil { + base = plugin.Spec.Defaults + } + return buildConfig(cmd, plugin, base, inputs, read) +} + +func BuildConfigureConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, current *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { + return buildConfig(cmd, plugin, current, inputs, read) +} + +func buildConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, base *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { + fields, err := SchemaFields(plugin) + if err != nil { + return nil, err + } + config := cloneConfig(base) + unknown := make(map[string]struct{}) + + if inputs.ConfigFile != "" { + contents, err := readInput(cmd, read, inputs.ConfigFile) + if err != nil { + return nil, fmt.Errorf("reading config %q: %w", inputs.ConfigFile, err) + } + if err := applyConfigDocument(config, fields, contents, unknown); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", inputs.ConfigFile, err) + } + } + + for _, path := range inputs.EnvFiles { + contents, err := readInput(cmd, read, path) + if err != nil { + return nil, fmt.Errorf("reading env file %q: %w", path, err) + } + if err := applyDotenv(config, fields, contents, unknown); err != nil { + return nil, fmt.Errorf("parsing env file %s:%w", path, err) + } + } + + for _, assignment := range inputs.SetValues { + key, value, err := parseAssignment(assignment) + if err != nil { + return nil, err + } + field, exists := fields[key] + if !exists { + unknown[key] = struct{}{} + continue + } + parsed, err := parseSetValue(cmd, read, value) + if err != nil { + return nil, fmt.Errorf("configuration key %q: %w", key, err) + } + applyValue(config, field, parsed) + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown configuration keys: %s", strings.Join(sortedKeys(unknown), ", ")) + } + + missing := make(map[string]struct{}) + for key, field := range fields { + if field.Required && !hasValue(config, field) { + missing[key] = struct{}{} + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required configuration keys: %s", strings.Join(sortedKeys(missing), ", ")) + } + + return config, nil +} + +type parsedValue struct { + value *string + secretRef *connectivityclient.KeyRef + configMapRef *connectivityclient.KeyRef +} + +type configDocument struct { + Env map[string]documentValue `yaml:"env"` + Files []documentFile `yaml:"files"` +} + +type documentValue struct { + Value *string `yaml:"value"` + SecretRef *documentRef `yaml:"secretRef"` + ConfigMapRef *documentRef `yaml:"configMapRef"` +} + +type documentRef struct { + Name string `yaml:"name"` + Key string `yaml:"key"` +} + +type documentFile struct { + Path string `yaml:"path"` + Value *string `yaml:"value"` + SecretRef *documentRef `yaml:"secretRef"` + ConfigMapRef *documentRef `yaml:"configMapRef"` + Mode *int32 `yaml:"mode"` +} + +func parseSetValue(cmd *cobra.Command, read ReadFileFunc, raw string) (parsedValue, error) { + if strings.HasPrefix(raw, "secret://") { + ref, err := parseReference(strings.TrimPrefix(raw, "secret://"), "secret") + return parsedValue{secretRef: ref}, err + } + if strings.HasPrefix(raw, "configmap://") { + ref, err := parseReference(strings.TrimPrefix(raw, "configmap://"), "configmap") + return parsedValue{configMapRef: ref}, err + } + if strings.HasPrefix(raw, "@") { + path := strings.TrimPrefix(raw, "@") + if path == "" { + return parsedValue{}, fmt.Errorf("file value path cannot be empty") + } + contents, err := readInput(cmd, read, path) + if err != nil { + return parsedValue{}, fmt.Errorf("reading value from %q: %w", path, err) + } + return parsedValue{value: stringPointer(contents)}, nil + } + return parsedValue{value: stringPointer(raw)}, nil +} + +func parseReference(raw, kind string) (*connectivityclient.KeyRef, error) { + if strings.Count(raw, "/") != 1 { + return nil, fmt.Errorf("malformed %s reference: expected %s://name/key", kind, kind) + } + parts := strings.SplitN(raw, "/", 2) + if parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("malformed %s reference: expected %s://name/key", kind, kind) + } + return &connectivityclient.KeyRef{Name: parts[0], Key: parts[1]}, nil +} + +func parseAssignment(raw string) (string, string, error) { + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid --set %q: expected KEY=value", raw) + } + key := strings.TrimSpace(parts[0]) + if key == "" { + return "", "", fmt.Errorf("invalid --set %q: empty key", raw) + } + return key, parts[1], nil +} + +func applyDotenv(config *connectivityclient.InstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { + scanner := bufio.NewScanner(strings.NewReader(contents)) + line := 0 + for scanner.Scan() { + line++ + raw := strings.TrimSpace(scanner.Text()) + if raw == "" || strings.HasPrefix(raw, "#") { + continue + } + raw = strings.TrimPrefix(raw, "export ") + equals := strings.IndexByte(raw, '=') + if equals <= 0 { + return fmt.Errorf("%d: expected KEY=value", line) + } + key := strings.TrimSpace(raw[:equals]) + if key == "" { + return fmt.Errorf("%d: expected KEY=value", line) + } + value := unquoteEnvValue(raw[equals+1:]) + field, exists := fields[key] + if !exists || field.Kind != ConfigEnv { + unknown[key] = struct{}{} + continue + } + applyValue(config, field, parsedValue{value: stringPointer(value)}) + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +func unquoteEnvValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 { + if value[0] == '"' && value[len(value)-1] == '"' { + return applyDoubleQuoteEscapes(value[1 : len(value)-1]) + } + if value[0] == '\'' && value[len(value)-1] == '\'' { + return value[1 : len(value)-1] + } + } + if comment := strings.IndexByte(value, '#'); comment >= 0 { + value = strings.TrimSpace(value[:comment]) + } + return value +} + +func applyDoubleQuoteEscapes(value string) string { + var decoded strings.Builder + decoded.Grow(len(value)) + for index := 0; index < len(value); index++ { + if value[index] == '\\' && index+1 < len(value) { + index++ + switch value[index] { + case 'n': + decoded.WriteByte('\n') + case 't': + decoded.WriteByte('\t') + case 'r': + decoded.WriteByte('\r') + case '\\': + decoded.WriteByte('\\') + case '"': + decoded.WriteByte('"') + default: + decoded.WriteByte(value[index]) + } + continue + } + decoded.WriteByte(value[index]) + } + return decoded.String() +} + +func applyConfigDocument(config *connectivityclient.InstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { + var root map[string]any + if err := yaml.Unmarshal([]byte(contents), &root); err != nil { + return err + } + if root == nil { + return nil + } + + _, hasEnv := root["env"] + _, hasFiles := root["files"] + if !hasEnv && !hasFiles { + for key, raw := range root { + field, exists := fields[key] + if !exists || field.Kind != ConfigEnv { + unknown[key] = struct{}{} + continue + } + value, err := scalarString(raw) + if err != nil { + return fmt.Errorf("configuration key %q: %w", key, err) + } + applyValue(config, field, parsedValue{value: stringPointer(value)}) + } + return nil + } + + for key := range root { + if key != "env" && key != "files" { + unknown[key] = struct{}{} + } + } + var document configDocument + if err := yaml.Unmarshal([]byte(contents), &document); err != nil { + return err + } + for key, value := range document.Env { + field, exists := fields[key] + if !exists || field.Kind != ConfigEnv { + unknown[key] = struct{}{} + continue + } + converted := connectivityclient.EnvValue{ + Value: cloneString(value.Value), + SecretRef: value.SecretRef.clientRef(), + ConfigMapRef: value.ConfigMapRef.clientRef(), + } + if err := validateSources(converted.Value, converted.SecretRef, converted.ConfigMapRef); err != nil { + return fmt.Errorf("configuration key %q: %w", key, err) + } + config.Env[key] = converted + } + for _, file := range document.Files { + if strings.TrimSpace(file.Path) == "" { + return fmt.Errorf("structured configuration file path is required") + } + field, exists := fields[file.Path] + if !exists || field.Kind != ConfigFile { + unknown[file.Path] = struct{}{} + continue + } + converted := connectivityclient.FileMount{ + Path: file.Path, + Value: cloneString(file.Value), + SecretRef: file.SecretRef.clientRef(), + ConfigMapRef: file.ConfigMapRef.clientRef(), + Mode: cloneMode(file.Mode), + } + if err := validateSources(converted.Value, converted.SecretRef, converted.ConfigMapRef); err != nil { + return fmt.Errorf("configuration key %q: %w", file.Path, err) + } + replaceFile(config, converted) + } + return nil +} + +func (ref *documentRef) clientRef() *connectivityclient.KeyRef { + if ref == nil { + return nil + } + return &connectivityclient.KeyRef{Name: ref.Name, Key: ref.Key} +} + +func validateSources(value *string, secretRef, configMapRef *connectivityclient.KeyRef) error { + count := 0 + if value != nil { + count++ + } + if secretRef != nil { + count++ + if secretRef.Name == "" || secretRef.Key == "" { + return fmt.Errorf("secret reference requires name and key") + } + } + if configMapRef != nil { + count++ + if configMapRef.Name == "" || configMapRef.Key == "" { + return fmt.Errorf("configmap reference requires name and key") + } + } + if count != 1 { + return fmt.Errorf("exactly one of value, secretRef, or configMapRef is required") + } + return nil +} + +func applyValue(config *connectivityclient.InstanceConfig, field SchemaField, value parsedValue) { + if field.Kind == ConfigEnv { + config.Env[field.Key] = connectivityclient.EnvValue{ + Value: value.value, + SecretRef: value.secretRef, + ConfigMapRef: value.configMapRef, + } + return + } + replaceFile(config, connectivityclient.FileMount{ + Path: field.Key, + Value: value.value, + SecretRef: value.secretRef, + ConfigMapRef: value.configMapRef, + }) +} + +func replaceFile(config *connectivityclient.InstanceConfig, replacement connectivityclient.FileMount) { + for index := range config.Files { + if config.Files[index].Path == replacement.Path { + if replacement.Mode == nil { + replacement.Mode = cloneMode(config.Files[index].Mode) + } + config.Files[index] = replacement + return + } + } + config.Files = append(config.Files, replacement) +} + +func hasValue(config *connectivityclient.InstanceConfig, field SchemaField) bool { + if field.Kind == ConfigEnv { + value, exists := config.Env[field.Key] + return exists && validateSources(value.Value, value.SecretRef, value.ConfigMapRef) == nil + } + for _, file := range config.Files { + if file.Path == field.Key { + return validateSources(file.Value, file.SecretRef, file.ConfigMapRef) == nil + } + } + return false +} + +func cloneConfig(source *connectivityclient.InstanceConfig) *connectivityclient.InstanceConfig { + clone := &connectivityclient.InstanceConfig{Env: make(map[string]connectivityclient.EnvValue)} + if source == nil { + return clone + } + for key, value := range source.Env { + clone.Env[key] = cloneEnvValue(value) + } + clone.Files = make([]connectivityclient.FileMount, len(source.Files)) + for index, file := range source.Files { + clone.Files[index] = cloneFileMount(file) + } + return clone +} + +func cloneEnvValue(value connectivityclient.EnvValue) connectivityclient.EnvValue { + return connectivityclient.EnvValue{ + Value: cloneString(value.Value), + SecretRef: cloneRef(value.SecretRef), + ConfigMapRef: cloneRef(value.ConfigMapRef), + } +} + +func cloneFileMount(file connectivityclient.FileMount) connectivityclient.FileMount { + clone := connectivityclient.FileMount{ + Path: file.Path, + Value: cloneString(file.Value), + SecretRef: cloneRef(file.SecretRef), + ConfigMapRef: cloneRef(file.ConfigMapRef), + } + clone.Mode = cloneMode(file.Mode) + return clone +} + +func cloneMode(value *int32) *int32 { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + return stringPointer(*value) +} + +func cloneRef(ref *connectivityclient.KeyRef) *connectivityclient.KeyRef { + if ref == nil { + return nil + } + return &connectivityclient.KeyRef{Name: ref.Name, Key: ref.Key} +} + +func readInput(cmd *cobra.Command, read ReadFileFunc, path string) (string, error) { + if read == nil { + return "", fmt.Errorf("file reader is required") + } + return read(cmd, path) +} + +func requiredSet(raw any) (map[string]bool, error) { + set := make(map[string]bool) + if raw == nil { + return set, nil + } + switch values := raw.(type) { + case []any: + for _, rawValue := range values { + value, ok := rawValue.(string) + if !ok { + return nil, fmt.Errorf("required entries must be strings") + } + set[value] = true + } + case []string: + for _, value := range values { + set[value] = true + } + default: + return nil, fmt.Errorf("required must be an array") + } + return set, nil +} + +func stringMap(raw any) (map[string]any, bool) { + value, ok := raw.(map[string]any) + return value, ok +} + +func scalarString(raw any) (string, error) { + switch value := raw.(type) { + case string: + return value, nil + case nil: + return "", nil + case bool, int, int64, uint64, float64: + return fmt.Sprint(value), nil + default: + return "", fmt.Errorf("flat configuration values must be scalars") + } +} + +func stringPointer(value string) *string { + return &value +} + +func sortedKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/connectivity/instances/config_test.go b/cmd/connectivity/instances/config_test.go new file mode 100644 index 00000000..19523324 --- /dev/null +++ b/cmd/connectivity/instances/config_test.go @@ -0,0 +1,328 @@ +package instances + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestSchemaFieldsExtractsEnvironmentAndFileMetadata(t *testing.T) { + fields, err := SchemaFields(pluginWithSchemaAndDefaults()) + + require.NoError(t, err) + require.Equal(t, SchemaField{ + Key: "TOKEN", + Kind: ConfigEnv, + Required: true, + Password: true, + Description: "Authentication token", + }, fields["TOKEN"]) + require.Equal(t, SchemaField{ + Key: "/etc/plugin/config.yaml", + Kind: ConfigFile, + Required: true, + Description: "Plugin configuration", + }, fields["/etc/plugin/config.yaml"]) + require.False(t, fields["TIMEOUT"].Required) + require.True(t, fields["/etc/plugin/ca.pem"].Password) +} + +func TestSchemaFieldsRejectsAmbiguousAndMalformedSchemas(t *testing.T) { + tests := []struct { + name string + schema map[string]any + want string + }{ + { + name: "malformed env section", + schema: map[string]any{"env": "not-an-object"}, + want: "env", + }, + { + name: "duplicate env and file key", + schema: map[string]any{ + "env": map[string]any{"properties": map[string]any{"SAME": map[string]any{}}}, + "files": map[string]any{"properties": map[string]any{"SAME": map[string]any{}}}, + }, + want: "SAME", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ConfigSchema: tt.schema}} + + _, err := SchemaFields(plugin) + + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { + plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ConfigSchema: map[string]any{ + "type": "object", + "required": []any{"LEGACY_TOKEN"}, + "properties": map[string]any{ + "LEGACY_TOKEN": map[string]any{ + "type": "string", + "x-secret": true, + "description": "Legacy token", + }, + }, + }}} + + fields, err := SchemaFields(plugin) + + require.NoError(t, err) + require.Equal(t, SchemaField{ + Key: "LEGACY_TOKEN", + Kind: ConfigEnv, + Required: true, + Password: true, + Description: "Legacy token", + }, fields["LEGACY_TOKEN"]) +} + +func TestBuildInstallConfigAllowsPluginWithoutSchema(t *testing.T) { + plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{Defaults: &connectivityclient.InstanceConfig{ + Env: map[string]connectivityclient.EnvValue{"SERVER_DEFAULT": {Value: stringPtr("kept")}}, + }}} + + got, err := BuildInstallConfig(&cobra.Command{}, plugin, InputOptions{}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "kept", *got.Env["SERVER_DEFAULT"].Value) +} + +func TestBuildInstallConfigParsesFlatYAMLAsEnvironmentValues(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": "API_URL: https://config.example\nTOKEN: config-token\nTIMEOUT: 45s\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://config.example", *got.Env["API_URL"].Value) + require.Equal(t, "config-token", *got.Env["TOKEN"].Value) + require.Equal(t, "45s", *got.Env["TIMEOUT"].Value) +} + +func TestBuildInstallConfigParsesStructuredYAML(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": `env: + API_URL: + value: https://structured.example + TOKEN: + secretRef: + name: plugin-secrets + key: token +files: + - path: /etc/plugin/config.yaml + configMapRef: + name: plugin-config + key: config.yaml + - path: /etc/plugin/ca.pem + value: structured-ca +`, + })) + + require.NoError(t, err) + require.Equal(t, "https://structured.example", *got.Env["API_URL"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Nil(t, got.Env["TOKEN"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Equal(t, "structured-ca", *got.Files[1].Value) +} + +func TestBuildInstallConfigParsesDotenvFilesInOrder(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"first.env", "second.env"}}, mapReadFile(map[string]string{ + "first.env": "# comment\n\nAPI_URL=https://first.example\nTOKEN=first=token\n", + "second.env": "API_URL=https://second.example\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://second.example", *got.Env["API_URL"].Value) + require.Equal(t, "first=token", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigParsesEstablishedDotenvSyntax(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": ` export API_URL = https://env.example # endpoint +TOKEN="line-one\nline-two\t\"quoted\"\\tail" +TIMEOUT=' literal # value ' +`, + })) + + require.NoError(t, err) + require.Equal(t, "https://env.example", *got.Env["API_URL"].Value) + require.Equal(t, "line-one\nline-two\t\"quoted\"\\tail", *got.Env["TOKEN"].Value) + require.Equal(t, " literal # value ", *got.Env["TIMEOUT"].Value) +} + +func TestBuildInstallConfigAppliesDocumentedSourcePrecedence(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ + ConfigFile: "config.yaml", + EnvFiles: []string{"first.env", "second.env"}, + SetValues: []string{"API_URL=https://set-one.example", "API_URL=https://set-two.example"}, + }, mapReadFile(map[string]string{ + "config.yaml": "API_URL: https://config.example\n", + "first.env": "API_URL=https://env-one.example\n", + "second.env": "API_URL=https://env-two.example\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://set-two.example", *got.Env["API_URL"].Value) + require.Equal(t, "default-token", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigReadsInlineFileAndStdinSetValues(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + "TIMEOUT=90s", + "TOKEN=@token.txt", + "/etc/plugin/config.yaml=@-", + }}, mapReadFile(map[string]string{ + "token.txt": "file-token\n", + "-": "stdin-config\n", + })) + + require.NoError(t, err) + require.Equal(t, "90s", *got.Env["TIMEOUT"].Value) + require.Equal(t, "file-token\n", *got.Env["TOKEN"].Value) + require.Equal(t, "stdin-config\n", *got.Files[0].Value) +} + +func TestBuildInstallConfigBuildsSecretAndConfigMapReferences(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + "TOKEN=secret://plugin-secrets/token", + "/etc/plugin/config.yaml=configmap://plugin-config/config.yaml", + }}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Nil(t, got.Env["TOKEN"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Nil(t, got.Files[0].Value) +} + +func TestBuildInstallConfigRoutesAssignmentsUsingSchema(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + "API_URL=https://routed.example", + "/etc/plugin/ca.pem=routed-ca", + }}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "https://routed.example", *got.Env["API_URL"].Value) + require.Equal(t, "routed-ca", *got.Files[1].Value) +} + +func TestBuildInstallConfigReportsSortedMissingRequiredKeys(t *testing.T) { + plugin := pluginWithSchemaAndDefaults() + plugin.Spec.Defaults = nil + + _, err := BuildInstallConfig(&cobra.Command{}, plugin, InputOptions{}, mapReadFile(nil)) + + require.EqualError(t, err, "missing required configuration keys: /etc/plugin/config.yaml, API_URL, TOKEN") +} + +func TestBuildInstallConfigReportsSortedUnknownKeys(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + "WRONG=value", + "BOGUS=value", + }}, mapReadFile(nil)) + + require.EqualError(t, err, "unknown configuration keys: BOGUS, WRONG") +} + +func TestBuildInstallConfigRejectsMalformedAssignmentsAndReferences(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "assignment without equals", value: "TOKEN", want: "KEY=value"}, + {name: "empty key", value: "=value", want: "empty key"}, + {name: "secret without key", value: "TOKEN=secret://name", want: "secret reference"}, + {name: "secret without name", value: "TOKEN=secret:///key", want: "secret reference"}, + {name: "configmap without key", value: "TOKEN=configmap://name/", want: "configmap reference"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{tt.value}}, mapReadFile(nil)) + + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestBuildInstallConfigReturnsInputReadAndParseErrors(t *testing.T) { + t.Run("unreadable config", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "missing.yaml"}, mapReadFile(nil)) + + require.ErrorContains(t, err, "missing.yaml") + }) + + t.Run("malformed config", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{"config.yaml": "env: ["})) + + require.ErrorContains(t, err, "config.yaml") + }) + + t.Run("malformed dotenv", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"bad.env"}}, mapReadFile(map[string]string{"bad.env": "TOKEN\n"})) + + require.ErrorContains(t, err, "bad.env:1") + }) +} + +func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { + mode := int32(0o600) + current := &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("a"), Mode: &mode}, + {Path: "/etc/b", Value: stringPtr("b")}, + }} + got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, + InputOptions{SetValues: []string{"/etc/a=@new"}}, + func(_ *cobra.Command, name string) (string, error) { + require.Equal(t, "new", name) + return "changed", nil + }) + + require.NoError(t, err) + require.Len(t, got.Files, 2) + require.Equal(t, "changed", *got.Files[0].Value) + require.NotNil(t, got.Files[0].Mode) + require.Equal(t, int32(0o600), *got.Files[0].Mode) + require.Equal(t, "b", *got.Files[1].Value) +} + +func TestBuildInstallConfigRejectsStructuredFileWithoutPath(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": "files:\n - value: missing-path\n", + })) + + require.ErrorContains(t, err, "file path is required") +} + +func TestBuildConfigureConfigDeepCopiesCurrentConfiguration(t *testing.T) { + current := &connectivityclient.InstanceConfig{ + Env: map[string]connectivityclient.EnvValue{ + "API_URL": {Value: stringPtr("https://current.example")}, + }, + Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("current-a")}, + }, + } + + got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, InputOptions{ + SetValues: []string{"API_URL=https://changed.example", "/etc/a=changed-a"}, + }, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "https://changed.example", *got.Env["API_URL"].Value) + require.Equal(t, "changed-a", *got.Files[0].Value) + require.Equal(t, "https://current.example", *current.Env["API_URL"].Value) + require.Equal(t, "current-a", *current.Files[0].Value) +} diff --git a/cmd/connectivity/instances/test_helpers_test.go b/cmd/connectivity/instances/test_helpers_test.go new file mode 100644 index 00000000..e1149c64 --- /dev/null +++ b/cmd/connectivity/instances/test_helpers_test.go @@ -0,0 +1,86 @@ +package instances + +import ( + "fmt" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func stringPtr(value string) *string { + return &value +} + +func mapReadFile(files map[string]string) ReadFileFunc { + return func(_ *cobra.Command, name string) (string, error) { + value, ok := files[name] + if !ok { + return "", fmt.Errorf("test file %q not found", name) + } + return value, nil + } +} + +func pluginWithFileSchema() *connectivityclient.Plugin { + return &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ + ConfigSchema: map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + }, + "required": []any{}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{ + "/etc/a": map[string]any{"type": "string", "description": "Primary config"}, + "/etc/b": map[string]any{"type": "string", "format": "password"}, + }, + "required": []any{"/etc/a"}, + }, + }, + Versions: []connectivityclient.VersionEntry{ + {Version: "1.0.0", Image: stringPtr("example/plugin:1.0.0")}, + {Version: "2.0.0", Image: stringPtr("example/plugin:2.0.0")}, + }, + }} +} + +func pluginWithSchemaAndDefaults() *connectivityclient.Plugin { + plugin := pluginWithFileSchema() + plugin.Spec.ConfigSchema = map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + "TOKEN": map[string]any{"type": "string", "format": "password", "description": "Authentication token"}, + "TIMEOUT": map[string]any{"type": "string"}, + }, + "required": []any{"API_URL", "TOKEN"}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{ + "/etc/plugin/config.yaml": map[string]any{"type": "string", "description": "Plugin configuration"}, + "/etc/plugin/ca.pem": map[string]any{"type": "string", "format": "password"}, + }, + "required": []any{"/etc/plugin/config.yaml"}, + }, + } + plugin.Spec.Defaults = &connectivityclient.InstanceConfig{ + Env: map[string]connectivityclient.EnvValue{ + "API_URL": {Value: stringPtr("https://default.example")}, + "TOKEN": {Value: stringPtr("default-token")}, + "TIMEOUT": {Value: stringPtr("30s")}, + }, + Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", Value: stringPtr("default: true\n")}, + {Path: "/etc/plugin/ca.pem", Value: stringPtr("default-ca")}, + }, + } + return plugin +} From cf563477a82f2214b92e774b489d6ae8332dcf50 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:31:07 +0200 Subject: [PATCH 05/21] fix(connectivity): preserve dotenv values --- cmd/connectivity/instances/config.go | 31 ++++++++++++++++++++--- cmd/connectivity/instances/config_test.go | 18 +++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/cmd/connectivity/instances/config.go b/cmd/connectivity/instances/config.go index dcfc290b..6bf52b02 100644 --- a/cmd/connectivity/instances/config.go +++ b/cmd/connectivity/instances/config.go @@ -289,6 +289,9 @@ func applyDotenv(config *connectivityclient.InstanceConfig, fields map[string]Sc func unquoteEnvValue(value string) string { value = strings.TrimSpace(value) + if comment := dotenvCommentIndex(value); comment >= 0 { + value = strings.TrimSpace(value[:comment]) + } if len(value) >= 2 { if value[0] == '"' && value[len(value)-1] == '"' { return applyDoubleQuoteEscapes(value[1 : len(value)-1]) @@ -297,12 +300,34 @@ func unquoteEnvValue(value string) string { return value[1 : len(value)-1] } } - if comment := strings.IndexByte(value, '#'); comment >= 0 { - value = strings.TrimSpace(value[:comment]) - } return value } +func dotenvCommentIndex(value string) int { + var quote byte + for index := 0; index < len(value); index++ { + character := value[index] + if quote != 0 { + if quote == '"' && character == '\\' && index+1 < len(value) { + index++ + continue + } + if character == quote { + quote = 0 + } + continue + } + if character == '"' || character == '\'' { + quote = character + continue + } + if character == '#' && (index == 0 || value[index-1] == ' ' || value[index-1] == '\t') { + return index + } + } + return -1 +} + func applyDoubleQuoteEscapes(value string) string { var decoded strings.Builder decoded.Grow(len(value)) diff --git a/cmd/connectivity/instances/config_test.go b/cmd/connectivity/instances/config_test.go index 19523324..8d37a5e4 100644 --- a/cmd/connectivity/instances/config_test.go +++ b/cmd/connectivity/instances/config_test.go @@ -161,6 +161,24 @@ TIMEOUT=' literal # value ' require.Equal(t, " literal # value ", *got.Env["TIMEOUT"].Value) } +func TestBuildInstallConfigUnquotesDotenvValueBeforeTrailingComment(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": "TOKEN=\"abc#def\" # trailing comment\n", + })) + + require.NoError(t, err) + require.Equal(t, "abc#def", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigPreservesHashWithinUnquotedDotenvValue(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": "TOKEN=abc#def\n", + })) + + require.NoError(t, err) + require.Equal(t, "abc#def", *got.Env["TOKEN"].Value) +} + func TestBuildInstallConfigAppliesDocumentedSourcePrecedence(t *testing.T) { got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ ConfigFile: "config.yaml", From e835df0d194251bbf7a9c7f6f5f2d4d5256d9dd8 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:38:33 +0200 Subject: [PATCH 06/21] feat(connectivity): add plugin catalog commands --- cmd/connectivity/internal/client.go | 32 +++ cmd/connectivity/plugins/completion.go | 57 +++++ cmd/connectivity/plugins/completion_test.go | 92 +++++++ cmd/connectivity/plugins/list.go | 122 ++++++++++ cmd/connectivity/plugins/list_test.go | 131 ++++++++++ cmd/connectivity/plugins/root.go | 20 ++ cmd/connectivity/plugins/show.go | 227 ++++++++++++++++++ cmd/connectivity/plugins/show_test.go | 163 +++++++++++++ cmd/connectivity/plugins/test_helpers_test.go | 54 +++++ 9 files changed, 898 insertions(+) create mode 100644 cmd/connectivity/internal/client.go create mode 100644 cmd/connectivity/plugins/completion.go create mode 100644 cmd/connectivity/plugins/completion_test.go create mode 100644 cmd/connectivity/plugins/list.go create mode 100644 cmd/connectivity/plugins/list_test.go create mode 100644 cmd/connectivity/plugins/root.go create mode 100644 cmd/connectivity/plugins/show.go create mode 100644 cmd/connectivity/plugins/show_test.go create mode 100644 cmd/connectivity/plugins/test_helpers_test.go diff --git a/cmd/connectivity/internal/client.go b/cmd/connectivity/internal/client.go new file mode 100644 index 00000000..b697ad16 --- /dev/null +++ b/cmd/connectivity/internal/client.go @@ -0,0 +1,32 @@ +package internal + +import ( + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ClientFactory func(*cobra.Command) (connectivityclient.Client, error) + +func NewClientFactory() ClientFactory { + return func(cmd *cobra.Command) (connectivityclient.Client, error) { + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + clients, err := fctl.NewStackClientsFromFlags( + cmd, + relyingParty, + fctl.NewPTermDialog(), + profileName, + *profile, + ) + if err != nil { + return nil, err + } + + return connectivityclient.New(clients.URI, clients.HTTPClient), nil + } +} diff --git a/cmd/connectivity/plugins/completion.go b/cmd/connectivity/plugins/completion.go new file mode 100644 index 00000000..5f73bf08 --- /dev/null +++ b/cmd/connectivity/plugins/completion.go @@ -0,0 +1,57 @@ +package plugins + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const ( + pluginCompletionLimit = int32(500) + pluginCompletionTimeout = 2 * time.Second +) + +func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if factory == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, pluginCompletionTimeout) + defer cancel() + + completionCommand := *cmd + completionCommand.SetContext(ctx) + client, err := factory(&completionCommand) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + response, err := client.ListPlugins(ctx, connectivityclient.ListOptions{Limit: pluginCompletionLimit}) + if err != nil || response == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + candidates := make([]string, 0, len(response.Items)) + for _, plugin := range response.Items { + name := stringValue(plugin.Metadata.Name) + if name == "" || !strings.HasPrefix(name, toComplete) { + continue + } + if description := stringValue(plugin.Spec.Description); description != "" { + name += "\t" + description + } + candidates = append(candidates, name) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/cmd/connectivity/plugins/completion_test.go b/cmd/connectivity/plugins/completion_test.go new file mode 100644 index 00000000..69fd7705 --- /dev/null +++ b/cmd/connectivity/plugins/completion_test.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestCompletePluginNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDescriptions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + var remaining time.Duration + client := pluginClientMock{list: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + gotOptions = options + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("completion context has no deadline") + } + remaining = time.Until(deadline) + return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{ + pluginFixture("alpha"), pluginFixture("beta"), + }}, nil + }} + + completion := CompletePluginNames(factoryReturning(client)) + candidates, directive := completion(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { + t.Fatalf("ListPlugins options = %#v, want limit 500", gotOptions) + } + if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { + t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) + } + if !reflect.DeepEqual(candidates, []string{"alpha\tPlugin description"}) { + t.Fatalf("candidates = %#v, want prefix match with description", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompletePluginNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testing.T) { + tests := map[string]struct { + command *cobra.Command + factory func(*cobra.Command) (connectivityclient.Client, error) + }{ + "authentication": { + command: &cobra.Command{}, + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("not authenticated") + }, + }, + "API": { + command: &cobra.Command{}, + factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return nil, errors.New("unsupported deployment") + }}), + }, + "timeout": { + command: commandWithExpiredContext(), + factory: factoryReturning(pluginClientMock{list: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + <-ctx.Done() + return nil, ctx.Err() + }}), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + candidates, directive := CompletePluginNames(test.factory)(test.command, nil, "") + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} + +func commandWithExpiredContext() *cobra.Command { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + command := &cobra.Command{} + command.SetContext(ctx) + return command +} diff --git a/cmd/connectivity/plugins/list.go b/cmd/connectivity/plugins/list.go new file mode 100644 index 00000000..3875ec34 --- /dev/null +++ b/cmd/connectivity/plugins/list.go @@ -0,0 +1,122 @@ +package plugins + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ListStore struct { + Plugins []connectivityclient.Plugin `json:"plugins"` + Cursor fctl.Cursor `json:"cursor"` +} + +type ListController struct { + factory connectivityinternal.ClientFactory + store *ListStore +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewListController(factory connectivityinternal.ClientFactory) *ListController { + return &ListController{ + factory: factory, + store: &ListStore{Plugins: []connectivityclient.Plugin{}}, + } +} + +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewListController(factory) + return fctl.NewCommand( + "list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List available Connectivity plugins"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithPageSizeFlag(), + fctl.WithCursorFlag(), + fctl.WithController[*ListStore](controller), + ) +} + +func (c *ListController) GetStore() *ListStore { + return c.store +} + +func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + + response, err := client.ListPlugins(cmd.Context(), connectivityclient.ListOptions{ + Limit: pageSize, + Continue: cursor, + }) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("list connectivity plugins: empty response") + } + + c.store.Plugins = response.Items + c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} + if response.Continue != "" { + c.store.Cursor.HasMore = true + c.store.Cursor.Next = fctl.Ptr(response.Continue) + } + return c, nil +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + rows := fctl.Map(c.store.Plugins, func(plugin connectivityclient.Plugin) []string { + return []string{ + stringValue(plugin.Metadata.Name), + stringValue(plugin.Spec.DefaultVersion), + stringValue(plugin.Spec.Description), + strings.Join(plugin.Spec.Capabilities, ", "), + pluginPhase(plugin.Status), + } + }) + rows = fctl.Prepend(rows, []string{"Name", "Default Version", "Description", "Capabilities", "Phase"}) + if err := pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(rows). + Render(); err != nil { + return err + } + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func pluginPhase(status *connectivityclient.PluginStatus) string { + if status == nil { + return "" + } + return stringValue(status.Phase) +} diff --git a/cmd/connectivity/plugins/list_test.go b/cmd/connectivity/plugins/list_test.go new file mode 100644 index 00000000..3d9e96a9 --- /dev/null +++ b/cmd/connectivity/plugins/list_test.go @@ -0,0 +1,131 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func TestListPluginsPassesPaginationAndRendersApprovedColumnsAndContinuation(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := pluginClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + gotOptions = options + return &connectivityclient.PluginList{ + Items: []connectivityclient.Plugin{pluginFixture("stripe")}, + Continue: "next-page", + }, nil + }} + + command := NewListCommand(factoryReturning(client)) + output, err := executeCommand(command, "--page-size", "7", "--cursor", "current-page") + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + wantOptions := connectivityclient.ListOptions{Limit: 7, Continue: "current-page"} + if !reflect.DeepEqual(gotOptions, wantOptions) { + t.Fatalf("ListPlugins options = %#v, want %#v", gotOptions, wantOptions) + } + for _, expected := range []string{ + "Name", "Default Version", "Description", "Capabilities", "Phase", + "stripe", "2.0.0", "Plugin description", "payments, webhooks", "Ready", + "HasMore", "true", "PageSize", "7", "Next", "next-page", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } +} + +func TestListPluginsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { + plugin := pluginFixture("stripe") + plugin.Metadata.Labels = map[string]string{"region": "eu"} + plugin.Spec.ConfigSchema = map[string]any{"type": "object"} + client := pluginClientMock{list: func(_ context.Context, _ connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{plugin}, Continue: "next-page"}, nil + }} + + command := NewListCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json", "--page-size", "4") + if err != nil { + t.Fatalf("execute JSON list command: %v", err) + } + + var envelope struct { + Data ListStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if !reflect.DeepEqual(envelope.Data.Plugins, []connectivityclient.Plugin{plugin}) { + t.Fatalf("JSON plugins = %#v, want complete model %#v", envelope.Data.Plugins, plugin) + } + if !envelope.Data.Cursor.HasMore || envelope.Data.Cursor.PageSize != 4 || envelope.Data.Cursor.Next == nil || *envelope.Data.Cursor.Next != "next-page" { + t.Fatalf("JSON cursor = %#v, want continuation and requested page size", envelope.Data.Cursor) + } +} + +func TestPluginRootRegistersApprovedCommandsAndAliases(t *testing.T) { + command := NewCommand(factoryReturning(pluginClientMock{})) + if command.Use != "plugins" || !reflect.DeepEqual(command.Aliases, []string{"plugin", "p"}) { + t.Fatalf("plugin root = %q aliases %v", command.Use, command.Aliases) + } + + wantAliases := map[string][]string{ + "list": {"ls", "l"}, + "show": {"get", "g", "sh", "s"}, + } + for name, aliases := range wantAliases { + child, _, err := command.Find([]string{name}) + if err != nil { + t.Fatalf("find %s command: %v", name, err) + } + if child.Name() != name || !reflect.DeepEqual(child.Aliases, aliases) { + t.Errorf("%s aliases = %v, want %v", name, child.Aliases, aliases) + } + } +} + +func TestListPluginsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return nil, errors.New("catalog unavailable") + }}), + want: "catalog unavailable", + }, + "empty response": { + factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewListCommand(test.factory)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} diff --git a/cmd/connectivity/plugins/root.go b/cmd/connectivity/plugins/root.go new file mode 100644 index 00000000..2bfc9e43 --- /dev/null +++ b/cmd/connectivity/plugins/root.go @@ -0,0 +1,20 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return fctl.NewCommand( + "plugins", + fctl.WithAliases("plugin", "p"), + fctl.WithShortDescription("Browse Connectivity plugins"), + fctl.WithChildCommands( + NewListCommand(factory), + NewShowCommand(factory), + ), + ) +} diff --git a/cmd/connectivity/plugins/show.go b/cmd/connectivity/plugins/show.go new file mode 100644 index 00000000..d8fa3a26 --- /dev/null +++ b/cmd/connectivity/plugins/show.go @@ -0,0 +1,227 @@ +package plugins + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ShowStore struct { + Plugin connectivityclient.Plugin `json:"plugin"` +} + +type ShowController struct { + factory connectivityinternal.ClientFactory + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func NewShowController(factory connectivityinternal.ClientFactory) *ShowController { + return &ShowController{factory: factory, store: &ShowStore{}} +} + +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewShowController(factory) + return fctl.NewCommand( + "show ", + fctl.WithAliases("get", "g", "sh", "s"), + fctl.WithShortDescription("Show a Connectivity plugin"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompletePluginNames(factory)), + fctl.WithController[*ShowStore](controller), + ) +} + +func (c *ShowController) GetStore() *ShowStore { + return c.store +} + +func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + plugin, err := client.GetPlugin(cmd.Context(), args[0]) + if err != nil { + return nil, err + } + if plugin == nil { + return nil, fmt.Errorf("show connectivity plugin %q: empty response", args[0]) + } + c.store.Plugin = *plugin + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + plugin := c.store.Plugin + out := cmd.OutOrStdout() + + fctl.Section.WithWriter(out).Println("Information") + information := pterm.TableData{ + {pterm.LightCyan("Name"), stringValue(plugin.Metadata.Name)}, + {pterm.LightCyan("Namespace"), stringValue(plugin.Metadata.Namespace)}, + {pterm.LightCyan("UID"), stringValue(plugin.Metadata.UID)}, + {pterm.LightCyan("Resource Version"), stringValue(plugin.Metadata.ResourceVersion)}, + {pterm.LightCyan("Created At"), timeValue(plugin.Metadata.CreationTimestamp)}, + {pterm.LightCyan("Labels"), mapValue(plugin.Metadata.Labels)}, + {pterm.LightCyan("Annotations"), mapValue(plugin.Metadata.Annotations)}, + {pterm.LightCyan("Image"), plugin.Spec.Image}, + {pterm.LightCyan("Version"), stringValue(plugin.Spec.Version)}, + {pterm.LightCyan("Default Version"), stringValue(plugin.Spec.DefaultVersion)}, + {pterm.LightCyan("Description"), stringValue(plugin.Spec.Description)}, + {pterm.LightCyan("Capabilities"), strings.Join(plugin.Spec.Capabilities, ", ")}, + {pterm.LightCyan("Phase"), pluginPhase(plugin.Status)}, + {pterm.LightCyan("Status Message"), pluginStatusMessage(plugin.Status)}, + {pterm.LightCyan("Documentation"), stringValue(plugin.Spec.DocsURL)}, + } + if err := pterm.DefaultTable.WithWriter(out).WithData(information).Render(); err != nil { + return err + } + + if len(plugin.Spec.Versions) > 0 { + fctl.Section.WithWriter(out).Println("Versions") + versions := fctl.Map(plugin.Spec.Versions, func(version connectivityclient.VersionEntry) []string { + return []string{version.Version, stringValue(version.Digest), stringValue(version.Image)} + }) + versions = fctl.Prepend(versions, []string{"Version", "Digest", "Image"}) + if err := pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(versions).Render(); err != nil { + return err + } + } + + fctl.Section.WithWriter(out).Println("Configuration Schema") + schemaRows := summarizeSchema(plugin.Spec.ConfigSchema) + if len(schemaRows) == 0 { + _, err := fmt.Fprintln(out, "No configurable fields.") + return err + } + schemaRows = fctl.Prepend(schemaRows, []string{"Key", "Source", "Requirement", "Format", "Description"}) + return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(schemaRows).Render() +} + +func summarizeSchema(schema map[string]any) [][]string { + type field struct { + key, source, requirement, format, description string + } + fields := make([]field, 0) + sections := []struct { + name string + source string + }{ + {name: "env", source: "environment"}, + {name: "files", source: "file"}, + } + + for _, section := range sections { + sectionSchema, ok := objectValue(schema[section.name]) + if !ok { + continue + } + properties, _ := objectValue(sectionSchema["properties"]) + required := requiredKeys(sectionSchema["required"]) + for key, rawDefinition := range properties { + definition, _ := objectValue(rawDefinition) + format, _ := definition["format"].(string) + if format == "" { + format, _ = definition["type"].(string) + } + description, _ := definition["description"].(string) + requirement := "optional" + if required[key] { + requirement = "required" + } + fields = append(fields, field{key, section.source, requirement, format, description}) + } + } + + if len(fields) == 0 { + properties, ok := objectValue(schema["properties"]) + if ok { + required := requiredKeys(schema["required"]) + for key, rawDefinition := range properties { + definition, _ := objectValue(rawDefinition) + format, _ := definition["format"].(string) + if format == "" { + format, _ = definition["type"].(string) + } + description, _ := definition["description"].(string) + requirement := "optional" + if required[key] { + requirement = "required" + } + fields = append(fields, field{key, "environment", requirement, format, description}) + } + } + } + + sort.Slice(fields, func(i, j int) bool { + if fields[i].source == fields[j].source { + return fields[i].key < fields[j].key + } + return fields[i].source < fields[j].source + }) + return fctl.Map(fields, func(value field) []string { + return []string{value.key, value.source, value.requirement, value.format, value.description} + }) +} + +func objectValue(value any) (map[string]any, bool) { + object, ok := value.(map[string]any) + return object, ok +} + +func requiredKeys(value any) map[string]bool { + result := map[string]bool{} + switch values := value.(type) { + case []any: + for _, value := range values { + if key, ok := value.(string); ok { + result[key] = true + } + } + case []string: + for _, key := range values { + result[key] = true + } + } + return result +} + +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} + +func mapValue(value map[string]string) string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, key+"="+value[key]) + } + return strings.Join(entries, ", ") +} + +func pluginStatusMessage(status *connectivityclient.PluginStatus) string { + if status == nil { + return "" + } + return stringValue(status.Message) +} diff --git a/cmd/connectivity/plugins/show_test.go b/cmd/connectivity/plugins/show_test.go new file mode 100644 index 00000000..08490804 --- /dev/null +++ b/cmd/connectivity/plugins/show_test.go @@ -0,0 +1,163 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func TestShowPluginRendersMetadataVersionsCapabilitiesStatusAndSchemaWithoutDefaults(t *testing.T) { + created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) + plugin := pluginFixture("stripe") + plugin.Metadata.Namespace = fctl.Ptr("formance") + plugin.Metadata.ResourceVersion = fctl.Ptr("42") + plugin.Metadata.UID = fctl.Ptr("plugin-uid") + plugin.Metadata.CreationTimestamp = &created + plugin.Metadata.Labels = map[string]string{"region": "eu"} + plugin.Metadata.Annotations = map[string]string{"owner": "platform"} + plugin.Spec.Version = fctl.Ptr("2.0.0") + plugin.Spec.DocsURL = fctl.Ptr("https://docs.example/stripe") + plugin.Spec.Versions = []connectivityclient.VersionEntry{ + {Version: "1.0.0", Digest: fctl.Ptr("sha256:one")}, + {Version: "2.0.0", Image: fctl.Ptr("registry/plugin:2")}, + } + plugin.Spec.ConfigSchema = map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "required": []any{"API_KEY"}, + "properties": map[string]any{"API_KEY": map[string]any{"type": "string", "format": "password", "description": "API credential"}}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{"/etc/plugin.yaml": map[string]any{"type": "string", "description": "Plugin settings"}}, + }, + } + plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: fctl.Ptr("must-not-leak")}, + }} + plugin.Status.Message = fctl.Ptr("Catalog entry is healthy") + + client := pluginClientMock{get: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { + if name != "stripe" { + t.Fatalf("GetPlugin name = %q, want stripe", name) + } + return &plugin, nil + }} + command := NewShowCommand(factoryReturning(client)) + output, err := executeCommand(command, "stripe") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + + for _, expected := range []string{ + "stripe", "formance", "42", "plugin-uid", created.Format(time.RFC3339), "region=eu", "owner=platform", + "registry/plugin", "Plugin description", "https://docs.example/stripe", "2.0.0", "1.0.0", "sha256:one", + "payments, webhooks", "Ready", "Catalog entry is healthy", + "Configuration Schema", "API_KEY", "environment", "required", "password", "API credential", + "/etc/plugin.yaml", "file", "Plugin settings", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } + if strings.Contains(output, "must-not-leak") { + t.Fatalf("plain output leaked an inline default value:\n%s", output) + } +} + +func TestShowPluginJSONPreservesCompleteModel(t *testing.T) { + plugin := pluginFixture("stripe") + plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: fctl.Ptr("json-keeps-full-model")}, + }} + client := pluginClientMock{get: func(_ context.Context, _ string) (*connectivityclient.Plugin, error) { + return &plugin, nil + }} + + command := NewShowCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json", "stripe") + if err != nil { + t.Fatalf("execute JSON show command: %v", err) + } + + var envelope struct { + Data ShowStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if !reflect.DeepEqual(envelope.Data.Plugin, plugin) { + t.Fatalf("JSON plugin = %#v, want complete model %#v", envelope.Data.Plugin, plugin) + } +} + +func TestShowPluginReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { + return nil, errors.New("plugin unavailable") + }}), + want: "plugin unavailable", + }, + "empty response": { + factory: factoryReturning(pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewShowCommand(test.factory), "stripe") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} + +func TestShowPluginSummarizesLegacyFlatSchema(t *testing.T) { + plugin := pluginFixture("legacy") + plugin.Status = nil + plugin.Spec.ConfigSchema = map[string]any{ + "type": "object", + "required": []string{"ENDPOINT"}, + "properties": map[string]any{ + "ENDPOINT": map[string]any{"type": "string", "description": "Service endpoint"}, + }, + } + client := pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &plugin, nil + }} + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "legacy") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + for _, expected := range []string{"ENDPOINT", "environment", "required", "string", "Service endpoint"} { + if !strings.Contains(output, expected) { + t.Errorf("legacy schema output missing %q:\n%s", expected, output) + } + } +} diff --git a/cmd/connectivity/plugins/test_helpers_test.go b/cmd/connectivity/plugins/test_helpers_test.go new file mode 100644 index 00000000..7de51df5 --- /dev/null +++ b/cmd/connectivity/plugins/test_helpers_test.go @@ -0,0 +1,54 @@ +package plugins + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type pluginClientMock struct { + connectivityclient.Client + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) + get func(context.Context, string) (*connectivityclient.Plugin, error) +} + +func (m pluginClientMock) ListPlugins(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return m.list(ctx, options) +} + +func (m pluginClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + return m.get(ctx, name) +} + +func pluginFixture(name string) connectivityclient.Plugin { + return connectivityclient.Plugin{ + Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, + Spec: connectivityclient.PluginSpec{ + Image: "registry/plugin", + Description: fctl.Ptr("Plugin description"), + DefaultVersion: fctl.Ptr("2.0.0"), + Capabilities: []string{"payments", "webhooks"}, + }, + Status: &connectivityclient.PluginStatus{Phase: fctl.Ptr("Ready")}, + } +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} From 26ce959e6fd8f1d03406e200b2ce67d94ad0b4a2 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:45:52 +0200 Subject: [PATCH 07/21] fix(connectivity): keep completion non-interactive --- cmd/connectivity/internal/client.go | 61 ++++++++- cmd/connectivity/internal/client_test.go | 139 ++++++++++++++++++++ cmd/connectivity/plugins/completion.go | 2 +- cmd/connectivity/plugins/completion_test.go | 8 +- 4 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 cmd/connectivity/internal/client_test.go diff --git a/cmd/connectivity/internal/client.go b/cmd/connectivity/internal/client.go index b697ad16..68c53b39 100644 --- a/cmd/connectivity/internal/client.go +++ b/cmd/connectivity/internal/client.go @@ -1,25 +1,76 @@ package internal import ( + "context" + "fmt" + "github.com/spf13/cobra" + oidcclient "github.com/formancehq/go-libs/v4/oidc/client" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" fctl "github.com/formancehq/fctl/v3/pkg" ) type ClientFactory func(*cobra.Command) (connectivityclient.Client, error) +type nonInteractiveContextKey struct{} + +func WithNonInteractive(ctx context.Context) context.Context { + return context.WithValue(ctx, nonInteractiveContextKey{}, true) +} + +func IsNonInteractive(ctx context.Context) bool { + if ctx == nil { + return false + } + nonInteractive, _ := ctx.Value(nonInteractiveContextKey{}).(bool) + return nonInteractive +} + +type clientFactoryDependencies struct { + loadAndAuthenticateCurrentProfile func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) + resolveStackID func(*cobra.Command, fctl.Profile) (string, string, error) + readStackToken func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) + newStackClientsFromFlags func(*cobra.Command, oidcclient.RelyingParty, fctl.Dialog, string, fctl.Profile) (*fctl.StackClients, error) +} + func NewClientFactory() ClientFactory { + return newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: fctl.LoadAndAuthenticateCurrentProfile, + resolveStackID: fctl.ResolveStackID, + readStackToken: fctl.ReadStackToken, + newStackClientsFromFlags: fctl.NewStackClientsFromFlags, + }) +} + +func newClientFactory(dependencies clientFactoryDependencies) ClientFactory { return func(cmd *cobra.Command) (connectivityclient.Client, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + _, profile, profileName, relyingParty, err := dependencies.loadAndAuthenticateCurrentProfile(cmd) if err != nil { return nil, err } - clients, err := fctl.NewStackClientsFromFlags( + dialog := fctl.NewPTermDialog() + if IsNonInteractive(cmd.Context()) { + organizationID, stackID, err := dependencies.resolveStackID(cmd, *profile) + if err != nil { + return nil, err + } + stackToken, err := dependencies.readStackToken(cmd, profileName, organizationID, stackID) + if err != nil { + return nil, err + } + if stackToken == nil || stackToken.Expired() { + return nil, fmt.Errorf("connectivity completion requires an existing unexpired stack token") + } + dialog = silentDialog{} + } + + clients, err := dependencies.newStackClientsFromFlags( cmd, relyingParty, - fctl.NewPTermDialog(), + dialog, profileName, *profile, ) @@ -30,3 +81,7 @@ func NewClientFactory() ClientFactory { return connectivityclient.New(clients.URI, clients.HTTPClient), nil } } + +type silentDialog struct{} + +func (silentDialog) Info(string, ...any) {} diff --git a/cmd/connectivity/internal/client_test.go b/cmd/connectivity/internal/client_test.go new file mode 100644 index 00000000..b0de07ac --- /dev/null +++ b/cmd/connectivity/internal/client_test.go @@ -0,0 +1,139 @@ +package internal + +import ( + "bytes" + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/go-libs/v4/oidc" + oidcclient "github.com/formancehq/go-libs/v4/oidc/client" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func TestClientFactoryNonInteractiveModeRejectsUnusableTokensBeforeAuthentication(t *testing.T) { + tests := map[string]*fctl.AccessToken{ + "missing": nil, + "expired": accessTokenExpiringAt(time.Now().Add(-time.Minute)), + } + + for name, token := range tests { + t.Run(name, func(t *testing.T) { + var terminal, commandOutput bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + authenticationStarted := false + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + resolveStackID: func(*cobra.Command, fctl.Profile) (string, string, error) { + return "org", "stack", nil + }, + readStackToken: func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) { + return token, nil + }, + newStackClientsFromFlags: func(*cobra.Command, oidcclient.RelyingParty, fctl.Dialog, string, fctl.Profile) (*fctl.StackClients, error) { + authenticationStarted = true + return nil, nil + }, + }) + command := &cobra.Command{} + command.SetContext(WithNonInteractive(context.Background())) + command.SetOut(&commandOutput) + command.SetErr(&commandOutput) + + client, err := factory(command) + if err == nil { + t.Fatal("factory error = nil, want unusable-token error") + } + if client != nil { + t.Fatalf("client = %#v, want nil", client) + } + if authenticationStarted { + t.Fatal("interactive authentication boundary was called") + } + if terminal.Len() != 0 || commandOutput.Len() != 0 { + t.Fatalf("completion emitted output: terminal=%q command=%q", terminal.String(), commandOutput.String()) + } + }) + } +} + +func TestClientFactoryNonInteractiveModeUsesSilentDialogWithValidToken(t *testing.T) { + var terminal bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + resolveStackID: func(*cobra.Command, fctl.Profile) (string, string, error) { + return "org", "stack", nil + }, + readStackToken: func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) { + return accessTokenExpiringAt(time.Now().Add(time.Hour)), nil + }, + newStackClientsFromFlags: func(_ *cobra.Command, _ oidcclient.RelyingParty, dialog fctl.Dialog, _ string, _ fctl.Profile) (*fctl.StackClients, error) { + dialog.Info("must stay silent") + return &fctl.StackClients{URI: "https://stack.example", HTTPClient: &http.Client{}}, nil + }, + }) + command := &cobra.Command{} + command.SetContext(WithNonInteractive(context.Background())) + + client, err := factory(command) + if err != nil { + t.Fatalf("factory error = %v", err) + } + if client == nil { + t.Fatal("client = nil, want Connectivity client") + } + if terminal.Len() != 0 { + t.Fatalf("non-interactive dialog output = %q, want silence", terminal.String()) + } +} + +func TestClientFactoryNormalModeRetainsInteractiveDialog(t *testing.T) { + var terminal bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + newStackClientsFromFlags: func(_ *cobra.Command, _ oidcclient.RelyingParty, dialog fctl.Dialog, _ string, _ fctl.Profile) (*fctl.StackClients, error) { + dialog.Info("normal authentication path") + return &fctl.StackClients{URI: "https://stack.example", HTTPClient: &http.Client{}}, nil + }, + }) + + client, err := factory(&cobra.Command{}) + if err != nil { + t.Fatalf("factory error = %v", err) + } + if client == nil { + t.Fatal("client = nil, want Connectivity client") + } + if !strings.Contains(terminal.String(), "normal authentication path") { + t.Fatalf("normal dialog output = %q, want interactive message", terminal.String()) + } +} + +func testProfileLoader() func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) { + return func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) { + return &fctl.Config{}, &fctl.Profile{}, "profile", nil, nil + } +} + +func accessTokenExpiringAt(expiration time.Time) *fctl.AccessToken { + return &fctl.AccessToken{TokenWithClaims: fctl.TokenWithClaims[fctl.AccessTokenClaims]{ + Claims: fctl.AccessTokenClaims{TokenClaims: oidc.TokenClaims{Expiration: oidc.Time(expiration.Unix())}}, + }} +} diff --git a/cmd/connectivity/plugins/completion.go b/cmd/connectivity/plugins/completion.go index 5f73bf08..6cdfaf0f 100644 --- a/cmd/connectivity/plugins/completion.go +++ b/cmd/connectivity/plugins/completion.go @@ -30,7 +30,7 @@ func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.Compl defer cancel() completionCommand := *cmd - completionCommand.SetContext(ctx) + completionCommand.SetContext(connectivityinternal.WithNonInteractive(ctx)) client, err := factory(&completionCommand) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/connectivity/plugins/completion_test.go b/cmd/connectivity/plugins/completion_test.go index 69fd7705..4e5442de 100644 --- a/cmd/connectivity/plugins/completion_test.go +++ b/cmd/connectivity/plugins/completion_test.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) @@ -27,7 +28,12 @@ func TestCompletePluginNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDe }}, nil }} - completion := CompletePluginNames(factoryReturning(client)) + completion := CompletePluginNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("completion factory context is interactive") + } + return client, nil + }) candidates, directive := completion(&cobra.Command{}, nil, "al") if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { From 23a0c62af481cc9a9baa779fd3f790a0e5e59d0c Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:57:58 +0200 Subject: [PATCH 08/21] feat(connectivity): list and show instances --- cmd/connectivity/instances/completion.go | 234 +++++++++++++++++ cmd/connectivity/instances/completion_test.go | 246 ++++++++++++++++++ cmd/connectivity/instances/list.go | 146 +++++++++++ cmd/connectivity/instances/list_test.go | 126 +++++++++ cmd/connectivity/instances/root.go | 20 ++ cmd/connectivity/instances/show.go | 183 +++++++++++++ cmd/connectivity/instances/show_test.go | 144 ++++++++++ .../instances/test_helpers_test.go | 69 ++++- 8 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 cmd/connectivity/instances/completion.go create mode 100644 cmd/connectivity/instances/completion_test.go create mode 100644 cmd/connectivity/instances/list.go create mode 100644 cmd/connectivity/instances/list_test.go create mode 100644 cmd/connectivity/instances/root.go create mode 100644 cmd/connectivity/instances/show.go create mode 100644 cmd/connectivity/instances/show_test.go diff --git a/cmd/connectivity/instances/completion.go b/cmd/connectivity/instances/completion.go new file mode 100644 index 00000000..90313992 --- /dev/null +++ b/cmd/connectivity/instances/completion.go @@ -0,0 +1,234 @@ +package instances + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const ( + instanceCompletionLimit = int32(500) + instanceCompletionTimeout = 2 * time.Second +) + +type PathCompleter func(prefix string) ([]string, error) + +func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := completionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + response, err := client.ListInstances(completionCommand.Context(), connectivityclient.ListOptions{Limit: instanceCompletionLimit}) + if err != nil || response == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidates := make([]string, 0, len(response.Items)) + for _, instance := range response.Items { + name := stringValue(instance.Metadata.Name) + if name == "" || !strings.HasPrefix(name, toComplete) { + continue + } + description := strings.Join(nonEmptyStrings(instance.Spec.Plugin, instance.Spec.Ledger), " ยท ") + if description != "" { + name += "\t" + description + } + candidates = append(candidates, name) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func(*cobra.Command, []string) string) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if pluginArg == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + pluginName := pluginArg(cmd, args) + if pluginName == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, completionCommand, cancel, ok := completionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + plugin, err := client.GetPlugin(completionCommand.Context(), pluginName) + if err != nil || plugin == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidates := make([]string, 0, len(plugin.Spec.Versions)) + for _, version := range plugin.Spec.Versions { + if version.Version == "" || !strings.HasPrefix(version.Version, toComplete) { + continue + } + candidate := version.Version + description := stringValue(version.Image) + if description == "" { + description = stringValue(version.Digest) + } + if description != "" { + candidate += "\t" + description + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func CompleteSetValues( + factory connectivityinternal.ClientFactory, + resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), + paths PathCompleter, +) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if marker := strings.Index(toComplete, "=@"); marker >= 0 { + if paths == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidatePrefix := toComplete[:marker+2] + pathPrefix := toComplete[marker+2:] + pathCandidates, err := paths(pathPrefix) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidates := make([]string, 0, len(pathCandidates)) + for _, path := range pathCandidates { + if strings.HasPrefix(path, pathPrefix) { + candidates = append(candidates, candidatePrefix+path) + } + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } + + if resolvePlugin == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, completionCommand, cancel, ok := completionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + plugin, err := resolvePlugin(completionCommand.Context(), client, completionCommand, args) + if err != nil || plugin == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + fields, err := SchemaFields(plugin) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + supplied := suppliedSetKeys(cmd, args) + ordered := make([]SchemaField, 0, len(fields)) + for key, field := range fields { + if supplied[key] || !strings.HasPrefix(key, strings.TrimSuffix(toComplete, "=")) { + continue + } + ordered = append(ordered, field) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].Required != ordered[j].Required { + return ordered[i].Required + } + return ordered[i].Key < ordered[j].Key + }) + candidates := make([]string, 0, len(ordered)) + for _, field := range ordered { + candidates = append(candidates, field.Key+"=\t"+schemaFieldDescription(field)) + } + return candidates, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } +} + +func schemaFieldDescription(field SchemaField) string { + if field.Description != "" { + return field.Description + } + if field.Kind == ConfigFile { + return "file configuration" + } + return "environment configuration" +} + +func OSPathCompleter(prefix string) ([]string, error) { + directory, base := filepath.Split(prefix) + readDirectory := directory + if readDirectory == "" { + readDirectory = "." + } + entries, err := os.ReadDir(readDirectory) + if err != nil { + return nil, err + } + candidates := make([]string, 0, len(entries)) + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), base) { + continue + } + candidate := directory + entry.Name() + if entry.IsDir() { + candidate += string(filepath.Separator) + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, nil +} + +func completionClient(cmd *cobra.Command, factory connectivityinternal.ClientFactory) (connectivityclient.Client, *cobra.Command, context.CancelFunc, bool) { + if factory == nil { + return nil, nil, func() {}, false + } + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, instanceCompletionTimeout) + completionCommand := *cmd + completionCommand.SetContext(connectivityinternal.WithNonInteractive(ctx)) + client, err := factory(&completionCommand) + if err != nil { + cancel() + return nil, nil, func() {}, false + } + return client, &completionCommand, cancel, true +} + +func suppliedSetKeys(cmd *cobra.Command, args []string) map[string]bool { + values := append([]string(nil), args...) + if cmd != nil && cmd.Flags().Lookup("set") != nil { + if setValues, err := cmd.Flags().GetStringArray("set"); err == nil { + values = append(values, setValues...) + } + } + supplied := make(map[string]bool) + for _, value := range values { + if separator := strings.IndexByte(value, '='); separator > 0 { + supplied[value[:separator]] = true + } + } + return supplied +} + +func nonEmptyStrings(values ...string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != "" { + result = append(result, value) + } + } + return result +} diff --git a/cmd/connectivity/instances/completion_test.go b/cmd/connectivity/instances/completion_test.go new file mode 100644 index 00000000..53781bca --- /dev/null +++ b/cmd/connectivity/instances/completion_test.go @@ -0,0 +1,246 @@ +package instances + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type completionClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) + getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) +} + +func (m completionClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m completionClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + return m.getPlugin(ctx, name) +} + +func TestCompleteInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + var remaining time.Duration + client := completionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + gotOptions = options + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("completion context has no deadline") + } + remaining = time.Until(deadline) + alpha := instanceFixture("alpha") + alpha.Spec.Plugin = "stripe" + alpha.Spec.Ledger = "eu" + alpine := instanceFixture("alpine") + alpine.Spec.Plugin = "wise" + alpine.Spec.Ledger = "uk" + return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{ + instanceFixture("beta"), alpine, alpha, + }}, nil + }} + completion := CompleteInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("completion factory context is interactive") + } + return client, nil + }) + + candidates, directive := completion(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { + t.Fatalf("ListInstances options = %#v, want limit 500", gotOptions) + } + if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { + t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) + } + want := []string{"alpha\tstripe ยท eu", "alpine\twise ยท uk"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteVersionsUsesSelectedPluginAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { + var gotName string + client := completionClientMock{getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + if !hasTwoSecondDeadline(ctx) { + t.Fatal("version completion context does not have the expected deadline") + } + gotName = name + plugin := pluginWithFileSchema() + plugin.Spec.Versions = []connectivityclient.VersionEntry{ + {Version: "2.1.0", Digest: stringPtr("sha256:two")}, + {Version: "1.0.0", Image: stringPtr("registry/plugin:1")}, + {Version: "2.0.0", Image: stringPtr("registry/plugin:2")}, + } + return plugin, nil + }} + completion := CompleteVersions(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("version completion factory context is interactive") + } + return client, nil + }, func(_ *cobra.Command, args []string) string { + return args[0] + }) + + candidates, directive := completion(&cobra.Command{}, []string{"stripe"}, "2") + + if gotName != "stripe" { + t.Fatalf("GetPlugin name = %q, want stripe", gotName) + } + want := []string{"2.0.0\tregistry/plugin:2", "2.1.0\tsha256:two"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteSetValuesPrioritizesRequiredKeysDescribesAndOmitsSuppliedKeys(t *testing.T) { + var resolvedClient connectivityclient.Client + client := &completionClientMock{} + resolvePlugin := func(ctx context.Context, got connectivityclient.Client, _ *cobra.Command, _ []string) (*connectivityclient.Plugin, error) { + if !connectivityinternal.IsNonInteractive(ctx) || !hasTwoSecondDeadline(ctx) { + t.Fatal("set completion resolver context must be non-interactive with a two-second deadline") + } + resolvedClient = got + return pluginWithSchemaAndDefaults(), nil + } + completion := CompleteSetValues(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("set completion factory context is interactive") + } + return client, nil + }, resolvePlugin, mockPathCompleter(nil)) + command := &cobra.Command{} + command.Flags().StringArray("set", nil, "") + if err := command.Flags().Set("set", "TOKEN=secret://plugin/token"); err != nil { + t.Fatal(err) + } + + candidates, directive := completion(command, nil, "") + + if resolvedClient != client { + t.Fatal("resolver did not receive the factory client") + } + want := []string{ + "/etc/plugin/config.yaml=\tPlugin configuration", + "API_URL=\tAPI endpoint", + "/etc/plugin/ca.pem=\tfile configuration", + "TIMEOUT=\tenvironment configuration", + } + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want required then optional keys %#v", candidates, want) + } + wantDirective := cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + if directive != wantDirective { + t.Fatalf("directive = %v, want NoFileComp|NoSpace", directive) + } +} + +func TestCompleteSetValuesPreservesKeyAtPrefixForInjectedPathCandidates(t *testing.T) { + var gotPrefix string + paths := func(prefix string) ([]string, error) { + gotPrefix = prefix + return []string{"fixtures/alpha/", "fixtures/api-key.txt"}, nil + } + completion := CompleteSetValues(nil, nil, paths) + + candidates, directive := completion(&cobra.Command{}, nil, "API_KEY=@fixtures/a") + + if gotPrefix != "fixtures/a" { + t.Fatalf("path prefix = %q, want fixtures/a", gotPrefix) + } + want := []string{"API_KEY=@fixtures/alpha/", "API_KEY=@fixtures/api-key.txt"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want prefixed paths %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors(t *testing.T) { + apiErrorClient := completionClientMock{ + listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return nil, errors.New("unsupported deployment") + }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return nil, errors.New("unsupported deployment") + }, + } + tests := map[string]struct { + completion cobra.CompletionFunc + command *cobra.Command + args []string + prefix string + }{ + "instance authentication": { + completion: CompleteInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }), + command: &cobra.Command{}, + }, + "instance API": {completion: CompleteInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, + "instance timeout": { + completion: CompleteInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + <-ctx.Done() + return nil, ctx.Err() + }})), + command: commandWithExpiredContext(), + }, + "version empty plugin": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "" }), command: &cobra.Command{}}, + "version API": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "stripe" }), command: &cobra.Command{}}, + "set resolver": { + completion: CompleteSetValues(factoryReturning(completionClientMock{}), func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error) { + return nil, errors.New("cannot resolve plugin") + }, mockPathCompleter(nil)), + command: &cobra.Command{}, + }, + "path": { + completion: CompleteSetValues(nil, nil, func(string) ([]string, error) { return nil, errors.New("cannot read directory") }), + command: &cobra.Command{}, + prefix: "API_KEY=@fixtures/a", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + candidates, directive := test.completion(test.command, test.args, test.prefix) + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} + +func hasTwoSecondDeadline(ctx context.Context) bool { + deadline, ok := ctx.Deadline() + if !ok { + return false + } + remaining := time.Until(deadline) + return remaining > 1500*time.Millisecond && remaining <= 2*time.Second +} + +func commandWithExpiredContext() *cobra.Command { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + command := &cobra.Command{} + command.SetContext(ctx) + return command +} diff --git a/cmd/connectivity/instances/list.go b/cmd/connectivity/instances/list.go new file mode 100644 index 00000000..686508a6 --- /dev/null +++ b/cmd/connectivity/instances/list.go @@ -0,0 +1,146 @@ +package instances + +import ( + "fmt" + "strconv" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const pluginFlag = "plugin" + +type ListStore struct { + Instances []connectivityclient.Instance `json:"instances"` + Cursor fctl.Cursor `json:"cursor"` +} + +type ListController struct { + factory connectivityinternal.ClientFactory + store *ListStore +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewListController(factory connectivityinternal.ClientFactory) *ListController { + return &ListController{ + factory: factory, + store: &ListStore{Instances: []connectivityclient.Instance{}}, + } +} + +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewListController(factory) + return fctl.NewCommand( + "list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List Connectivity instances"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithStringFlag(pluginFlag, "", "Filter instances by plugin"), + fctl.WithPageSizeFlag(), + fctl.WithCursorFlag(), + fctl.WithController[*ListStore](controller), + ) +} + +func (c *ListController) GetStore() *ListStore { + return c.store +} + +func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + + response, err := client.ListInstances(cmd.Context(), connectivityclient.ListOptions{ + Plugin: fctl.GetString(cmd, pluginFlag), + Limit: pageSize, + Continue: cursor, + }) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("list connectivity instances: empty response") + } + + c.store.Instances = response.Items + c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} + if response.Continue != "" { + c.store.Cursor.HasMore = true + c.store.Cursor.Next = fctl.Ptr(response.Continue) + } + return c, nil +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + rows := fctl.Map(c.store.Instances, func(instance connectivityclient.Instance) []string { + return []string{ + stringValue(instance.Metadata.Name), + instance.Spec.Plugin, + stringValue(instance.Spec.Version), + instance.Spec.Ledger, + instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Phase }), + instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.State }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.CurrentSequence }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.SourceTipSequence }), + instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.LastError }), + } + }) + rows = fctl.Prepend(rows, []string{ + "Name", "Plugin", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + }) + if err := pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(rows). + Render(); err != nil { + return err + } + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func int64Value(value *int64) string { + if value == nil { + return "" + } + return strconv.FormatInt(*value, 10) +} + +func instanceStatusValue(status *connectivityclient.InstanceStatus, field func(*connectivityclient.InstanceStatus) *string) string { + if status == nil { + return "" + } + return stringValue(field(status)) +} + +func instanceStatusInt64(status *connectivityclient.InstanceStatus, field func(*connectivityclient.InstanceStatus) *int64) string { + if status == nil { + return "" + } + return int64Value(field(status)) +} diff --git a/cmd/connectivity/instances/list_test.go b/cmd/connectivity/instances/list_test.go new file mode 100644 index 00000000..902c1f7d --- /dev/null +++ b/cmd/connectivity/instances/list_test.go @@ -0,0 +1,126 @@ +package instances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type listInstanceClientMock struct { + connectivityclient.Client + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) +} + +func (m listInstanceClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return m.list(ctx, options) +} + +func TestListInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + gotOptions = options + return &connectivityclient.InstanceList{ + Items: []connectivityclient.Instance{instanceFixture("stripe-eu")}, + Continue: "next-page", + }, nil + }} + + output, err := executeCommand(NewListCommand(factoryReturning(client)), "--plugin", "stripe", "--page-size", "7", "--cursor", "current-page") + + require.NoError(t, err) + require.Equal(t, connectivityclient.ListOptions{Plugin: "stripe", Limit: 7, Continue: "current-page"}, gotOptions) + for _, expected := range []string{ + "Name", "Plugin", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "stripe-eu", "stripe", "2.0.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", + "HasMore", "true", "PageSize", "7", "Next", "next-page", + } { + require.Contains(t, output, expected) + } +} + +func TestListInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Metadata.Labels = map[string]string{"region": "eu"} + instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("json-keeps-full-model")}, + }} + client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{instance}, Continue: "next-page"}, nil + }} + command := NewListCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "--output", "json", "--page-size", "4") + + require.NoError(t, err) + var envelope struct { + Data ListStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, []connectivityclient.Instance{instance}, envelope.Data.Instances) + require.True(t, envelope.Data.Cursor.HasMore) + require.Equal(t, int64(4), envelope.Data.Cursor.PageSize) + require.NotNil(t, envelope.Data.Cursor.Next) + require.Equal(t, "next-page", *envelope.Data.Cursor.Next) +} + +func TestInstanceRootRegistersReadCommandsAndAliases(t *testing.T) { + command := NewCommand(factoryReturning(listInstanceClientMock{}), mockReadFile(nil), mockPathCompleter(nil)) + + require.Equal(t, "instances", command.Use) + require.Equal(t, []string{"instance", "i"}, command.Aliases) + wantAliases := map[string][]string{ + "list": {"ls", "l"}, + "show": {"get", "g", "sh", "s"}, + } + for name, aliases := range wantAliases { + child, _, err := command.Find([]string{name}) + require.NoError(t, err) + require.Equal(t, name, child.Name()) + require.True(t, reflect.DeepEqual(aliases, child.Aliases), "%s aliases = %v", name, child.Aliases) + } +} + +func TestListInstancesReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "missing factory": {factory: nil, want: "factory is required"}, + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return nil, errors.New("instances unavailable") + }}), + want: "instances unavailable", + }, + "empty response": { + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewListCommand(test.factory)) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), test.want), "error = %v", err) + }) + } +} diff --git a/cmd/connectivity/instances/root.go b/cmd/connectivity/instances/root.go new file mode 100644 index 00000000..455c08c9 --- /dev/null +++ b/cmd/connectivity/instances/root.go @@ -0,0 +1,20 @@ +package instances + +import ( + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand(factory connectivityinternal.ClientFactory, _ ReadFileFunc, _ PathCompleter) *cobra.Command { + return fctl.NewCommand( + "instances", + fctl.WithAliases("instance", "i"), + fctl.WithShortDescription("Manage Connectivity instances"), + fctl.WithChildCommands( + NewListCommand(factory), + NewShowCommand(factory), + ), + ) +} diff --git a/cmd/connectivity/instances/show.go b/cmd/connectivity/instances/show.go new file mode 100644 index 00000000..f9bec2f8 --- /dev/null +++ b/cmd/connectivity/instances/show.go @@ -0,0 +1,183 @@ +package instances + +import ( + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ShowStore struct { + Instance connectivityclient.Instance `json:"instance"` +} + +type ShowController struct { + factory connectivityinternal.ClientFactory + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func NewShowController(factory connectivityinternal.ClientFactory) *ShowController { + return &ShowController{factory: factory, store: &ShowStore{}} +} + +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewShowController(factory) + return fctl.NewCommand( + "show ", + fctl.WithAliases("get", "g", "sh", "s"), + fctl.WithShortDescription("Show a Connectivity instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithController[*ShowStore](controller), + ) +} + +func (c *ShowController) GetStore() *ShowStore { + return c.store +} + +func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + instance, err := client.GetInstance(cmd.Context(), args[0]) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("show connectivity instance %q: empty response", args[0]) + } + c.store.Instance = *instance + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + instance := c.store.Instance + out := cmd.OutOrStdout() + + if err := renderInstanceSection(out, "Information", pterm.TableData{ + {pterm.LightCyan("Name"), stringValue(instance.Metadata.Name)}, + {pterm.LightCyan("Namespace"), stringValue(instance.Metadata.Namespace)}, + {pterm.LightCyan("UID"), stringValue(instance.Metadata.UID)}, + {pterm.LightCyan("Resource Version"), stringValue(instance.Metadata.ResourceVersion)}, + {pterm.LightCyan("Created At"), timeValue(instance.Metadata.CreationTimestamp)}, + {pterm.LightCyan("Labels"), mapValue(instance.Metadata.Labels)}, + {pterm.LightCyan("Annotations"), mapValue(instance.Metadata.Annotations)}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Desired Specification", pterm.TableData{ + {pterm.LightCyan("Plugin"), instance.Spec.Plugin}, + {pterm.LightCyan("Version"), stringValue(instance.Spec.Version)}, + {pterm.LightCyan("Connectivity Reference"), stringValue(instance.Spec.ConnectivityRef)}, + {pterm.LightCyan("Ledger"), instance.Spec.Ledger}, + {pterm.LightCyan("Start Sequence"), int64Value(instance.Spec.StartSequence)}, + {pterm.LightCyan("Poll Interval"), stringValue(instance.Spec.PollInterval)}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Lifecycle", pterm.TableData{ + {pterm.LightCyan("Resolved Image"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.ResolvedImage })}, + {pterm.LightCyan("Plugin Address"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.PluginAddress })}, + {pterm.LightCyan("Phase"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Phase })}, + {pterm.LightCyan("State"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.State })}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Ingestion Progress", pterm.TableData{ + {pterm.LightCyan("Current Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.CurrentSequence })}, + {pterm.LightCyan("Source Tip Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.SourceTipSequence })}, + {pterm.LightCyan("Last Error"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.LastError })}, + {pterm.LightCyan("Message"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Message })}, + }); err != nil { + return err + } + + fctl.Section.WithWriter(out).Println("Configuration") + rows := configRows(instance.Spec.Config) + if len(rows) == 0 { + _, err := fmt.Fprintln(out, "No configuration entries.") + return err + } + rows = fctl.Prepend(rows, []string{"Key", "Kind", "Source", "Mode"}) + return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(rows).Render() +} + +func renderInstanceSection(out io.Writer, title string, data pterm.TableData) error { + fctl.Section.WithWriter(out).Println(title) + return pterm.DefaultTable.WithWriter(out).WithData(data).Render() +} + +func configRows(config *connectivityclient.InstanceConfig) [][]string { + if config == nil { + return nil + } + rows := make([][]string, 0, len(config.Env)+len(config.Files)) + envKeys := make([]string, 0, len(config.Env)) + for key := range config.Env { + envKeys = append(envKeys, key) + } + sort.Strings(envKeys) + for _, key := range envKeys { + value := config.Env[key] + rows = append(rows, []string{key, "environment", configSource(value.Value, value.SecretRef, value.ConfigMapRef), ""}) + } + files := append([]connectivityclient.FileMount(nil), config.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + for _, file := range files { + mode := "" + if file.Mode != nil { + mode = strconv.FormatInt(int64(*file.Mode), 10) + } + rows = append(rows, []string{file.Path, "file", configSource(file.Value, file.SecretRef, file.ConfigMapRef), mode}) + } + return rows +} + +func configSource(value *string, secretRef, configMapRef *connectivityclient.KeyRef) string { + if secretRef != nil { + return "secret:" + secretRef.Name + "/" + secretRef.Key + } + if configMapRef != nil { + return "configmap:" + configMapRef.Name + "/" + configMapRef.Key + } + if value != nil { + return "inline" + } + return "" +} + +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} + +func mapValue(value map[string]string) string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, key+"="+value[key]) + } + return strings.Join(entries, ", ") +} diff --git a/cmd/connectivity/instances/show_test.go b/cmd/connectivity/instances/show_test.go new file mode 100644 index 00000000..299684aa --- /dev/null +++ b/cmd/connectivity/instances/show_test.go @@ -0,0 +1,144 @@ +package instances + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type showInstanceClientMock struct { + connectivityclient.Client + get func(context.Context, string) (*connectivityclient.Instance, error) +} + +func (m showInstanceClientMock) GetInstance(ctx context.Context, name string) (*connectivityclient.Instance, error) { + return m.get(ctx, name) +} + +func TestShowInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConfigSources(t *testing.T) { + created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) + instance := instanceFixture("stripe-eu") + instance.Metadata.Namespace = stringPtr("formance") + instance.Metadata.ResourceVersion = stringPtr("42") + instance.Metadata.UID = stringPtr("instance-uid") + instance.Metadata.CreationTimestamp = &created + instance.Metadata.Labels = map[string]string{"region": "eu"} + instance.Metadata.Annotations = map[string]string{"owner": "platform"} + mode := int32(420) + instance.Spec.Config = &connectivityclient.InstanceConfig{ + Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("must-not-leak")}, + "TOKEN": {SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}}, + }, + Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", ConfigMapRef: &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, Mode: &mode}, + {Path: "/etc/plugin/inline.pem", Value: stringPtr("also-must-not-leak")}, + }, + } + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + for _, expected := range []string{ + "Information", "Name", "stripe-eu", "Namespace", "formance", "UID", "instance-uid", "Resource Version", "42", + "Created At", created.Format(time.RFC3339), "Labels", "region=eu", "Annotations", "owner=platform", + "Desired Specification", "Plugin", "stripe", "Version", "2.0.0", "Ledger", "main", "Start Sequence", "10", "Poll Interval", "5s", + "Lifecycle", "Resolved Image", "registry/plugin:2.0.0", "Plugin Address", "http://stripe.default.svc", "Phase", "Ready", "State", "Running", + "Ingestion Progress", "Current Sequence", "42", "Source Tip Sequence", "48", "Last Error", "source temporarily unavailable", "Message", "retrying ingestion", + "Configuration", "API_KEY", "environment", "inline", "TOKEN", "secret:plugin-secrets/token", + "/etc/plugin/config.yaml", "file", "configmap:plugin-config/config.yaml", "420", "/etc/plugin/inline.pem", + } { + require.Contains(t, output, expected) + } + require.NotContains(t, output, "must-not-leak") + require.NotContains(t, output, "also-must-not-leak") +} + +func TestShowPlainOutputNeverPrintsInlineConfigValues(t *testing.T) { + instance := instanceWithTwoFiles() + instance.Spec.Config.Env = map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("must-not-leak")}, + } + command := NewShowCommand(factoryWithInstance(*instance)) + + output, err := executeCommand(command, "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "API_KEY") + require.Contains(t, output, "inline") + require.NotContains(t, output, "must-not-leak") + require.NotContains(t, output, "private config") +} + +func TestShowInstanceJSONPreservesCompleteModel(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("json-keeps-full-model")}, + }} + command := NewShowCommand(factoryWithInstance(instance)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "--output", "json", "stripe-eu") + + require.NoError(t, err) + var envelope struct { + Data ShowStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, instance, envelope.Data.Instance) +} + +func TestShowInstanceHandlesMissingStatusAndConfig(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Status = nil + instance.Spec.Config = nil + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "No configuration entries.") +} + +func TestShowInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "missing factory": {factory: nil, want: "factory is required"}, + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.Instance, error) { + return nil, errors.New("instance unavailable") + }}), + want: "instance unavailable", + }, + "empty response": { + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.Instance, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewShowCommand(test.factory), "stripe-eu") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), test.want), "error = %v", err) + }) + } +} diff --git a/cmd/connectivity/instances/test_helpers_test.go b/cmd/connectivity/instances/test_helpers_test.go index e1149c64..03bfd4db 100644 --- a/cmd/connectivity/instances/test_helpers_test.go +++ b/cmd/connectivity/instances/test_helpers_test.go @@ -1,18 +1,66 @@ package instances import ( + "bytes" + "context" "fmt" "github.com/spf13/cobra" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" ) func stringPtr(value string) *string { return &value } -func mapReadFile(files map[string]string) ReadFileFunc { +func instanceFixture(name string) connectivityclient.Instance { + return connectivityclient.Instance{ + Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}, + Spec: connectivityclient.InstanceSpec{ + Plugin: "stripe", + Version: stringPtr("2.0.0"), + Ledger: "main", + PollInterval: stringPtr("5s"), + StartSequence: fctl.Ptr(int64(10)), + }, + Status: &connectivityclient.InstanceStatus{ + Phase: stringPtr("Ready"), + State: stringPtr("Running"), + ResolvedImage: stringPtr("registry/plugin:2.0.0"), + PluginAddress: stringPtr("http://stripe.default.svc"), + CurrentSequence: fctl.Ptr(int64(42)), + SourceTipSequence: fctl.Ptr(int64(48)), + LastError: stringPtr("source temporarily unavailable"), + Message: stringPtr("retrying ingestion"), + }, + } +} + +func instanceWithTwoFiles() *connectivityclient.Instance { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", Value: stringPtr("private config")}, + {Path: "/etc/plugin/ca.pem", SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "ca.pem"}}, + }} + return &instance +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func factoryWithInstance(instance connectivityclient.Instance) connectivityinternal.ClientFactory { + return factoryReturning(showInstanceClientMock{get: func(_ context.Context, _ string) (*connectivityclient.Instance, error) { + return &instance, nil + }}) +} + +func mockReadFile(files map[string]string) ReadFileFunc { return func(_ *cobra.Command, name string) (string, error) { value, ok := files[name] if !ok { @@ -22,6 +70,25 @@ func mapReadFile(files map[string]string) ReadFileFunc { } } +func mapReadFile(files map[string]string) ReadFileFunc { + return mockReadFile(files) +} + +func mockPathCompleter(paths []string) PathCompleter { + return func(string) ([]string, error) { + return paths, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} + func pluginWithFileSchema() *connectivityclient.Plugin { return &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ ConfigSchema: map[string]any{ From 50769cf2123b05356e994dfec4ef6dca9996595e Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:05:51 +0200 Subject: [PATCH 09/21] feat(connectivity): install plugin instances --- cmd/connectivity/instances/install.go | 202 +++++++++++++++ cmd/connectivity/instances/install_test.go | 283 +++++++++++++++++++++ cmd/connectivity/instances/root.go | 3 +- 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 cmd/connectivity/instances/install.go create mode 100644 cmd/connectivity/instances/install_test.go diff --git a/cmd/connectivity/instances/install.go b/cmd/connectivity/instances/install.go new file mode 100644 index 00000000..7f303ad8 --- /dev/null +++ b/cmd/connectivity/instances/install.go @@ -0,0 +1,202 @@ +package instances + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + "github.com/formancehq/fctl/v3/cmd/connectivity/plugins" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const ( + nameFlag = "name" + ledgerFlag = "ledger" + versionFlag = "version" + startSequenceFlag = "start-sequence" + pollIntervalFlag = "poll-interval" + configFlag = "config" + envFileFlag = "env-file" + setFlag = "set" +) + +type InstallStore struct { + Instance connectivityclient.Instance `json:"instance"` +} + +type approvalFunc func(*cobra.Command, string, ...any) bool + +type InstallController struct { + factory connectivityinternal.ClientFactory + read ReadFileFunc + approve approvalFunc + store *InstallStore +} + +var _ fctl.Controller[*InstallStore] = (*InstallController)(nil) + +func NewInstallController(factory connectivityinternal.ClientFactory, read ReadFileFunc) *InstallController { + return &InstallController{ + factory: factory, + read: read, + approve: fctl.CheckStackApprobation, + store: &InstallStore{}, + } +} + +func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { + controller := NewInstallController(factory, read) + command := fctl.NewCommand( + "install ", + fctl.WithAliases("create", "in"), + fctl.WithShortDescription("Install a Connectivity plugin instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(plugins.CompletePluginNames(factory)), + fctl.WithStringFlag(nameFlag, "", "Instance name (defaults to the plugin name)"), + fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), + fctl.WithStringFlag(versionFlag, "", "Plugin version"), + withInt64Flag(startSequenceFlag, 0, "Starting ledger sequence"), + fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), + fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), + fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), + fctl.WithStringArrayFlag(setFlag, nil, "Configuration value KEY=VALUE (repeatable)"), + fctl.WithConfirmFlag(), + fctl.WithController[*InstallStore](controller), + ) + if err := command.MarkFlagRequired(ledgerFlag); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(versionFlag, CompleteVersions(factory, installPluginArgument)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveInstallPlugin, paths)); err != nil { + panic(err) + } + return command +} + +func withInt64Flag(name string, defaultValue int64, help string) fctl.CommandOptionFn { + return func(cmd *cobra.Command) { + cmd.Flags().Int64(name, defaultValue, help) + } +} + +func installPluginArgument(_ *cobra.Command, args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +func resolveInstallPlugin(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (*connectivityclient.Plugin, error) { + if len(args) == 0 { + return nil, nil + } + return client.GetPlugin(ctx, args[0]) +} + +func (c *InstallController) GetStore() *InstallStore { + return c.store +} + +func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + + pluginName := args[0] + plugin, err := client.GetPlugin(cmd.Context(), pluginName) + if err != nil { + return nil, err + } + if plugin == nil { + return nil, fmt.Errorf("install connectivity plugin %q: empty plugin response", pluginName) + } + + envFiles, err := cmd.Flags().GetStringArray(envFileFlag) + if err != nil { + return nil, err + } + setValues, err := cmd.Flags().GetStringArray(setFlag) + if err != nil { + return nil, err + } + config, err := BuildInstallConfig(cmd, plugin, InputOptions{ + ConfigFile: fctl.GetString(cmd, configFlag), + EnvFiles: envFiles, + SetValues: setValues, + }, c.read) + if err != nil { + return nil, err + } + if !configHasData(config) { + config = nil + } + + name := fctl.GetString(cmd, nameFlag) + if name == "" { + name = pluginName + } + spec := connectivityclient.InstanceSpec{ + Plugin: pluginName, + Ledger: fctl.GetString(cmd, ledgerFlag), + Config: config, + } + if cmd.Flags().Changed(versionFlag) { + value, err := cmd.Flags().GetString(versionFlag) + if err != nil { + return nil, err + } + spec.Version = fctl.Ptr(value) + } + if cmd.Flags().Changed(startSequenceFlag) { + value, err := cmd.Flags().GetInt64(startSequenceFlag) + if err != nil { + return nil, err + } + spec.StartSequence = fctl.Ptr(value) + } + if cmd.Flags().Changed(pollIntervalFlag) { + value, err := cmd.Flags().GetString(pollIntervalFlag) + if err != nil { + return nil, err + } + spec.PollInterval = fctl.Ptr(value) + } + + if !c.approve(cmd, "You are about to install Connectivity instance %q", name) { + return nil, fctl.ErrMissingApproval + } + instance, err := client.CreateInstance(cmd.Context(), connectivityclient.InstanceCreate{Name: name, Spec: spec}) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("install connectivity instance %q: empty instance response", name) + } + c.store.Instance = *instance + return c, nil +} + +func configHasData(config *connectivityclient.InstanceConfig) bool { + return config != nil && (len(config.Env) > 0 || len(config.Files) > 0) +} + +func (c *InstallController) Render(cmd *cobra.Command, _ []string) error { + instance := c.store.Instance + _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Instance %q installed with plugin %q for ledger %q.\n", + stringValue(instance.Metadata.Name), + instance.Spec.Plugin, + instance.Spec.Ledger, + ) + return err +} diff --git a/cmd/connectivity/instances/install_test.go b/cmd/connectivity/instances/install_test.go new file mode 100644 index 00000000..7a97ae6d --- /dev/null +++ b/cmd/connectivity/instances/install_test.go @@ -0,0 +1,283 @@ +package instances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type mutationClientMock struct { + connectivityclient.Client + listPlugins func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) + getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) + create func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) +} + +func (m mutationClientMock) ListPlugins(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + return m.listPlugins(ctx, options) +} + +func (m mutationClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + return m.getPlugin(ctx, name) +} + +func (m mutationClientMock) CreateInstance(ctx context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + return m.create(ctx, body) +} + +func TestInstallBuildsInstanceFromPluginSchemaAndOnlySetsChangedScalars(t *testing.T) { + plugin := pluginWithSchemaAndDefaults() + var gotPlugin string + var gotBody connectivityclient.InstanceCreate + client := mutationClientMock{ + getPlugin: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { + gotPlugin = name + return plugin, nil + }, + create: func(_ context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + gotBody = body + return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + read := mockReadFile(map[string]string{ + "install.yaml": "env:\n API_URL:\n value: https://config.example\n", + "plugin.env": "TIMEOUT=45s\n", + "token.txt": "file-token", + }) + + output, err := executeCommand( + NewInstallCommand(factoryReturning(client), read, mockPathCompleter(nil)), + "stripe", "--name=stripe-eu", "--ledger=main", "--version=2.0.0", + "--start-sequence=0", "--poll-interval=3s", "--config=install.yaml", + "--env-file=plugin.env", "--set=TOKEN=@token.txt", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "stripe", gotPlugin) + require.Equal(t, "stripe-eu", gotBody.Name) + require.Equal(t, "stripe", gotBody.Spec.Plugin) + require.Equal(t, "main", gotBody.Spec.Ledger) + require.Equal(t, "2.0.0", *gotBody.Spec.Version) + require.Equal(t, int64(0), *gotBody.Spec.StartSequence) + require.Equal(t, "3s", *gotBody.Spec.PollInterval) + require.Equal(t, "https://config.example", *gotBody.Spec.Config.Env["API_URL"].Value) + require.Equal(t, "file-token", *gotBody.Spec.Config.Env["TOKEN"].Value) + require.Equal(t, "45s", *gotBody.Spec.Config.Env["TIMEOUT"].Value) + require.Equal(t, plugin.Spec.Defaults.Files, gotBody.Spec.Config.Files) + require.Equal(t, "Instance \"stripe-eu\" installed with plugin \"stripe\" for ledger \"main\".", strings.TrimSpace(output)) +} + +func TestInstallDefaultsNameToPluginAndOmitsUnsetOptionalData(t *testing.T) { + plugin := &connectivityclient.Plugin{} + var gotBody connectivityclient.InstanceCreate + client := mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return plugin, nil }, + create: func(_ context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + gotBody = body + return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "wise", "--ledger=main", "--confirm") + + require.NoError(t, err) + require.Equal(t, "wise", gotBody.Name) + require.Nil(t, gotBody.Spec.Version) + require.Nil(t, gotBody.Spec.StartSequence) + require.Nil(t, gotBody.Spec.PollInterval) + require.Nil(t, gotBody.Spec.Config) +} + +func TestInstallRequiresLedgerBeforeUsingClient(t *testing.T) { + usedClient := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedClient = true + return nil, errors.New("client must not be used") + } + + _, err := executeCommand(NewInstallCommand(factory, mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--confirm") + + require.ErrorContains(t, err, `required flag(s) "ledger" not set`) + require.False(t, usedClient) +} + +func TestInstallConfirmationRejectionPreventsCreateInstance(t *testing.T) { + created := false + client := mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + created = true + return nil, errors.New("CreateInstance must not be called") + }, + } + controller := NewInstallController(factoryReturning(client), mockReadFile(nil)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + require.NoError(t, command.ParseFlags([]string{"--ledger=main"})) + + _, err := controller.Run(command, []string{"stripe"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, created) +} + +func TestInstallPreservesPluginAndCreateAPIErrorsAndRejectsEmptyResponses(t *testing.T) { + pluginError := &connectivityclient.APIError{StatusCode: 404, Code: "PLUGIN_NOT_FOUND", Message: "missing"} + createError := &connectivityclient.APIError{StatusCode: 409, Code: "INSTANCE_EXISTS", Message: "duplicate"} + tests := map[string]struct { + client mutationClientMock + want error + text string + }{ + "plugin API": { + client: mutationClientMock{getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, pluginError }}, + want: pluginError, + }, + "empty plugin": { + client: mutationClientMock{getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, nil }}, + text: "empty plugin response", + }, + "create API": { + client: mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + return nil, createError + }, + }, + want: createError, + }, + "empty create": { + client: mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + return nil, nil + }, + }, + text: "empty instance response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewInstallCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") + require.Error(t, err) + if test.want != nil { + require.ErrorIs(t, err, test.want) + } + if test.text != "" { + require.ErrorContains(t, err, test.text) + } + }) + } +} + +func TestInstallJSONStoresCompleteReturnedInstance(t *testing.T) { + returned := instanceFixture("stripe-eu") + returned.Metadata.Labels = map[string]string{"region": "eu"} + returned.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "TOKEN": {Value: stringPtr("preserved-in-json")}, + }} + client := mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + return &returned, nil + }, + } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "stripe", "--ledger=main", "--confirm", "--output=json") + + require.NoError(t, err) + var envelope struct { + Data InstallStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, returned, envelope.Data.Instance) +} + +func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { + standalone := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + require.Equal(t, "install ", standalone.Use) + require.Equal(t, []string{"create", "in"}, standalone.Aliases) + require.Error(t, standalone.Args(standalone, nil)) + require.NoError(t, standalone.Args(standalone, []string{"stripe"})) + require.Error(t, standalone.Args(standalone, []string{"stripe", "extra"})) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"install"}) + require.NoError(t, err) + require.Equal(t, "install", child.Name()) + require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) +} + +func TestInstallWiresPluginVersionSetAndFileCompletions(t *testing.T) { + plugin := pluginWithSchemaAndDefaults() + plugin.Metadata.Name = stringPtr("stripe") + plugin.Spec.Description = stringPtr("Stripe ingestion") + client := mutationClientMock{ + listPlugins: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{*plugin}}, nil + }, + getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return plugin, nil + }, + } + var pathPrefix string + paths := func(prefix string) ([]string, error) { + pathPrefix = prefix + return []string{"fixtures/token.txt"}, nil + } + factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { + require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) + return client, nil + } + command := NewInstallCommand(factory, mockReadFile(nil), paths) + + plugins, directive := command.ValidArgsFunction(command, nil, "str") + require.Equal(t, []string{"stripe\tStripe ingestion"}, plugins) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + versionCompletion, ok := command.GetFlagCompletionFunc("version") + require.True(t, ok) + versions, directive := versionCompletion(command, []string{"stripe"}, "2") + require.Equal(t, []string{"2.0.0\texample/plugin:2.0.0"}, versions) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + setCompletion, ok := command.GetFlagCompletionFunc("set") + require.True(t, ok) + sets, directive := setCompletion(command, []string{"stripe"}, "API") + require.Equal(t, []string{"API_URL=\tAPI endpoint"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) + + sets, directive = setCompletion(command, []string{"stripe"}, "TOKEN=@fixtures/t") + require.Equal(t, "fixtures/t", pathPrefix) + require.Equal(t, []string{"TOKEN=@fixtures/token.txt"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + _, configRegistered := command.GetFlagCompletionFunc("config") + _, envFileRegistered := command.GetFlagCompletionFunc("env-file") + require.False(t, configRegistered, "--config must retain Cobra's normal file completion") + require.False(t, envFileRegistered, "--env-file must retain Cobra's normal file completion") +} diff --git a/cmd/connectivity/instances/root.go b/cmd/connectivity/instances/root.go index 455c08c9..caf8a77f 100644 --- a/cmd/connectivity/instances/root.go +++ b/cmd/connectivity/instances/root.go @@ -7,7 +7,7 @@ import ( fctl "github.com/formancehq/fctl/v3/pkg" ) -func NewCommand(factory connectivityinternal.ClientFactory, _ ReadFileFunc, _ PathCompleter) *cobra.Command { +func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { return fctl.NewCommand( "instances", fctl.WithAliases("instance", "i"), @@ -15,6 +15,7 @@ func NewCommand(factory connectivityinternal.ClientFactory, _ ReadFileFunc, _ Pa fctl.WithChildCommands( NewListCommand(factory), NewShowCommand(factory), + NewInstallCommand(factory, read, paths), ), ) } From 0d5db54fbf2b07d1793135ab3c33d12410a7a2ca Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:13:27 +0200 Subject: [PATCH 10/21] feat(connectivity): configure and uninstall instances --- cmd/connectivity/instances/completion.go | 20 +- cmd/connectivity/instances/completion_test.go | 27 ++ cmd/connectivity/instances/configure.go | 161 ++++++++ cmd/connectivity/instances/configure_test.go | 347 ++++++++++++++++++ cmd/connectivity/instances/install_test.go | 21 ++ cmd/connectivity/instances/root.go | 2 + cmd/connectivity/instances/uninstall.go | 71 ++++ cmd/connectivity/instances/uninstall_test.go | 108 ++++++ 8 files changed, 753 insertions(+), 4 deletions(-) create mode 100644 cmd/connectivity/instances/configure.go create mode 100644 cmd/connectivity/instances/configure_test.go create mode 100644 cmd/connectivity/instances/uninstall.go create mode 100644 cmd/connectivity/instances/uninstall_test.go diff --git a/cmd/connectivity/instances/completion.go b/cmd/connectivity/instances/completion.go index 90313992..1451f477 100644 --- a/cmd/connectivity/instances/completion.go +++ b/cmd/connectivity/instances/completion.go @@ -51,12 +51,24 @@ func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.Com } func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func(*cobra.Command, []string) string) cobra.CompletionFunc { - return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeVersions(factory, func(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.Plugin, error) { if pluginArg == nil { - return nil, cobra.ShellCompDirectiveNoFileComp + return nil, nil } pluginName := pluginArg(cmd, args) if pluginName == "" { + return nil, nil + } + return client.GetPlugin(ctx, pluginName) + }) +} + +func completeVersions( + factory connectivityinternal.ClientFactory, + resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), +) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if resolvePlugin == nil { return nil, cobra.ShellCompDirectiveNoFileComp } client, completionCommand, cancel, ok := completionClient(cmd, factory) @@ -65,7 +77,7 @@ func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func } defer cancel() - plugin, err := client.GetPlugin(completionCommand.Context(), pluginName) + plugin, err := resolvePlugin(completionCommand.Context(), client, completionCommand, args) if err != nil || plugin == nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -200,7 +212,7 @@ func completionClient(cmd *cobra.Command, factory connectivityinternal.ClientFac completionCommand := *cmd completionCommand.SetContext(connectivityinternal.WithNonInteractive(ctx)) client, err := factory(&completionCommand) - if err != nil { + if err != nil || client == nil { cancel() return nil, nil, func() {}, false } diff --git a/cmd/connectivity/instances/completion_test.go b/cmd/connectivity/instances/completion_test.go index 53781bca..2be47954 100644 --- a/cmd/connectivity/instances/completion_test.go +++ b/cmd/connectivity/instances/completion_test.go @@ -192,6 +192,10 @@ func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors completion: CompleteInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }), command: &cobra.Command{}, }, + "instance nil client": { + completion: CompleteInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, nil }), + command: &cobra.Command{}, + }, "instance API": {completion: CompleteInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, "instance timeout": { completion: CompleteInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { @@ -228,6 +232,29 @@ func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors } } +func TestCompletionClientCopyDoesNotMutateOriginalCommandContext(t *testing.T) { + originalContext := context.WithValue(context.Background(), struct{}{}, "original") + command := &cobra.Command{} + command.SetContext(originalContext) + completion := CompleteInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("copied completion command must be non-interactive") + } + return completionClientMock{listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return &connectivityclient.InstanceList{}, nil + }}, nil + }) + + _, _ = completion(command, nil, "") + + if command.Context() != originalContext { + t.Fatal("completion mutated the original command context") + } + if connectivityinternal.IsNonInteractive(command.Context()) { + t.Fatal("original command context became non-interactive") + } +} + func hasTwoSecondDeadline(ctx context.Context) bool { deadline, ok := ctx.Deadline() if !ok { diff --git a/cmd/connectivity/instances/configure.go b/cmd/connectivity/instances/configure.go new file mode 100644 index 00000000..42fbb94b --- /dev/null +++ b/cmd/connectivity/instances/configure.go @@ -0,0 +1,161 @@ +package instances + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ConfigureStore struct { + Instance connectivityclient.Instance `json:"instance"` +} + +type ConfigureController struct { + factory connectivityinternal.ClientFactory + read ReadFileFunc + approve approvalFunc + store *ConfigureStore +} + +var _ fctl.Controller[*ConfigureStore] = (*ConfigureController)(nil) + +func NewConfigureController(factory connectivityinternal.ClientFactory, read ReadFileFunc) *ConfigureController { + return &ConfigureController{ + factory: factory, + read: read, + approve: fctl.CheckStackApprobation, + store: &ConfigureStore{}, + } +} + +func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { + controller := NewConfigureController(factory, read) + command := fctl.NewCommand( + "configure ", + fctl.WithAliases("config", "update", "c"), + fctl.WithShortDescription("Configure a Connectivity plugin instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), + fctl.WithStringFlag(versionFlag, "", "Plugin version"), + withInt64Flag(startSequenceFlag, 0, "Starting ledger sequence"), + fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), + fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), + fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), + fctl.WithStringArrayFlag(setFlag, nil, "Configuration value KEY=VALUE (repeatable)"), + fctl.WithConfirmFlag(), + fctl.WithController[*ConfigureStore](controller), + ) + if err := command.RegisterFlagCompletionFunc(versionFlag, completeVersions(factory, resolveConfigurePlugin)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveConfigurePlugin, paths)); err != nil { + panic(err) + } + return command +} + +func resolveConfigurePlugin(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (*connectivityclient.Plugin, error) { + if len(args) == 0 { + return nil, nil + } + instance, err := client.GetInstance(ctx, args[0]) + if err != nil || instance == nil { + return nil, err + } + return client.GetPlugin(ctx, instance.Spec.Plugin) +} + +func (c *ConfigureController) GetStore() *ConfigureStore { + return c.store +} + +func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + + name := args[0] + instance, err := client.GetInstance(cmd.Context(), name) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("configure connectivity instance %q: empty instance response", name) + } + plugin, err := client.GetPlugin(cmd.Context(), instance.Spec.Plugin) + if err != nil { + return nil, err + } + if plugin == nil { + return nil, fmt.Errorf("configure connectivity instance %q: empty plugin response", name) + } + + specPatch := map[string]any{} + configChanged := cmd.Flags().Changed(configFlag) || cmd.Flags().Changed(envFileFlag) || cmd.Flags().Changed(setFlag) + if configChanged { + envFiles, err := cmd.Flags().GetStringArray(envFileFlag) + if err != nil { + return nil, err + } + setValues, err := cmd.Flags().GetStringArray(setFlag) + if err != nil { + return nil, err + } + config, err := BuildConfigureConfig(cmd, plugin, instance.Spec.Config, InputOptions{ + ConfigFile: fctl.GetString(cmd, configFlag), + EnvFiles: envFiles, + SetValues: setValues, + }, c.read) + if err != nil { + return nil, err + } + specPatch["config"] = config + } + if cmd.Flags().Changed(versionFlag) { + specPatch["version"] = fctl.GetString(cmd, versionFlag) + } + if cmd.Flags().Changed(ledgerFlag) { + specPatch["ledger"] = fctl.GetString(cmd, ledgerFlag) + } + if cmd.Flags().Changed(startSequenceFlag) { + startSequence, err := cmd.Flags().GetInt64(startSequenceFlag) + if err != nil { + return nil, err + } + specPatch["startSequence"] = startSequence + } + if cmd.Flags().Changed(pollIntervalFlag) { + specPatch["pollInterval"] = fctl.GetString(cmd, pollIntervalFlag) + } + if len(specPatch) == 0 { + return nil, fmt.Errorf("no configuration changes requested") + } + if !c.approve(cmd, "You are about to configure Connectivity instance %q", name) { + return nil, fctl.ErrMissingApproval + } + + updated, err := client.PatchInstance(cmd.Context(), name, connectivityclient.InstancePatch{"spec": specPatch}) + if err != nil { + return nil, err + } + if updated == nil { + return nil, fmt.Errorf("configure connectivity instance %q: empty instance response", name) + } + c.store.Instance = *updated + return c, nil +} + +func (c *ConfigureController) Render(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Instance %q configured.\n", stringValue(c.store.Instance.Metadata.Name)) + return err +} diff --git a/cmd/connectivity/instances/configure_test.go b/cmd/connectivity/instances/configure_test.go new file mode 100644 index 00000000..9d652ccb --- /dev/null +++ b/cmd/connectivity/instances/configure_test.go @@ -0,0 +1,347 @@ +package instances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type configureClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) + getInstance func(context.Context, string) (*connectivityclient.Instance, error) + getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) + patch func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) +} + +func (m configureClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m configureClientMock) GetInstance(ctx context.Context, name string) (*connectivityclient.Instance, error) { + return m.getInstance(ctx, name) +} + +func (m configureClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + return m.getPlugin(ctx, name) +} + +func (m configureClientMock) PatchInstance(ctx context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + return m.patch(ctx, name, patch) +} + +func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing.T) { + mode := int32(384) + current := instanceFixture("stripe-eu") + current.Spec.Config = &connectivityclient.InstanceConfig{ + Env: map[string]connectivityclient.EnvValue{"API_URL": {Value: stringPtr("https://current.example")}}, + Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("old"), Mode: &mode}, + {Path: "/etc/b", SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "b"}}, + }, + } + plugin := pluginWithFileSchema() + plugin.Metadata.Name = stringPtr("stripe") + plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{{Path: "/etc/b", Value: stringPtr("must-not-replace-current")}}} + returned := instanceFixture("stripe-eu") + var order []string + var gotPatch connectivityclient.InstancePatch + client := configureClientMock{ + getInstance: func(_ context.Context, name string) (*connectivityclient.Instance, error) { + order = append(order, "instance:"+name) + return ¤t, nil + }, + getPlugin: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { + order = append(order, "plugin:"+name) + return plugin, nil + }, + patch: func(_ context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + order = append(order, "patch:"+name) + gotPatch = patch + return &returned, nil + }, + } + + output, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(map[string]string{"new": "changed"}), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=@new", "--version=3.0.0", "--ledger=archive", + "--start-sequence=0", "--poll-interval=15s", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, []string{"instance:stripe-eu", "plugin:stripe", "patch:stripe-eu"}, order) + spec, ok := gotPatch["spec"].(map[string]any) + require.True(t, ok) + require.Equal(t, "3.0.0", spec["version"]) + require.Equal(t, "archive", spec["ledger"]) + require.Equal(t, int64(0), spec["startSequence"]) + require.Equal(t, "15s", spec["pollInterval"]) + require.Len(t, spec, 5, "the immutable plugin and unchanged fields must not be patched") + config, ok := spec["config"].(*connectivityclient.InstanceConfig) + require.True(t, ok) + require.Equal(t, "https://current.example", *config.Env["API_URL"].Value, "current config must win over plugin defaults") + require.Len(t, config.Files, 2) + require.Equal(t, "/etc/a", config.Files[0].Path) + require.Equal(t, "changed", *config.Files[0].Value) + require.Equal(t, mode, *config.Files[0].Mode) + require.Equal(t, current.Spec.Config.Files[1], config.Files[1], "untouched file mounts must be preserved") + require.Equal(t, `Instance "stripe-eu" configured.`, strings.TrimSpace(output)) +} + +func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Config = &connectivityclient.InstanceConfig{} + returned := instanceFixture("stripe-eu") + returned.Metadata.Labels = map[string]string{"region": "eu"} + var gotPatch connectivityclient.InstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + patch: func(_ context.Context, _ string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + gotPatch = patch + return &returned, nil + }, + } + command := NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "stripe-eu", "--ledger=archive", "--confirm", "--output=json") + + require.NoError(t, err) + require.Equal(t, connectivityclient.InstancePatch{"spec": map[string]any{"ledger": "archive"}}, gotPatch) + var envelope struct { + Data ConfigureStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, returned, envelope.Data.Instance) +} + +func TestConfigureRejectsNoChangesWithoutPatch(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + patched = true + return nil, errors.New("PatchInstance must not be called") + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--confirm") + + require.EqualError(t, err, "no configuration changes requested") + require.False(t, patched) +} + +func TestConfigureConfirmationRejectionPreventsPatch(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + patched = true + return nil, errors.New("PatchInstance must not be called") + }, + } + controller := NewConfigureController(factoryReturning(client), mockReadFile(nil)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + command := NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + require.NoError(t, command.ParseFlags([]string{"--ledger=archive"})) + + _, err := controller.Run(command, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, patched) +} + +func TestConfigureValidationAndArgumentsPreventPatch(t *testing.T) { + t.Run("invalid configuration", func(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return pluginWithFileSchema(), nil }, + patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + patched = true + return nil, errors.New("PatchInstance must not be called") + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--set=UNKNOWN=value", "--confirm") + + require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") + require.False(t, patched) + }) + + t.Run("invalid arguments", func(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + command := NewConfigureCommand(factory, mockReadFile(nil), mockPathCompleter(nil)) + + _, err := executeCommand(command, "stripe-eu", "extra", "--ledger=archive", "--confirm") + + require.Error(t, err) + require.False(t, usedFactory) + }) +} + +func TestConfigurePreservesGetAndPatchAPIErrorsAndRejectsEmptyResponses(t *testing.T) { + instanceError := &connectivityclient.APIError{StatusCode: 404, Code: "INSTANCE_NOT_FOUND", Message: "missing"} + pluginError := &connectivityclient.APIError{StatusCode: 404, Code: "PLUGIN_NOT_FOUND", Message: "missing"} + patchError := &connectivityclient.APIError{StatusCode: 409, Code: "CONFLICT", Message: "stale"} + current := instanceFixture("stripe-eu") + tests := map[string]struct { + client configureClientMock + want error + text string + }{ + "instance API": { + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return nil, instanceError }}, + want: instanceError, + }, + "empty instance": { + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return nil, nil }}, + text: "empty instance response", + }, + "plugin API": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, pluginError }, + }, + want: pluginError, + }, + "empty plugin": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, nil }, + }, + text: "empty plugin response", + }, + "patch API": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + return nil, patchError + }, + }, + want: patchError, + }, + "empty patch": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + return nil, nil + }, + }, + text: "empty instance response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewConfigureCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--ledger=archive", "--confirm") + require.Error(t, err) + if test.want != nil { + require.ErrorIs(t, err, test.want) + } + if test.text != "" { + require.ErrorContains(t, err, test.text) + } + }) + } +} + +func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { + plugin := pluginWithFileSchema() + plugin.Metadata.Name = stringPtr("stripe") + current := instanceFixture("stripe-eu") + var pathPrefix string + client := configureClientMock{ + listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{current}}, nil + }, + getInstance: func(ctx context.Context, name string) (*connectivityclient.Instance, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe-eu", name) + return ¤t, nil + }, + getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return plugin, nil + }, + } + factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { + require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) + return client, nil + } + paths := func(prefix string) ([]string, error) { + pathPrefix = prefix + return []string{"fixtures/token.txt"}, nil + } + command := NewConfigureCommand(factory, mockReadFile(nil), paths) + + require.Equal(t, "configure ", command.Use) + require.Equal(t, []string{"config", "update", "c"}, command.Aliases) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + instances, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe ยท main"}, instances) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + versionCompletion, ok := command.GetFlagCompletionFunc(versionFlag) + require.True(t, ok) + versions, directive := versionCompletion(command, []string{"stripe-eu"}, "2") + require.Equal(t, []string{"2.0.0\texample/plugin:2.0.0"}, versions) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + setCompletion, ok := command.GetFlagCompletionFunc(setFlag) + require.True(t, ok) + sets, directive := setCompletion(command, []string{"stripe-eu"}, "/etc") + require.Equal(t, []string{"/etc/a=\tPrimary config", "/etc/b=\tfile configuration"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) + sets, directive = setCompletion(command, []string{"stripe-eu"}, "/etc/a=@fixtures/t") + require.Equal(t, "fixtures/t", pathPrefix) + require.Equal(t, []string{"/etc/a=@fixtures/token.txt"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + _, configRegistered := command.GetFlagCompletionFunc(configFlag) + _, envFileRegistered := command.GetFlagCompletionFunc(envFileFlag) + require.False(t, configRegistered) + require.False(t, envFileRegistered) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + require.Equal(t, "configure", child.Name()) + require.True(t, reflect.DeepEqual([]string{"config", "update", "c"}, child.Aliases)) +} diff --git a/cmd/connectivity/instances/install_test.go b/cmd/connectivity/instances/install_test.go index 7a97ae6d..c13ccbda 100644 --- a/cmd/connectivity/instances/install_test.go +++ b/cmd/connectivity/instances/install_test.go @@ -133,6 +133,27 @@ func TestInstallConfirmationRejectionPreventsCreateInstance(t *testing.T) { require.False(t, created) } +func TestInstallValidationErrorPreventsCreateInstance(t *testing.T) { + created := false + client := mutationClientMock{ + getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { + return &connectivityclient.Plugin{}, nil + }, + create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { + created = true + return nil, errors.New("CreateInstance must not be called") + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--set=UNKNOWN=value", "--confirm", + ) + + require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") + require.False(t, created) +} + func TestInstallPreservesPluginAndCreateAPIErrorsAndRejectsEmptyResponses(t *testing.T) { pluginError := &connectivityclient.APIError{StatusCode: 404, Code: "PLUGIN_NOT_FOUND", Message: "missing"} createError := &connectivityclient.APIError{StatusCode: 409, Code: "INSTANCE_EXISTS", Message: "duplicate"} diff --git a/cmd/connectivity/instances/root.go b/cmd/connectivity/instances/root.go index caf8a77f..c7e4bc3b 100644 --- a/cmd/connectivity/instances/root.go +++ b/cmd/connectivity/instances/root.go @@ -16,6 +16,8 @@ func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, p NewListCommand(factory), NewShowCommand(factory), NewInstallCommand(factory, read, paths), + NewConfigureCommand(factory, read, paths), + NewUninstallCommand(factory), ), ) } diff --git a/cmd/connectivity/instances/uninstall.go b/cmd/connectivity/instances/uninstall.go new file mode 100644 index 00000000..b95d0f93 --- /dev/null +++ b/cmd/connectivity/instances/uninstall.go @@ -0,0 +1,71 @@ +package instances + +import ( + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type UninstallStore struct { + Name string `json:"name"` +} + +type UninstallController struct { + factory connectivityinternal.ClientFactory + approve approvalFunc + store *UninstallStore +} + +var _ fctl.Controller[*UninstallStore] = (*UninstallController)(nil) + +func NewUninstallController(factory connectivityinternal.ClientFactory) *UninstallController { + return &UninstallController{ + factory: factory, + approve: fctl.CheckStackApprobation, + store: &UninstallStore{}, + } +} + +func NewUninstallCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewUninstallController(factory) + return fctl.NewCommand( + "uninstall ", + fctl.WithAliases("delete", "remove", "rm", "u"), + fctl.WithShortDescription("Uninstall a Connectivity plugin instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithConfirmFlag(), + fctl.WithController[*UninstallStore](controller), + ) +} + +func (c *UninstallController) GetStore() *UninstallStore { + return c.store +} + +func (c *UninstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + name := args[0] + if !c.approve(cmd, "You are about to uninstall Connectivity instance %q", name) { + return nil, fctl.ErrMissingApproval + } + if err := client.DeleteInstance(cmd.Context(), name); err != nil { + return nil, err + } + c.store.Name = name + return c, nil +} + +func (c *UninstallController) Render(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Instance %q uninstalled.\n", c.store.Name) + return err +} diff --git a/cmd/connectivity/instances/uninstall_test.go b/cmd/connectivity/instances/uninstall_test.go new file mode 100644 index 00000000..efc3de4f --- /dev/null +++ b/cmd/connectivity/instances/uninstall_test.go @@ -0,0 +1,108 @@ +package instances + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type uninstallClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) + delete func(context.Context, string) error +} + +func (m uninstallClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m uninstallClientMock) DeleteInstance(ctx context.Context, name string) error { + return m.delete(ctx, name) +} + +func TestUninstallDeletesConfirmedInstanceAndRendersSuccess(t *testing.T) { + var deletedName string + client := uninstallClientMock{delete: func(_ context.Context, name string) error { + deletedName = name + return nil + }} + + output, err := executeCommand(NewUninstallCommand(factoryReturning(client)), "stripe-eu", "--confirm") + + require.NoError(t, err) + require.Equal(t, "stripe-eu", deletedName) + require.Equal(t, `Instance "stripe-eu" uninstalled.`, strings.TrimSpace(output)) +} + +func TestUninstallConfirmationRejectionPreventsDelete(t *testing.T) { + deleted := false + client := uninstallClientMock{delete: func(context.Context, string) error { + deleted = true + return errors.New("DeleteInstance must not be called") + }} + controller := NewUninstallController(factoryReturning(client)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + + _, err := controller.Run(&cobra.Command{}, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, deleted) +} + +func TestUninstallPreservesDeleteAPIError(t *testing.T) { + apiError := &connectivityclient.APIError{StatusCode: 404, Code: "INSTANCE_NOT_FOUND", Message: "missing"} + client := uninstallClientMock{delete: func(context.Context, string) error { return apiError }} + + _, err := executeCommand(NewUninstallCommand(factoryReturning(client)), "stripe-eu", "--confirm") + + require.ErrorIs(t, err, apiError) +} + +func TestUninstallRegistersAliasesRootAndInstanceCompletion(t *testing.T) { + instance := instanceFixture("stripe-eu") + client := uninstallClientMock{ + listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{instance}}, nil + }, + } + command := NewUninstallCommand(factoryReturning(client)) + + require.Equal(t, "uninstall ", command.Use) + require.Equal(t, []string{"delete", "remove", "rm", "u"}, command.Aliases) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + candidates, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe ยท main"}, candidates) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"uninstall"}) + require.NoError(t, err) + require.Equal(t, "uninstall", child.Name()) + require.True(t, reflect.DeepEqual([]string{"delete", "remove", "rm", "u"}, child.Aliases)) +} + +func TestUninstallInvalidArgumentsDoNotDelete(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + + _, err := executeCommand(NewUninstallCommand(factory), "stripe-eu", "extra", "--confirm") + + require.Error(t, err) + require.False(t, usedFactory) +} From ce26e925a8b25d8171bc0b1d4327a67e3da6c7d4 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:21:02 +0200 Subject: [PATCH 11/21] feat(connectivity): expose fctl command tree --- cmd/connectivity/root.go | 21 +++++++++++++++++++++ cmd/connectivity/root_test.go | 29 +++++++++++++++++++++++++++++ cmd/root.go | 2 ++ cmd/root_test.go | 13 +++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 cmd/connectivity/root.go create mode 100644 cmd/connectivity/root_test.go create mode 100644 cmd/root_test.go diff --git a/cmd/connectivity/root.go b/cmd/connectivity/root.go new file mode 100644 index 00000000..15c1dabc --- /dev/null +++ b/cmd/connectivity/root.go @@ -0,0 +1,21 @@ +package connectivity + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v3/cmd/connectivity/instances" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + "github.com/formancehq/fctl/v3/cmd/connectivity/plugins" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand() *cobra.Command { + factory := connectivityinternal.NewClientFactory() + return fctl.NewStackCommand("connectivity", + fctl.WithShortDescription("Manage Connectivity plugins and instances"), + fctl.WithChildCommands( + plugins.NewCommand(factory), + instances.NewCommand(factory, fctl.ReadFile, instances.OSPathCompleter), + ), + ) +} diff --git a/cmd/connectivity/root_test.go b/cmd/connectivity/root_test.go new file mode 100644 index 00000000..57ebff21 --- /dev/null +++ b/cmd/connectivity/root_test.go @@ -0,0 +1,29 @@ +package connectivity + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommandTreeMatchesConnectivityUX(t *testing.T) { + cmd := NewCommand() + require.Equal(t, "connectivity", cmd.Name()) + + for _, path := range [][]string{ + {"plugins", "list"}, + {"plugins", "show"}, + {"instances", "list"}, + {"instances", "show"}, + {"instances", "install"}, + {"instances", "configure"}, + {"instances", "uninstall"}, + } { + found, _, err := cmd.Find(path) + require.NoError(t, err) + require.Equal(t, path[len(path)-1], found.Name()) + } + + require.NotNil(t, cmd.PersistentFlags().Lookup("stack")) + require.NotNil(t, cmd.PersistentFlags().Lookup("organization")) +} diff --git a/cmd/root.go b/cmd/root.go index 48d89984..a955a2af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/formancehq/fctl/v3/cmd/auth" "github.com/formancehq/fctl/v3/cmd/cloud" + "github.com/formancehq/fctl/v3/cmd/connectivity" "github.com/formancehq/fctl/v3/cmd/ledger" "github.com/formancehq/fctl/v3/cmd/login" "github.com/formancehq/fctl/v3/cmd/orchestration" @@ -61,6 +62,7 @@ func NewRootCommand() *cobra.Command { webhooks.NewCommand(), wallets.NewCommand(), orchestration.NewCommand(), + connectivity.NewCommand(), ), fctl.WithPersistentStringPFlag(fctl.ProfileFlag, "p", "", "Configuration profile to use"), fctl.WithPersistentStringPFlag(fctl.ConfigDir, "c", fmt.Sprintf("%s/.config/formance/fctl", homedir), "Path to configuration dir"), diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..7c6b8dea --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRootCommandRegistersConnectivity(t *testing.T) { + found, _, err := NewRootCommand().Find([]string{"connectivity"}) + require.NoError(t, err) + require.Equal(t, "connectivity", found.Name()) +} From e37a6832e4ef2cef679d49c64893db66c0d2d940 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:49:30 +0200 Subject: [PATCH 12/21] fix(connectivity): harden API compatibility --- cmd/connectivity/instances/config.go | 4 +- cmd/connectivity/instances/config_test.go | 21 +++ cmd/connectivity/instances/configure.go | 8 - cmd/connectivity/instances/configure_test.go | 11 +- cmd/connectivity/instances/install.go | 29 +-- cmd/connectivity/instances/install_test.go | 9 +- cmd/connectivity/instances/show.go | 1 - cmd/connectivity/instances/show_test.go | 13 +- .../plans/2026-08-07-connectivity-cli.md | 10 +- .../2026-08-07-connectivity-cli-design.md | 20 ++- internal/connectivityclient/client.go | 110 +++++++++++- internal/connectivityclient/client_test.go | 169 +++++++++++++++++ .../connectivityclient/integration_test.go | 51 ++++++ pkg/clients.go | 12 +- pkg/clients_test.go | 170 ++++++++++++++++++ pkg/http.go | 40 +++++ pkg/http_test.go | 86 +++++++++ 17 files changed, 710 insertions(+), 54 deletions(-) create mode 100644 pkg/clients_test.go diff --git a/cmd/connectivity/instances/config.go b/cmd/connectivity/instances/config.go index 6bf52b02..880b3d38 100644 --- a/cmd/connectivity/instances/config.go +++ b/cmd/connectivity/instances/config.go @@ -246,11 +246,11 @@ func parseReference(raw, kind string) (*connectivityclient.KeyRef, error) { func parseAssignment(raw string) (string, string, error) { parts := strings.SplitN(raw, "=", 2) if len(parts) != 2 { - return "", "", fmt.Errorf("invalid --set %q: expected KEY=value", raw) + return "", "", fmt.Errorf("invalid --set: expected KEY=value") } key := strings.TrimSpace(parts[0]) if key == "" { - return "", "", fmt.Errorf("invalid --set %q: empty key", raw) + return "", "", fmt.Errorf("invalid --set: empty key") } return key, parts[1], nil } diff --git a/cmd/connectivity/instances/config_test.go b/cmd/connectivity/instances/config_test.go index 8d37a5e4..8817f111 100644 --- a/cmd/connectivity/instances/config_test.go +++ b/cmd/connectivity/instances/config_test.go @@ -275,6 +275,27 @@ func TestBuildInstallConfigRejectsMalformedAssignmentsAndReferences(t *testing.T } } +func TestBuildInstallConfigMalformedSetErrorDoesNotEchoSecretAssignment(t *testing.T) { + const secretSentinel = "SECRET-SENTINEL-MUST-NOT-LEAK" + tests := []string{ + "TOKEN-" + secretSentinel, + "=" + secretSentinel, + } + + for _, assignment := range tests { + _, err := BuildInstallConfig( + &cobra.Command{}, + pluginWithSchemaAndDefaults(), + InputOptions{SetValues: []string{assignment}}, + mapReadFile(nil), + ) + + require.Error(t, err) + require.NotContains(t, err.Error(), assignment) + require.NotContains(t, err.Error(), secretSentinel) + } +} + func TestBuildInstallConfigReturnsInputReadAndParseErrors(t *testing.T) { t.Run("unreadable config", func(t *testing.T) { _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "missing.yaml"}, mapReadFile(nil)) diff --git a/cmd/connectivity/instances/configure.go b/cmd/connectivity/instances/configure.go index 42fbb94b..6d3a03c0 100644 --- a/cmd/connectivity/instances/configure.go +++ b/cmd/connectivity/instances/configure.go @@ -43,7 +43,6 @@ func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFi fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), fctl.WithStringFlag(versionFlag, "", "Plugin version"), - withInt64Flag(startSequenceFlag, 0, "Starting ledger sequence"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -127,13 +126,6 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende if cmd.Flags().Changed(ledgerFlag) { specPatch["ledger"] = fctl.GetString(cmd, ledgerFlag) } - if cmd.Flags().Changed(startSequenceFlag) { - startSequence, err := cmd.Flags().GetInt64(startSequenceFlag) - if err != nil { - return nil, err - } - specPatch["startSequence"] = startSequence - } if cmd.Flags().Changed(pollIntervalFlag) { specPatch["pollInterval"] = fctl.GetString(cmd, pollIntervalFlag) } diff --git a/cmd/connectivity/instances/configure_test.go b/cmd/connectivity/instances/configure_test.go index 9d652ccb..55c333f1 100644 --- a/cmd/connectivity/instances/configure_test.go +++ b/cmd/connectivity/instances/configure_test.go @@ -75,7 +75,7 @@ func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing output, err := executeCommand( NewConfigureCommand(factoryReturning(client), mockReadFile(map[string]string{"new": "changed"}), mockPathCompleter(nil)), "stripe-eu", "--set=/etc/a=@new", "--version=3.0.0", "--ledger=archive", - "--start-sequence=0", "--poll-interval=15s", "--confirm", + "--poll-interval=15s", "--confirm", ) require.NoError(t, err) @@ -84,9 +84,8 @@ func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing require.True(t, ok) require.Equal(t, "3.0.0", spec["version"]) require.Equal(t, "archive", spec["ledger"]) - require.Equal(t, int64(0), spec["startSequence"]) require.Equal(t, "15s", spec["pollInterval"]) - require.Len(t, spec, 5, "the immutable plugin and unchanged fields must not be patched") + require.Len(t, spec, 4, "the immutable plugin and unchanged fields must not be patched") config, ok := spec["config"].(*connectivityclient.InstanceConfig) require.True(t, ok) require.Equal(t, "https://current.example", *config.Env["API_URL"].Value, "current config must win over plugin defaults") @@ -345,3 +344,9 @@ func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { require.Equal(t, "configure", child.Name()) require.True(t, reflect.DeepEqual([]string{"config", "update", "c"}, child.Aliases)) } + +func TestConfigureDoesNotExposeUnsupportedStartSequence(t *testing.T) { + command := NewConfigureCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + + require.Nil(t, command.Flags().Lookup("start-sequence")) +} diff --git a/cmd/connectivity/instances/install.go b/cmd/connectivity/instances/install.go index 7f303ad8..3e5d193b 100644 --- a/cmd/connectivity/instances/install.go +++ b/cmd/connectivity/instances/install.go @@ -13,14 +13,13 @@ import ( ) const ( - nameFlag = "name" - ledgerFlag = "ledger" - versionFlag = "version" - startSequenceFlag = "start-sequence" - pollIntervalFlag = "poll-interval" - configFlag = "config" - envFileFlag = "env-file" - setFlag = "set" + nameFlag = "name" + ledgerFlag = "ledger" + versionFlag = "version" + pollIntervalFlag = "poll-interval" + configFlag = "config" + envFileFlag = "env-file" + setFlag = "set" ) type InstallStore struct { @@ -58,7 +57,6 @@ func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFile fctl.WithStringFlag(nameFlag, "", "Instance name (defaults to the plugin name)"), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), fctl.WithStringFlag(versionFlag, "", "Plugin version"), - withInt64Flag(startSequenceFlag, 0, "Starting ledger sequence"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -78,12 +76,6 @@ func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFile return command } -func withInt64Flag(name string, defaultValue int64, help string) fctl.CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().Int64(name, defaultValue, help) - } -} - func installPluginArgument(_ *cobra.Command, args []string) string { if len(args) == 0 { return "" @@ -156,13 +148,6 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera } spec.Version = fctl.Ptr(value) } - if cmd.Flags().Changed(startSequenceFlag) { - value, err := cmd.Flags().GetInt64(startSequenceFlag) - if err != nil { - return nil, err - } - spec.StartSequence = fctl.Ptr(value) - } if cmd.Flags().Changed(pollIntervalFlag) { value, err := cmd.Flags().GetString(pollIntervalFlag) if err != nil { diff --git a/cmd/connectivity/instances/install_test.go b/cmd/connectivity/instances/install_test.go index c13ccbda..2f271bb7 100644 --- a/cmd/connectivity/instances/install_test.go +++ b/cmd/connectivity/instances/install_test.go @@ -58,7 +58,7 @@ func TestInstallBuildsInstanceFromPluginSchemaAndOnlySetsChangedScalars(t *testi output, err := executeCommand( NewInstallCommand(factoryReturning(client), read, mockPathCompleter(nil)), "stripe", "--name=stripe-eu", "--ledger=main", "--version=2.0.0", - "--start-sequence=0", "--poll-interval=3s", "--config=install.yaml", + "--poll-interval=3s", "--config=install.yaml", "--env-file=plugin.env", "--set=TOKEN=@token.txt", "--confirm", ) @@ -68,7 +68,6 @@ func TestInstallBuildsInstanceFromPluginSchemaAndOnlySetsChangedScalars(t *testi require.Equal(t, "stripe", gotBody.Spec.Plugin) require.Equal(t, "main", gotBody.Spec.Ledger) require.Equal(t, "2.0.0", *gotBody.Spec.Version) - require.Equal(t, int64(0), *gotBody.Spec.StartSequence) require.Equal(t, "3s", *gotBody.Spec.PollInterval) require.Equal(t, "https://config.example", *gotBody.Spec.Config.Env["API_URL"].Value) require.Equal(t, "file-token", *gotBody.Spec.Config.Env["TOKEN"].Value) @@ -250,6 +249,12 @@ func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) } +func TestInstallDoesNotExposeUnsupportedStartSequence(t *testing.T) { + command := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + + require.Nil(t, command.Flags().Lookup("start-sequence")) +} + func TestInstallWiresPluginVersionSetAndFileCompletions(t *testing.T) { plugin := pluginWithSchemaAndDefaults() plugin.Metadata.Name = stringPtr("stripe") diff --git a/cmd/connectivity/instances/show.go b/cmd/connectivity/instances/show.go index f9bec2f8..9576ed83 100644 --- a/cmd/connectivity/instances/show.go +++ b/cmd/connectivity/instances/show.go @@ -86,7 +86,6 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { {pterm.LightCyan("Version"), stringValue(instance.Spec.Version)}, {pterm.LightCyan("Connectivity Reference"), stringValue(instance.Spec.ConnectivityRef)}, {pterm.LightCyan("Ledger"), instance.Spec.Ledger}, - {pterm.LightCyan("Start Sequence"), int64Value(instance.Spec.StartSequence)}, {pterm.LightCyan("Poll Interval"), stringValue(instance.Spec.PollInterval)}, }); err != nil { return err diff --git a/cmd/connectivity/instances/show_test.go b/cmd/connectivity/instances/show_test.go index 299684aa..fc55975f 100644 --- a/cmd/connectivity/instances/show_test.go +++ b/cmd/connectivity/instances/show_test.go @@ -51,7 +51,7 @@ func TestShowInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConf for _, expected := range []string{ "Information", "Name", "stripe-eu", "Namespace", "formance", "UID", "instance-uid", "Resource Version", "42", "Created At", created.Format(time.RFC3339), "Labels", "region=eu", "Annotations", "owner=platform", - "Desired Specification", "Plugin", "stripe", "Version", "2.0.0", "Ledger", "main", "Start Sequence", "10", "Poll Interval", "5s", + "Desired Specification", "Plugin", "stripe", "Version", "2.0.0", "Ledger", "main", "Poll Interval", "5s", "Lifecycle", "Resolved Image", "registry/plugin:2.0.0", "Plugin Address", "http://stripe.default.svc", "Phase", "Ready", "State", "Running", "Ingestion Progress", "Current Sequence", "42", "Source Tip Sequence", "48", "Last Error", "source temporarily unavailable", "Message", "retrying ingestion", "Configuration", "API_KEY", "environment", "inline", "TOKEN", "secret:plugin-secrets/token", @@ -79,6 +79,17 @@ func TestShowPlainOutputNeverPrintsInlineConfigValues(t *testing.T) { require.NotContains(t, output, "private config") } +func TestShowPlainOutputOmitsUnsupportedStartSequence(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.StartSequence = fctl.Ptr(int64(987654321)) + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.NotContains(t, output, "Start Sequence") + require.NotContains(t, output, "987654321") +} + func TestShowInstanceJSONPreservesCompleteModel(t *testing.T) { instance := instanceFixture("stripe-eu") instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ diff --git a/docs/superpowers/plans/2026-08-07-connectivity-cli.md b/docs/superpowers/plans/2026-08-07-connectivity-cli.md index 4ee6face..68e6952b 100644 --- a/docs/superpowers/plans/2026-08-07-connectivity-cli.md +++ b/docs/superpowers/plans/2026-08-07-connectivity-cli.md @@ -16,6 +16,11 @@ - Add no Connectivity profile, token, context, API URL flag, generated client, or new dependency. - Never print inline configuration values in plain output; JSON retains the API model. - Dynamic completion has a two-second timeout, fails silently, and never prompts. +- Keep controller/client PATCH inputs API-shaped as `spec.config`; adapt them to + pinned commit `2ec12564`'s root CRD `spec.env`/`spec.files` only at the HTTP + boundary, preserving scalar fields and the complete files array. +- Do not expose API 0.1.0's `startSequence` through CLI flags: the pinned CRD + uses an opaque byte-valued `initialCursor` and provides no valid mapping. - Unit tests use mocks; HTTP integration test names use Given/When/Then. - New handwritten packages maintain at least 80% statement coverage. - Run all tooling through Nix and Just, including tests and precommit before each commit. @@ -121,7 +126,7 @@ func New(stackURI string, httpClient *http.Client) Client - [ ] **Step 4: Implement minimal HTTP wrappers** -Use one JSON request helper. Join `/api/connectivity`, escape names with `url.PathEscape`, use `url.Values`, set `Accept`, set POST content type to `application/json`, PATCH to `application/merge-patch+json`, parse `{code,message,details}` into `APIError`, and reject malformed or empty object responses. Accept 200 for reads and patch, 201 for create, and 204 for delete. +Use one JSON request helper. Join `/api/connectivity`, escape names with `url.PathEscape`, use `url.Values`, set `Accept`, set POST content type to `application/json`, PATCH to `application/merge-patch+json`, parse `{code,message,details}` into `APIError`, and reject malformed or empty object responses. Accept 200 for reads and patch, 201 for create, and 204 for delete. At the PATCH HTTP boundary, reshape API `spec.config.env/files` to the pinned CRD's root `spec.env/files`, remove `config`, and preserve other spec fields. - [ ] **Step 5: Verify GREEN, coverage, repository gates, and commit** @@ -394,7 +399,7 @@ Expected: compilation fails because `NewInstallCommand` does not exist. - [ ] **Step 3: Implement flags, completions, controller, and renderer** -Build `install ` with aliases `create,in`, exact one argument, `--name`, required `--ledger`, optional `--version`, `--start-sequence`, `--poll-interval`, `--config`, repeatable `--env-file`, repeatable `--set`, and confirmation. Fetch plugin, call `BuildInstallConfig`, approve, POST, store the returned instance, and render: +Build `install ` with aliases `create,in`, exact one argument, `--name`, required `--ledger`, optional `--version`, `--poll-interval`, `--config`, repeatable `--env-file`, repeatable `--set`, and confirmation. Fetch plugin, call `BuildInstallConfig`, approve, POST, store the returned instance, and render: ```text Instance "stripe-eu" installed with plugin "stripe" for ledger "main". @@ -464,7 +469,6 @@ specPatch := map[string]any{} if configChanged { specPatch["config"] = mergedConfig } if cmd.Flags().Changed("version") { specPatch["version"] = version } if cmd.Flags().Changed("ledger") { specPatch["ledger"] = ledger } -if cmd.Flags().Changed("start-sequence") { specPatch["startSequence"] = startSequence } if cmd.Flags().Changed("poll-interval") { specPatch["pollInterval"] = pollInterval } patch := connectivityclient.InstancePatch{"spec": specPatch} ``` diff --git a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md index c7bda2eb..d0c24e33 100644 --- a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md +++ b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md @@ -80,7 +80,7 @@ calls `POST /instances`. It accepts: - `--name`, defaulting to the plugin name; - required `--ledger`; -- optional `--version`, `--start-sequence`, and `--poll-interval`; +- optional `--version` and `--poll-interval`; - optional `--config `; - repeatable `--env-file `; - repeatable `--set KEY=VALUE`; @@ -90,8 +90,13 @@ calls `POST /instances`. It accepts: `GET /plugins/{plugin}` for schema-aware parsing, and finally `PATCH /instances/{name}` with `application/merge-patch+json`. It accepts the same configuration inputs as install. It also accepts optional `--version`, -`--ledger`, `--start-sequence`, and `--poll-interval`; only explicitly supplied -fields are patched. At least one change must be supplied. +`--ledger`, and `--poll-interval`; only explicitly supplied fields are patched. +At least one change must be supplied. + +Although the API 0.1.0 model includes `startSequence`, the pinned server drops +the CRD's opaque byte-valued `initialCursor` on reads and has no valid write +mapping from an integer sequence. The CLI intentionally does not expose a +`--start-sequence` flag rather than accepting a value that cannot take effect. `instances uninstall ` calls `DELETE /instances/{name}` after the standard `fctl` confirmation flow. @@ -142,9 +147,12 @@ precedence: During configure, the instance's current configuration replaces plugin defaults as the lowest-precedence base. This preserves every untouched env -entry and file mount. The command sends the resulting complete `config` object -inside the merge patch, so changing one file mount cannot accidentally replace -the other mounts in the API's array-valued `files` field. +entry and file mount. Controllers keep the API-shaped complete `config` object +inside the merge patch. Immediately before the HTTP request, the Connectivity +client adapts it to root CRD `spec.env` and `spec.files`, removing `config`, +because pinned commit `2ec12564` applies PATCH directly to the CRD and its +password materializer reads those root fields. Sending the complete `files` +array ensures changing one mount cannot accidentally replace the other mounts. The plugin's `configSchema` determines whether a key is an environment entry or a file mount. Required keys are validated before the write request. Invalid diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go index 4437eb0c..7f3a131b 100644 --- a/internal/connectivityclient/client.go +++ b/internal/connectivityclient/client.go @@ -64,13 +64,57 @@ func (c *client) GetInstance(ctx context.Context, name string) (*Instance, error } func (c *client) PatchInstance(ctx context.Context, name string, patch InstancePatch) (*Instance, error) { + wirePatch, err := adaptInstancePatchForPinnedAPI(patch) + if err != nil { + return nil, err + } result := &Instance{} - if err := c.requestJSON(ctx, http.MethodPatch, "instances", name, nil, patch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { + if err := c.requestJSON(ctx, http.MethodPatch, "instances", name, nil, wirePatch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } +// adaptInstancePatchForPinnedAPI bridges the API 0.1.0 model to Connectivity +// commit 2ec12564's PATCH handler. That handler applies the patch directly to +// the Instance CRD and materializes passwords by reading spec.env/spec.files, +// while read/create mapping exposes those fields as spec.config. +func adaptInstancePatchForPinnedAPI(patch InstancePatch) (InstancePatch, error) { + encoded, err := json.Marshal(patch) + if err != nil { + return nil, fmt.Errorf("marshal connectivity instance patch: %w", err) + } + wirePatch := InstancePatch{} + if err := json.Unmarshal(encoded, &wirePatch); err != nil { + return nil, fmt.Errorf("reshape connectivity instance patch: %w", err) + } + spec, ok := wirePatch["spec"].(map[string]any) + if !ok { + return wirePatch, nil + } + config, exists := spec["config"] + if !exists { + return wirePatch, nil + } + delete(spec, "config") + if config == nil { + spec["env"] = nil + spec["files"] = nil + return wirePatch, nil + } + configObject, ok := config.(map[string]any) + if !ok { + return nil, fmt.Errorf("reshape connectivity instance patch: spec.config must be an object") + } + if env, ok := configObject["env"]; ok { + spec["env"] = env + } + if files, ok := configObject["files"]; ok { + spec["files"] = files + } + return wirePatch, nil +} + func (c *client) DeleteInstance(ctx context.Context, name string) error { return c.requestJSON(ctx, http.MethodDelete, "instances", name, nil, nil, "", http.StatusNoContent, nil, false) } @@ -170,12 +214,72 @@ func decodeResponse(body io.Reader, destination any, requireObject bool) error { if len(trimmed) == 0 { return fmt.Errorf("decode connectivity response: empty response body") } - if requireObject && (bytes.Equal(trimmed, []byte("{}")) || bytes.Equal(trimmed, []byte("null"))) { - return fmt.Errorf("decode connectivity response: empty object response") + if requireObject { + var root any + if err := json.Unmarshal(trimmed, &root); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return fmt.Errorf("decode connectivity response: root must be an object") + } } if err := json.Unmarshal(trimmed, destination); err != nil { return fmt.Errorf("decode connectivity response: %w", err) } + if err := validateResponse(destination); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + return nil +} + +func validateResponse(destination any) error { + switch value := destination.(type) { + case *Plugin: + return validatePlugin(value) + case *PluginList: + if value.Items == nil { + return fmt.Errorf("items must be a non-null array") + } + for i := range value.Items { + if err := validatePlugin(&value.Items[i]); err != nil { + return fmt.Errorf("items[%d]: %w", i, err) + } + } + case *Instance: + return validateInstance(value) + case *InstanceList: + if value.Items == nil { + return fmt.Errorf("items must be a non-null array") + } + for i := range value.Items { + if err := validateInstance(&value.Items[i]); err != nil { + return fmt.Errorf("items[%d]: %w", i, err) + } + } + } + return nil +} + +func validatePlugin(plugin *Plugin) error { + if plugin == nil || plugin.Metadata.Name == nil || strings.TrimSpace(*plugin.Metadata.Name) == "" { + return fmt.Errorf("plugin metadata.name is required") + } + if strings.TrimSpace(plugin.Spec.Image) == "" { + return fmt.Errorf("plugin spec.image is required") + } + return nil +} + +func validateInstance(instance *Instance) error { + if instance == nil || instance.Metadata.Name == nil || strings.TrimSpace(*instance.Metadata.Name) == "" { + return fmt.Errorf("instance metadata.name is required") + } + if strings.TrimSpace(instance.Spec.Plugin) == "" { + return fmt.Errorf("instance spec.plugin is required") + } + if strings.TrimSpace(instance.Spec.Ledger) == "" { + return fmt.Errorf("instance spec.ledger is required") + } return nil } diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go index 82303064..57df8899 100644 --- a/internal/connectivityclient/client_test.go +++ b/internal/connectivityclient/client_test.go @@ -183,6 +183,49 @@ func TestClientReturnsStructuredAPIError(t *testing.T) { require.Equal(t, map[string]any{"field": "plugin"}, apiErr.Details) } +func TestPatchInstanceAdaptsAPIConfigToPinnedCRDWireShape(t *testing.T) { + var seenBody []byte + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var err error + seenBody, err = io.ReadAll(req.Body) + require.NoError(t, err) + return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"main"}}`), nil + })} + password := "env-password" + privateKey := "file-password" + config := &InstanceConfig{ + Env: map[string]EnvValue{ + "API_PASSWORD": {Value: &password}, + }, + Files: []FileMount{ + {Path: "/etc/plugin/key.pem", Value: &privateKey}, + {Path: "/etc/plugin/config.json", ConfigMapRef: &KeyRef{Name: "plugin-config", Key: "config.json"}}, + }, + } + + _, err := New("https://stack.example", httpClient).PatchInstance(context.Background(), "worker", InstancePatch{ + "spec": map[string]any{ + "config": config, + "ledger": "main", + "pollInterval": "15s", + }, + }) + + require.NoError(t, err) + require.JSONEq(t, `{ + "spec": { + "env": {"API_PASSWORD": {"value": "env-password"}}, + "files": [ + {"path": "/etc/plugin/key.pem", "value": "file-password"}, + {"path": "/etc/plugin/config.json", "configMapRef": {"name": "plugin-config", "key": "config.json"}} + ], + "ledger": "main", + "pollInterval": "15s" + } + }`, string(seenBody)) + require.NotContains(t, string(seenBody), `"config"`) +} + func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { tests := []struct { name string @@ -235,3 +278,129 @@ func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { }) } } + +func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { + tests := []struct { + name string + status int + body string + call func(Client) error + }{ + { + name: "plugin root is not an object", + status: http.StatusOK, + body: `[]`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "whitespace-only plugin object", + status: http.StatusOK, + body: "{ \n\t }", + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "plugin list missing items", + status: http.StatusOK, + body: `{"continue":"next"}`, + call: func(client Client) error { + _, err := client.ListPlugins(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "instance list has null items", + status: http.StatusOK, + body: `{"items":null}`, + call: func(client Client) error { + _, err := client.ListInstances(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "plugin missing metadata name", + status: http.StatusOK, + body: `{"metadata":{},"spec":{"image":"image"}}`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "plugin missing image", + status: http.StatusOK, + body: `{"metadata":{"name":"stripe"},"spec":{}}`, + call: func(client Client) error { + _, err := client.GetPlugin(context.Background(), "stripe") + return err + }, + }, + { + name: "plugin list item missing required fields", + status: http.StatusOK, + body: `{"items":[{"metadata":{"name":"stripe"},"spec":{}}]}`, + call: func(client Client) error { + _, err := client.ListPlugins(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "instance missing metadata name", + status: http.StatusOK, + body: `{"metadata":{},"spec":{"plugin":"stripe","ledger":"main"}}`, + call: func(client Client) error { + _, err := client.GetInstance(context.Background(), "worker") + return err + }, + }, + { + name: "instance missing plugin", + status: http.StatusCreated, + body: `{"metadata":{"name":"worker"},"spec":{"ledger":"main"}}`, + call: func(client Client) error { + _, err := client.CreateInstance(context.Background(), InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "main"}}) + return err + }, + }, + { + name: "instance list item missing ledger", + status: http.StatusOK, + body: `{"items":[{"metadata":{"name":"worker"},"spec":{"plugin":"stripe"}}]}`, + call: func(client Client) error { + _, err := client.ListInstances(context.Background(), ListOptions{}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(tt.status, tt.body), nil + })} + + err := tt.call(New("https://stack.example", httpClient)) + + require.Error(t, err) + }) + } +} + +func TestClientAcceptsValidEmptyLists(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"items":[]}`), nil + })} + client := New("https://stack.example", httpClient) + + plugins, err := client.ListPlugins(context.Background(), ListOptions{}) + require.NoError(t, err) + require.Empty(t, plugins.Items) + instances, err := client.ListInstances(context.Background(), ListOptions{}) + require.NoError(t, err) + require.Empty(t, instances.Items) +} diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go index fb7dc10e..740b70cc 100644 --- a/internal/connectivityclient/integration_test.go +++ b/internal/connectivityclient/integration_test.go @@ -2,6 +2,7 @@ package connectivityclient import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -67,3 +68,53 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected require.Equal(t, "worker", *fetched.Metadata.Name) require.Equal(t, "worker", *patched.Metadata.Name) } + +func TestGivenPinnedConnectivityPatchHandler_WhenAPIConfigIsPatched_ThenCRDShapeIsSent(t *testing.T) { + // Given a server that applies the PATCH body directly to the pinned Instance CRD. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + require.Equal(t, http.MethodPatch, req.Method) + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + + var patch map[string]any + require.NoError(t, json.NewDecoder(req.Body).Decode(&patch)) + spec, ok := patch["spec"].(map[string]any) + require.True(t, ok) + require.NotContains(t, spec, "config") + require.Equal(t, "30s", spec["pollInterval"]) + require.Equal(t, map[string]any{ + "API_PASSWORD": map[string]any{"value": "env-password"}, + }, spec["env"]) + require.Equal(t, []any{ + map[string]any{"path": "/etc/plugin/key.pem", "value": "file-password"}, + map[string]any{"path": "/etc/plugin/config.json", "value": "complete-file-array"}, + }, spec["files"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"main"}}`) + })) + defer server.Close() + + client := New(server.URL, server.Client()) + envPassword := "env-password" + filePassword := "file-password" + otherFile := "complete-file-array" + + // When an API-shaped config patch contains password-compatible inline values. + _, err := client.PatchInstance(context.Background(), "worker", InstancePatch{ + "spec": map[string]any{ + "config": &InstanceConfig{ + Env: map[string]EnvValue{ + "API_PASSWORD": {Value: &envPassword}, + }, + Files: []FileMount{ + {Path: "/etc/plugin/key.pem", Value: &filePassword}, + {Path: "/etc/plugin/config.json", Value: &otherFile}, + }, + }, + "pollInterval": "30s", + }, + }) + + // Then the client sends root CRD env/files fields for materialisation and preserves the full files array. + require.NoError(t, err) +} diff --git a/pkg/clients.go b/pkg/clients.go index a7f1a798..9e2950a2 100644 --- a/pkg/clients.go +++ b/pkg/clients.go @@ -633,7 +633,8 @@ func NewAppDeployClientFromFlags( } type stackTokenSource struct { - mu sync.Mutex + mu sync.Mutex + ctx context.Context // Membership token stackToken AccessToken @@ -671,7 +672,7 @@ func (t *stackTokenSource) Token() (*oauth2.Token, error) { } if t.stackToken.Expired() { - newStackToken, err := Refresh(context.Background(), t.relyingParty, t.stackToken) + newStackToken, err := Refresh(t.ctx, t.relyingParty, t.stackToken) if err != nil { oidcErr := &oidc.Error{} if errors.As(err, &oidcErr) && (oidcErr.ErrorType == oidc.InvalidToken || oidcErr.ErrorType == oidc.InvalidRequest) { @@ -685,7 +686,7 @@ func (t *stackTokenSource) Token() (*oauth2.Token, error) { } } - token, err := FetchStackToken(context.Background(), t.relyingParty.HttpClient(), t.stackAccess.URI, t.stackToken.Token) + token, err := FetchStackToken(t.ctx, t.relyingParty.HttpClient(), t.stackAccess.URI, t.stackToken.Token) if err != nil { return nil, err } @@ -717,7 +718,12 @@ func NewStackTokenSource( organizationID string, stackID string, ) oauth2.TokenSource { + tokenContext := context.Background() + if cmd != nil && cmd.Context() != nil { + tokenContext = cmd.Context() + } return &stackTokenSource{ + ctx: tokenContext, stackToken: stackToken, stackAccess: stackAccess, relyingParty: relyingParty, diff --git a/pkg/clients_test.go b/pkg/clients_test.go new file mode 100644 index 00000000..12f84790 --- /dev/null +++ b/pkg/clients_test.go @@ -0,0 +1,170 @@ +package fctl + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/formancehq/go-libs/v4/oidc" + "github.com/formancehq/go-libs/v4/oidc/client" +) + +type tokenTestRelyingParty struct { + client.RelyingParty + httpClient *http.Client + oauthConfig *oauth2.Config +} + +func (r tokenTestRelyingParty) HttpClient() *http.Client { + return r.httpClient +} + +func (r tokenTestRelyingParty) OAuthConfig() *oauth2.Config { + return r.oauthConfig +} + +func TestStackTokenSourceDeadlineBoundsStackAPITokenFetch(t *testing.T) { + tokenStarted := make(chan struct{}) + releaseToken := make(chan struct{}) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"token_endpoint":%q}`, server.URL+"/token") + case "/token": + close(tokenStarted) + select { + case <-req.Context().Done(): + case <-releaseToken: + } + default: + http.NotFound(w, req) + } + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond) + defer cancel() + cmd := tokenSourceTestCommand(t, ctx) + stackToken := accessTokenExpiringAt(time.Now().Add(time.Hour)) + source := NewStackTokenSource( + stackToken, + &StackAccess{URI: server.URL}, + tokenTestRelyingParty{httpClient: server.Client()}, + func(AccessToken) error { return nil }, + cmd, + "profile", + "organization", + "stack", + ) + + result := make(chan error, 1) + go func() { + _, err := source.Token() + result <- err + }() + select { + case <-tokenStarted: + case <-time.After(time.Second): + close(releaseToken) + t.Fatal("stack API token endpoint was not called") + } + + select { + case err := <-result: + require.ErrorIs(t, err, context.DeadlineExceeded) + close(releaseToken) + case <-time.After(300 * time.Millisecond): + close(releaseToken) + <-result + t.Fatal("stack API token acquisition outlived the command deadline") + } +} + +func TestStackTokenSourceCancellationBoundsStackTokenRefresh(t *testing.T) { + tokenStarted := make(chan struct{}) + releaseToken := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/token" { + http.NotFound(w, req) + return + } + close(tokenStarted) + select { + case <-req.Context().Done(): + case <-releaseToken: + } + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := tokenSourceTestCommand(t, ctx) + source := NewStackTokenSource( + accessTokenExpiringAt(time.Now().Add(-time.Hour)), + &StackAccess{URI: server.URL}, + tokenTestRelyingParty{ + httpClient: server.Client(), + oauthConfig: &oauth2.Config{ + ClientID: "fctl", + Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}, + }, + }, + func(AccessToken) error { return nil }, + cmd, + "profile", + "organization", + "stack", + ) + + result := make(chan error, 1) + go func() { + _, err := source.Token() + result <- err + }() + select { + case <-tokenStarted: + case <-time.After(time.Second): + close(releaseToken) + t.Fatal("stack refresh token endpoint was not called") + } + cancel() + + select { + case err := <-result: + require.ErrorIs(t, err, context.Canceled) + close(releaseToken) + case <-time.After(300 * time.Millisecond): + close(releaseToken) + <-result + t.Fatal("stack token refresh outlived command cancellation") + } +} + +func tokenSourceTestCommand(t *testing.T, ctx context.Context) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.Flags().String(ConfigDir, t.TempDir(), "") + return cmd +} + +func accessTokenExpiringAt(expiry time.Time) AccessToken { + return AccessToken{ + TokenWithClaims: TokenWithClaims[AccessTokenClaims]{ + Token: "stack-token", + Claims: AccessTokenClaims{ + TokenClaims: oidc.TokenClaims{Expiration: oidc.Time(expiry.Unix())}, + }, + }, + Refresh: "refresh-token", + } +} diff --git a/pkg/http.go b/pkg/http.go index 044550c0..99c139f7 100644 --- a/pkg/http.go +++ b/pkg/http.go @@ -25,6 +25,7 @@ func printBody(data []byte) { if len(data) == 0 { return } + data = redactDebugBody(data) raw := make(map[string]any) if err := json.Unmarshal(data, &raw); err == nil { f := colorjson.NewFormatter() @@ -39,6 +40,45 @@ func printBody(data []byte) { } } +func redactDebugBody(data []byte) []byte { + var body any + if err := json.Unmarshal(data, &body); err != nil { + return append([]byte(nil), data...) + } + redactInlineConfigValues(body, false) + redacted, err := json.Marshal(body) + if err != nil { + return append([]byte(nil), data...) + } + return redacted +} + +func redactInlineConfigValues(value any, inConfig bool) { + switch value := value.(type) { + case map[string]any: + for key, child := range value { + if inConfig && key == "value" { + value[key] = "[REDACTED]" + continue + } + redactInlineConfigValues(child, inConfig || isConfigContainer(key)) + } + case []any: + for _, child := range value { + redactInlineConfigValues(child, inConfig) + } + } +} + +func isConfigContainer(key string) bool { + switch key { + case "config", "defaults", "env", "files": + return true + default: + return false + } +} + func debugRoundTripper(rt http.RoundTripper) RoundTripperFn { return func(req *http.Request) (*http.Response, error) { data, err := httputil.DumpRequest(req, false) diff --git a/pkg/http_test.go b/pkg/http_test.go index b39e9a11..0f0cae12 100644 --- a/pkg/http_test.go +++ b/pkg/http_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stretchr/testify/require" ) func TestNewHTTPTransportUsesProxyFromEnvironment(t *testing.T) { @@ -31,3 +32,88 @@ func TestNewHTTPTransportUsesProxyFromEnvironment(t *testing.T) { t.Fatal("expected HTTP transport proxy to be http.ProxyFromEnvironment") } } + +func TestRedactDebugBodyRemovesInlineConfigurationValues(t *testing.T) { + tests := []struct { + name string + body string + want string + leaks []string + }{ + { + name: "API-shaped request config", + body: `{ + "name":"worker", + "spec":{ + "value":"public-spec-value", + "config":{ + "env":{"PASSWORD":{"value":"request-env-secret"}}, + "files":[{"path":"/secret","value":"request-file-secret"}] + } + } + }`, + want: `{ + "name":"worker", + "spec":{ + "value":"public-spec-value", + "config":{ + "env":{"PASSWORD":{"value":"[REDACTED]"}}, + "files":[{"path":"/secret","value":"[REDACTED]"}] + } + } + }`, + leaks: []string{"request-env-secret", "request-file-secret"}, + }, + { + name: "pinned CRD-shaped patch", + body: `{ + "spec":{ + "env":{"PASSWORD":{"value":"patch-env-secret"}}, + "files":[{"path":"/secret","value":"patch-file-secret"}], + "other":{"value":"public-other-value"} + } + }`, + want: `{ + "spec":{ + "env":{"PASSWORD":{"value":"[REDACTED]"}}, + "files":[{"path":"/secret","value":"[REDACTED]"}], + "other":{"value":"public-other-value"} + } + }`, + leaks: []string{"patch-env-secret", "patch-file-secret"}, + }, + { + name: "response config and plugin defaults", + body: `{ + "items":[ + {"spec":{"config":{"env":{"TOKEN":{"value":"response-secret"}}}}}, + {"spec":{"defaults":{"files":[{"path":"/key","value":"default-secret"}]}}} + ] + }`, + want: `{ + "items":[ + {"spec":{"config":{"env":{"TOKEN":{"value":"[REDACTED]"}}}}}, + {"spec":{"defaults":{"files":[{"path":"/key","value":"[REDACTED]"}]}}} + ] + }`, + leaks: []string{"response-secret", "default-secret"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + redacted := redactDebugBody([]byte(tt.body)) + + require.JSONEq(t, tt.want, string(redacted)) + for _, leak := range tt.leaks { + require.NotContains(t, string(redacted), leak) + } + }) + } +} + +func TestRedactDebugBodyLeavesNonJSONUnchanged(t *testing.T) { + body := []byte("not-json") + + require.Equal(t, body, redactDebugBody(body)) +} From 03272411028afc8d3cf13e3118fe75990cadea5d Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:02:05 +0200 Subject: [PATCH 13/21] chore: remove superpowers artifacts --- .../plans/2026-08-07-connectivity-cli.md | 599 ------------------ .../2026-08-07-connectivity-cli-design.md | 276 -------- 2 files changed, 875 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-07-connectivity-cli.md delete mode 100644 docs/superpowers/specs/2026-08-07-connectivity-cli-design.md diff --git a/docs/superpowers/plans/2026-08-07-connectivity-cli.md b/docs/superpowers/plans/2026-08-07-connectivity-cli.md deleted file mode 100644 index 68e6952b..00000000 --- a/docs/superpowers/plans/2026-08-07-connectivity-cli.md +++ /dev/null @@ -1,599 +0,0 @@ -# Fctl Connectivity CLI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a stack-authenticated, domain-oriented `fctl connectivity` command tree for discovering plugins and managing Connectivity instances, including schema-aware configuration and dynamic shell completion. - -**Architecture:** A handwritten `internal/connectivityclient` implements the seven Connectivity API operations used by the CLI. Resource-specific Cobra controllers receive a mockable client factory and reuse `fctl` authentication, rendering, pagination, confirmation, and JSON output. - -**Tech Stack:** Go 1.25, Cobra, pterm, testify, YAML v3, `net/http`, Nix, Just. - -## Global Constraints - -- Use `formancehq/connectivity@develop` commit `2ec12564c02c040b3f9da5fbdf40c880e67c1137`, API `0.1.0`, as the contract. -- Keep `fctl payments` unchanged and use `fctl connectivity `. -- Call `/api/connectivity` through existing stack authentication. -- Add no Connectivity profile, token, context, API URL flag, generated client, or new dependency. -- Never print inline configuration values in plain output; JSON retains the API model. -- Dynamic completion has a two-second timeout, fails silently, and never prompts. -- Keep controller/client PATCH inputs API-shaped as `spec.config`; adapt them to - pinned commit `2ec12564`'s root CRD `spec.env`/`spec.files` only at the HTTP - boundary, preserving scalar fields and the complete files array. -- Do not expose API 0.1.0's `startSequence` through CLI flags: the pinned CRD - uses an opaque byte-valued `initialCursor` and provides no valid mapping. -- Unit tests use mocks; HTTP integration test names use Given/When/Then. -- New handwritten packages maintain at least 80% statement coverage. -- Run all tooling through Nix and Just, including tests and precommit before each commit. - -Approved design: `docs/superpowers/specs/2026-08-07-connectivity-cli-design.md`. - -## File map - -- `internal/connectivityclient/{models,client}.go`: API contract and HTTP adapter. -- `cmd/connectivity/internal/client.go`: authenticated client factory. -- `cmd/connectivity/plugins`: list/show/render/complete plugins. -- `cmd/connectivity/instances/config.go`: config sources, schema, merge, validation. -- `cmd/connectivity/instances`: list/show/install/configure/uninstall and completion. -- `cmd/connectivity/root.go`, `cmd/root.go`: public command wiring. -- `completions/fctl.{bash,zsh,fish}`: regenerated shell scripts. - ---- - -### Task 1: Connectivity API client - -**Files:** -- Create: `internal/connectivityclient/models.go` -- Create: `internal/connectivityclient/client.go` -- Test: `internal/connectivityclient/client_test.go` -- Test: `internal/connectivityclient/integration_test.go` - -**Interfaces:** -- Consumes: stack URI and authenticated `*http.Client`. -- Produces: models, `ListOptions`, `InstancePatch`, `APIError`, `Client`, and `New`. - -- [ ] **Step 1: Write failing HTTP contract tests** - -Use a mocked `RoundTripper` to assert method, escaped path, query, headers, body, response decoding, empty success, and structured errors. Include this representative test and table cases for all seven methods: - -```go -func TestListPluginsBuildsStackConnectivityRequest(t *testing.T) { - var seen *http.Request - httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - seen = req.Clone(req.Context()) - return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"img"}}],"continue":"next"}`), nil - })} - got, err := New("https://stack.example/base", httpClient).ListPlugins(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) - require.NoError(t, err) - require.Equal(t, "/base/api/connectivity/plugins", seen.URL.Path) - require.Equal(t, "25", seen.URL.Query().Get("limit")) - require.Equal(t, "cursor", seen.URL.Query().Get("continue")) - require.Equal(t, "stripe", *got.Items[0].Metadata.Name) - require.Equal(t, "next", got.Continue) -} -``` - -Define these test-only helpers in `client_test.go`: - -```go -type roundTripperFunc func(*http.Request) (*http.Response, error) -func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } -func jsonResponse(status int, body string) *http.Response { - return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} -} -``` - -Add `TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected` with `httptest.Server` and Given/When/Then comment sections. - -- [ ] **Step 2: Verify RED** - -Run `nix develop -c go test ./internal/connectivityclient -v`. - -Expected: compilation fails because client symbols do not exist. - -- [ ] **Step 3: Add exact public models and interface** - -```go -type ObjectMeta struct { Name *string `json:"name,omitempty"`; Namespace *string `json:"namespace,omitempty"`; ResourceVersion *string `json:"resourceVersion,omitempty"`; UID *string `json:"uid,omitempty"`; CreationTimestamp *time.Time `json:"creationTimestamp,omitempty"`; Labels map[string]string `json:"labels,omitempty"`; Annotations map[string]string `json:"annotations,omitempty"` } -type KeyRef struct { Name string `json:"name"`; Key string `json:"key"` } -type EnvValue struct { Value *string `json:"value,omitempty"`; SecretRef *KeyRef `json:"secretRef,omitempty"`; ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` } -type FileMount struct { Path string `json:"path"`; Value *string `json:"value,omitempty"`; SecretRef *KeyRef `json:"secretRef,omitempty"`; ConfigMapRef *KeyRef `json:"configMapRef,omitempty"`; Mode *int32 `json:"mode,omitempty"` } -type InstanceConfig struct { Env map[string]EnvValue `json:"env,omitempty"`; Files []FileMount `json:"files,omitempty"` } -type VersionEntry struct { Version string `json:"version"`; Digest *string `json:"digest,omitempty"`; Image *string `json:"image,omitempty"` } -type PluginSpec struct { Image string `json:"image"`; Version *string `json:"version,omitempty"`; Description *string `json:"description,omitempty"`; DocsURL *string `json:"docsURL,omitempty"`; Capabilities []string `json:"capabilities,omitempty"`; ConfigSchema map[string]any `json:"configSchema,omitempty"`; DefaultVersion *string `json:"defaultVersion,omitempty"`; Versions []VersionEntry `json:"versions,omitempty"`; Defaults *InstanceConfig `json:"defaults,omitempty"` } -type PluginStatus struct { Phase *string `json:"phase,omitempty"`; Message *string `json:"message,omitempty"` } -type Plugin struct { Metadata ObjectMeta `json:"metadata"`; Spec PluginSpec `json:"spec"`; Status *PluginStatus `json:"status,omitempty"` } -type PluginList struct { Items []Plugin `json:"items"`; Continue string `json:"continue,omitempty"` } -type InstanceSpec struct { Plugin string `json:"plugin"`; Version *string `json:"version,omitempty"`; ConnectivityRef *string `json:"connectivityRef,omitempty"`; Ledger string `json:"ledger"`; StartSequence *int64 `json:"startSequence,omitempty"`; PollInterval *string `json:"pollInterval,omitempty"`; Config *InstanceConfig `json:"config,omitempty"` } -type InstanceStatus struct { Phase *string `json:"phase,omitempty"`; State *string `json:"state,omitempty"`; PluginAddress *string `json:"pluginAddress,omitempty"`; ResolvedImage *string `json:"resolvedImage,omitempty"`; CurrentSequence *int64 `json:"currentSequence,omitempty"`; SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"`; LastError *string `json:"lastError,omitempty"`; Message *string `json:"message,omitempty"` } -type Instance struct { Metadata ObjectMeta `json:"metadata"`; Spec InstanceSpec `json:"spec"`; Status *InstanceStatus `json:"status,omitempty"` } -type InstanceList struct { Items []Instance `json:"items"`; Continue string `json:"continue,omitempty"` } -type InstanceCreate struct { Name string `json:"name"`; Labels map[string]string `json:"labels,omitempty"`; Annotations map[string]string `json:"annotations,omitempty"`; Spec InstanceSpec `json:"spec"` } -type InstancePatch map[string]any -type ListOptions struct { Limit int32; Continue string; Plugin string } -type APIError struct { StatusCode int; Code string; Message string; Details map[string]any } - -type Client interface { - ListPlugins(context.Context, ListOptions) (*PluginList, error) - GetPlugin(context.Context, string) (*Plugin, error) - ListInstances(context.Context, ListOptions) (*InstanceList, error) - CreateInstance(context.Context, InstanceCreate) (*Instance, error) - GetInstance(context.Context, string) (*Instance, error) - PatchInstance(context.Context, string, InstancePatch) (*Instance, error) - DeleteInstance(context.Context, string) error -} -func New(stackURI string, httpClient *http.Client) Client -``` - -- [ ] **Step 4: Implement minimal HTTP wrappers** - -Use one JSON request helper. Join `/api/connectivity`, escape names with `url.PathEscape`, use `url.Values`, set `Accept`, set POST content type to `application/json`, PATCH to `application/merge-patch+json`, parse `{code,message,details}` into `APIError`, and reject malformed or empty object responses. Accept 200 for reads and patch, 201 for create, and 204 for delete. At the PATCH HTTP boundary, reshape API `spec.config.env/files` to the pinned CRD's root `spec.env/files`, remove `config`, and preserve other spec fields. - -- [ ] **Step 5: Verify GREEN, coverage, repository gates, and commit** - -```text -nix develop -c go test ./internal/connectivityclient -coverprofile=/tmp/connectivity-client.cover -v -nix develop -c go tool cover -func=/tmp/connectivity-client.cover -nix develop -c just tests -nix develop -c just pre-commit -git add internal/connectivityclient -git commit -m "feat(connectivity): add API client" -``` - -Expected: tests pass and package coverage is at least 80%. - ---- - -### Task 2: Schema-aware configuration - -**Files:** -- Create: `cmd/connectivity/instances/config.go` -- Test: `cmd/connectivity/instances/test_helpers_test.go` -- Test: `cmd/connectivity/instances/config_test.go` - -**Interfaces:** -- Consumes: plugin schema/defaults, current instance config, Cobra stdin, and injected file reader. -- Produces: `SchemaFields`, `BuildInstallConfig`, and `BuildConfigureConfig`. - -- [ ] **Step 1: Write failing parser and merge tests** - -Use a map-backed `ReadFileFunc`, never the real filesystem. Test flat/structured YAML, dotenv, later-source precedence, later `--set`, inline values, `@file`, `@-`, secret/configmap refs, env/file routing, missing required keys, malformed refs, unknown keys, and configure preserving untouched files. - -```go -func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { - current := &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ - {Path: "/etc/a", Value: stringPtr("a")}, - {Path: "/etc/b", Value: stringPtr("b")}, - }} - got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, - InputOptions{SetValues: []string{"/etc/a=@new"}}, - func(cmd *cobra.Command, name string) (string, error) { return "changed", nil }) - require.NoError(t, err) - require.Len(t, got.Files, 2) - require.Equal(t, "changed", *got.Files[0].Value) - require.Equal(t, "b", *got.Files[1].Value) -} -``` - -Define `stringPtr`, `pluginWithFileSchema`, and `pluginWithSchemaAndDefaults` in `test_helpers_test.go`. The first returns `&value`; the plugin fixtures return API models with explicit env/file properties, required arrays, password formats, defaults, and two version entries. - -- [ ] **Step 2: Verify RED** - -Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(Build|Parse|Schema)' -v`. - -Expected: compilation fails because configuration symbols do not exist. - -- [ ] **Step 3: Implement exact configuration contracts** - -```go -type ConfigKind string -const ( ConfigEnv ConfigKind = "env"; ConfigFile ConfigKind = "file" ) -type SchemaField struct { Key string; Kind ConfigKind; Required bool; Password bool; Description string } -type InputOptions struct { ConfigFile string; EnvFiles []string; SetValues []string } -type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) -func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, error) -func BuildInstallConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) -func BuildConfigureConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, current *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) -``` - -Install merges defaults โ†’ config โ†’ env files in order โ†’ set values in order. Configure replaces defaults with a deep copy of current config. Replace file mounts by path without losing untouched mounts. Required-key and unknown-key errors sort keys for deterministic output. - -- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** - -```text -nix develop -c go test ./cmd/connectivity/instances -run 'Test(Build|Parse|Schema)' -coverprofile=/tmp/connectivity-config.cover -v -nix develop -c go tool cover -func=/tmp/connectivity-config.cover -nix develop -c just tests -nix develop -c just pre-commit -git add cmd/connectivity/instances/config.go cmd/connectivity/instances/config_test.go docs/superpowers/specs/2026-08-07-connectivity-cli-design.md -git commit -m "feat(connectivity): assemble instance configuration" -``` - ---- - -### Task 3: Plugin catalog commands - -**Files:** -- Create: `cmd/connectivity/internal/client.go` -- Create: `cmd/connectivity/plugins/{root,list,show,completion}.go` -- Test: `cmd/connectivity/plugins/test_helpers_test.go` -- Test: `cmd/connectivity/plugins/{list,show,completion}_test.go` - -**Interfaces:** -- Consumes: client API, stack clients, pagination, renderer. -- Produces: `ClientFactory`, plugin root, list/show, `CompletePluginNames`. - -- [ ] **Step 1: Write failing mocked command tests** - -Embed `connectivityclient.Client` in mocks and override only called methods. Assert pagination, continuation rendering, JSON store, versions/capabilities/schema detail rendering, prefix filtering, completion descriptions, two-second deadline, and silent errors. - -```go -type pluginClientMock struct { connectivityclient.Client; list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error); get func(context.Context, string) (*connectivityclient.Plugin, error) } -func (m pluginClientMock) ListPlugins(ctx context.Context, opts connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { return m.list(ctx, opts) } -func (m pluginClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { return m.get(ctx, name) } -func pluginFixture(name string) connectivityclient.Plugin { return connectivityclient.Plugin{Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, Spec: connectivityclient.PluginSpec{Image: "registry/plugin", Description: fctl.Ptr("Plugin description")}} } -``` - -- [ ] **Step 2: Verify RED** - -Run `nix develop -c go test ./cmd/connectivity/plugins -v`. - -Expected: compilation fails because plugin packages do not exist. - -- [ ] **Step 3: Implement authenticated factory and commands** - -```go -type ClientFactory func(*cobra.Command) (connectivityclient.Client, error) -func NewClientFactory() ClientFactory -func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command -func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command -func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command -func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc -``` - -The factory calls `LoadAndAuthenticateCurrentProfile` and `NewStackClientsFromFlags`, then constructs the API client. Use aliases `plugins: plugin,p`, `list: ls,l`, `show: get,g,sh,s`. `ListStore` carries plugins and `fctl.Cursor`; `ShowStore` carries the complete plugin. Render the approved columns and schema summary. - -- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** - -```text -nix develop -c go test ./cmd/connectivity/plugins -coverprofile=/tmp/connectivity-plugins.cover -v -nix develop -c go tool cover -func=/tmp/connectivity-plugins.cover -nix develop -c just tests -nix develop -c just pre-commit -git add cmd/connectivity/internal cmd/connectivity/plugins -git commit -m "feat(connectivity): add plugin catalog commands" -``` - ---- - -### Task 4: Instance read commands and completion - -**Files:** -- Create: `cmd/connectivity/instances/{root,list,show,completion}.go` -- Modify: `cmd/connectivity/instances/test_helpers_test.go` -- Test: `cmd/connectivity/instances/{list,show,completion}_test.go` - -**Interfaces:** -- Consumes: shared client factory, API models, cursor flags, schema fields. -- Produces: instance root, list/show, instance/version/set/path completion. - -- [ ] **Step 1: Write failing list/show tests** - -Mock `ListInstances` and `GetInstance`. Assert `--plugin`, cursor/page size, approved columns, progression fields, JSON store, and that plain output names config keys/source types but never inline values. - -```go -func TestShowPlainOutputNeverPrintsInlineConfigValues(t *testing.T) { - instance := instanceFixture("stripe-eu") - instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ - "API_KEY": {Value: stringPtr("must-not-leak")}, - }} - cmd := NewShowCommand(factoryWithInstance(instance)) - cmd.SetArgs([]string{"stripe-eu"}) - var out bytes.Buffer - cmd.SetOut(&out) - require.NoError(t, cmd.Execute()) - require.Contains(t, out.String(), "API_KEY") - require.Contains(t, out.String(), "inline") - require.NotContains(t, out.String(), "must-not-leak") -} -``` - -- [ ] **Step 2: Write failing completion tests** - -Assert instance-name and version candidates, required schema keys before optional keys, descriptions after a tab, already supplied keys omitted, `KEY=@` candidates preserving the prefix, `NoSpace` for `KEY=`, and silence on timeout/error. Inject a mock path completer rather than reading disk. - -Define reusable test helpers with these signatures in `test_helpers_test.go`: - -```go -func stringPtr(value string) *string -func instanceFixture(name string) connectivityclient.Instance -func instanceWithTwoFiles() *connectivityclient.Instance -func pluginWithFileSchema() *connectivityclient.Plugin -func pluginWithSchemaAndDefaults() *connectivityclient.Plugin -func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory -func factoryWithInstance(instance connectivityclient.Instance) connectivityinternal.ClientFactory -func mockReadFile(files map[string]string) ReadFileFunc -func mockPathCompleter(paths []string) PathCompleter -``` - -The package-specific mock structs embed `connectivityclient.Client` and implement only the methods exercised by their test file; every function-valued field is invoked directly by its matching method. - -- [ ] **Step 3: Verify RED** - -Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(ListInstances|Show|Complete)' -v`. - -Expected: compilation fails because read commands and completion functions do not exist. - -- [ ] **Step 4: Implement read commands and renderers** - -```go -func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command -func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command -func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command -``` - -Use aliases `instances: instance,i`, `list: ls,l`, and `show: get,g,sh,s`. `ListStore` contains instances and `fctl.Cursor`; `ShowStore` contains the complete instance. Render config sources as `inline`, `secret:name/key`, or `configmap:name/key` without rendering `Value` fields. - -- [ ] **Step 5: Implement exact completion API** - -```go -type PathCompleter func(prefix string) ([]string, error) -func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc -func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func(*cobra.Command, []string) string) cobra.CompletionFunc -func CompleteSetValues(factory connectivityinternal.ClientFactory, resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), paths PathCompleter) cobra.CompletionFunc -func OSPathCompleter(prefix string) ([]string, error) -``` - -Use `context.WithTimeout(cmd.Context(), 2*time.Second)`, list limit 500, prefix filtering, sorted candidates, and silent `NoFileComp` errors. Preserve `KEY=@` when completing paths. - -- [ ] **Step 6: Verify GREEN, coverage, gates, and commit** - -```text -nix develop -c go test ./cmd/connectivity/instances -run 'Test(ListInstances|Show|Complete)' -coverprofile=/tmp/connectivity-read.cover -v -nix develop -c go tool cover -func=/tmp/connectivity-read.cover -nix develop -c just tests -nix develop -c just pre-commit -git add cmd/connectivity/instances -git commit -m "feat(connectivity): list and show instances" -``` - ---- - -### Task 5: Install instances - -**Files:** -- Create: `cmd/connectivity/instances/install.go` -- Test: `cmd/connectivity/instances/install_test.go` -- Modify: `cmd/connectivity/instances/root.go` -- Modify: `cmd/connectivity/instances/completion.go` - -**Interfaces:** -- Consumes: install config builder and plugin/version/set completion. -- Produces: `NewInstallCommand` and POST workflow. - -- [ ] **Step 1: Write failing install tests** - -Test required ledger, default name, scalar flags, config assembly, confirmation rejection, API error, success renderer, plugin positional completion, version completion, set completion, and file completion for config/env files. - -```go -func TestInstallBuildsInstanceFromPluginSchema(t *testing.T) { - client := mutationClientMock{ - getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { return pluginWithSchemaAndDefaults(), nil }, - create: func(ctx context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - require.Equal(t, "stripe-eu", body.Name) - require.Equal(t, "main", body.Spec.Ledger) - require.Equal(t, "api-key", *body.Spec.Config.Env["API_KEY"].Value) - return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil - }, - } - cmd := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) - cmd.SetArgs([]string{"stripe", "--name=stripe-eu", "--ledger=main", "--set=API_KEY=api-key", "--confirm"}) - require.NoError(t, cmd.Execute()) -} -``` - -- [ ] **Step 2: Verify RED** - -Run `nix develop -c go test ./cmd/connectivity/instances -run TestInstall -v`. - -Expected: compilation fails because `NewInstallCommand` does not exist. - -- [ ] **Step 3: Implement flags, completions, controller, and renderer** - -Build `install ` with aliases `create,in`, exact one argument, `--name`, required `--ledger`, optional `--version`, `--poll-interval`, `--config`, repeatable `--env-file`, repeatable `--set`, and confirmation. Fetch plugin, call `BuildInstallConfig`, approve, POST, store the returned instance, and render: - -```text -Instance "stripe-eu" installed with plugin "stripe" for ledger "main". -``` - -Use `Flags().Changed` for optional scalar fields. Send config only when defaults or user inputs produce one. - -- [ ] **Step 4: Verify GREEN, coverage, gates, and commit** - -```text -nix develop -c go test ./cmd/connectivity/instances -run TestInstall -v -nix develop -c go test ./cmd/connectivity/instances -coverprofile=/tmp/connectivity-instances.cover -nix develop -c go tool cover -func=/tmp/connectivity-instances.cover -nix develop -c just tests -nix develop -c just pre-commit -git add cmd/connectivity/instances -git commit -m "feat(connectivity): install plugin instances" -``` - ---- - -### Task 6: Configure and uninstall instances - -**Files:** -- Create: `cmd/connectivity/instances/configure.go` -- Test: `cmd/connectivity/instances/configure_test.go` -- Create: `cmd/connectivity/instances/uninstall.go` -- Test: `cmd/connectivity/instances/uninstall_test.go` -- Modify: `cmd/connectivity/instances/root.go` -- Modify: `cmd/connectivity/instances/completion.go` - -**Interfaces:** -- Consumes: current instance config, plugin schema, configure builder, completion, confirmation. -- Produces: PATCH `configure` and DELETE `uninstall` workflows. - -- [ ] **Step 1: Write failing configure tests** - -Assert current-config precedence, all untouched file mounts preserved, changed scalar fields included, unchanged scalars omitted, no-change rejection, confirmation rejection, API errors, instance/version/set completion, and success rendering. - -```go -func TestConfigureBuildsNestedPatch(t *testing.T) { - client := configureClientMock{instance: instanceWithTwoFiles(), plugin: pluginWithFileSchema(), patch: func(ctx context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { - spec := patch["spec"].(map[string]any) - require.Equal(t, "15s", spec["pollInterval"]) - config := spec["config"].(*connectivityclient.InstanceConfig) - require.Len(t, config.Files, 2) - return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}}, nil - }} - cmd := NewConfigureCommand(factoryReturning(client), mockReadFile(map[string]string{"new": "changed"}), mockPathCompleter(nil)) - cmd.SetArgs([]string{"stripe-eu", "--set=/etc/a=@new", "--poll-interval=15s", "--confirm"}) - require.NoError(t, cmd.Execute()) -} -``` - -- [ ] **Step 2: Write failing uninstall tests and verify RED** - -Test confirmed delete, rejected confirmation, API error, output, and instance completion. Run `nix develop -c go test ./cmd/connectivity/instances -run 'Test(Configure|Uninstall)' -v`. - -Expected: compilation fails because mutation commands do not exist. - -- [ ] **Step 3: Implement configure** - -Build `configure ` with aliases `config,update,c`, shared config flags, optional scalar flags, exact one argument, and confirmation. Fetch instance then plugin. Merge on current config. Build exactly: - -```go -specPatch := map[string]any{} -if configChanged { specPatch["config"] = mergedConfig } -if cmd.Flags().Changed("version") { specPatch["version"] = version } -if cmd.Flags().Changed("ledger") { specPatch["ledger"] = ledger } -if cmd.Flags().Changed("poll-interval") { specPatch["pollInterval"] = pollInterval } -patch := connectivityclient.InstancePatch{"spec": specPatch} -``` - -Reject an empty map, approve, PATCH, and render the returned instance name. - -- [ ] **Step 4: Implement uninstall** - -Build `uninstall ` with aliases `delete,remove,rm,u`, exact one argument, instance completion, and confirmation. Delete only after approval and render `Instance "" uninstalled.` - -- [ ] **Step 5: Verify GREEN, coverage, gates, and commit** - -```text -nix develop -c go test ./cmd/connectivity/instances -v -nix develop -c go test ./cmd/connectivity/instances -coverprofile=/tmp/connectivity-instances.cover -nix develop -c go tool cover -func=/tmp/connectivity-instances.cover -nix develop -c just tests -nix develop -c just pre-commit -git add cmd/connectivity/instances -git commit -m "feat(connectivity): configure and uninstall instances" -``` - ---- - -### Task 7: Root wiring and final PR verification - -**Files:** -- Create: `cmd/connectivity/root.go` -- Test: `cmd/connectivity/root_test.go` -- Modify: `cmd/root.go` -- Modify: `completions/fctl.bash` -- Modify: `completions/fctl.zsh` -- Modify: `completions/fctl.fish` - -**Interfaces:** -- Consumes: plugin/instance roots and production dependencies. -- Produces: public `fctl connectivity` tree and shell scripts. - -- [ ] **Step 1: Write failing tree tests** - -```go -func TestCommandTreeMatchesConnectivityUX(t *testing.T) { - cmd := NewCommand() - require.Equal(t, "connectivity", cmd.Name()) - for _, path := range [][]string{{"plugins", "list"}, {"plugins", "show"}, {"instances", "list"}, {"instances", "show"}, {"instances", "install"}, {"instances", "configure"}, {"instances", "uninstall"}} { - found, _, err := cmd.Find(path) - require.NoError(t, err) - require.Equal(t, path[len(path)-1], found.Name()) - } - require.NotNil(t, cmd.PersistentFlags().Lookup("stack")) - require.NotNil(t, cmd.PersistentFlags().Lookup("organization")) -} -``` - -Add a `cmd` package test that `NewRootCommand().Find([]string{"connectivity"})` succeeds. - -- [ ] **Step 2: Verify RED** - -Run `nix develop -c go test ./cmd/connectivity ./cmd -run 'Test(CommandTree|RootCommand)' -v`. - -Expected: compilation or assertion failure because the root is not wired. - -- [ ] **Step 3: Implement and register root** - -```go -func NewCommand() *cobra.Command { - factory := connectivityinternal.NewClientFactory() - return fctl.NewStackCommand("connectivity", - fctl.WithShortDescription("Manage Connectivity plugins and instances"), - fctl.WithChildCommands( - plugins.NewCommand(factory), - instances.NewCommand(factory, fctl.ReadFile, instances.OSPathCompleter), - ), - ) -} -``` - -Import Connectivity in `cmd/root.go` and add it without moving existing children. - -- [ ] **Step 4: Format, inspect help, and regenerate completions** - -```text -nix develop -c gofmt -w internal/connectivityclient cmd/connectivity cmd/root.go -nix develop -c go run main.go connectivity --help -nix develop -c go run main.go connectivity plugins --help -nix develop -c go run main.go connectivity instances --help -nix develop -c just completions -``` - -Expected: help matches the approved hierarchy and all three scripts contain Connectivity commands and flags. - -- [ ] **Step 5: Run final verification** - -```text -nix develop -c just tests -nix develop -c go test -race ./internal/connectivityclient ./cmd/connectivity/... -nix develop -c go test -coverprofile=/tmp/connectivity-all.cover ./internal/connectivityclient ./cmd/connectivity/... -nix develop -c go tool cover -func=/tmp/connectivity-all.cover -nix develop -c just pre-commit -git diff --check -git status --short -``` - -Expected: all commands exit 0, race detector is clean, new code coverage is at least 80%, and no `cmd/payments` file changed. - -- [ ] **Step 6: Commit and prepare PR** - -```text -git add cmd/root.go cmd/connectivity/root.go cmd/connectivity/root_test.go completions/fctl.bash completions/fctl.zsh completions/fctl.fish docs/superpowers/plans/2026-08-07-connectivity-cli.md -git commit -m "feat(connectivity): expose fctl command tree" -``` - -PR title and body: - -```text -feat(connectivity): add plugin and instance commands - -- add a stack-authenticated fctl connectivity module -- expose plugin discovery and instance lifecycle workflows -- support schema-aware --set, --env-file, --config, and @file inputs -- add dynamic completion for plugins, instances, versions, schema keys, and paths - -Testing: -- nix develop -c just tests -- nix develop -c go test -race ./internal/connectivityclient ./cmd/connectivity/... -- nix develop -c just completions -- nix develop -c just pre-commit -``` diff --git a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md b/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md deleted file mode 100644 index d0c24e33..00000000 --- a/docs/superpowers/specs/2026-08-07-connectivity-cli-design.md +++ /dev/null @@ -1,276 +0,0 @@ -# Connectivity CLI Design - -**Date:** 2026-08-07 -**Status:** Approved in conversation; pending written-spec review -**Upstream contract:** `formancehq/connectivity@develop` commit `2ec12564c02c040b3f9da5fbdf40c880e67c1137`, Connectivity API `0.1.0` - -## Goal - -Add a first-class `fctl connectivity` module that exposes the Connectivity -plugin catalog and instance lifecycle as domain-oriented commands. The module -must feel native to `fctl`, use the selected stack and existing authentication, -and provide reliable context-aware shell completion. - -Connectivity is a new module and may eventually replace Payments. This change -does not remove, alias, or modify any `fctl payments` command. - -## Scope - -The upstream API provides health endpoints, read-only plugin catalog endpoints, -and CRUD endpoints for instances. The CLI exposes the user workflows, not the -HTTP verbs: - -```text -fctl connectivity plugins list -fctl connectivity plugins show - -fctl connectivity instances list -fctl connectivity instances show -fctl connectivity instances install -fctl connectivity instances configure -fctl connectivity instances uninstall -``` - -The health probes remain operational endpoints and do not receive CLI commands. -The API's full-replacement `PUT /instances/{name}` is not exposed in this first -version because there is no corresponding `fctl` workflow. A future declarative -`apply` command can add that behavior without changing the commands above. - -## Command conventions - -The hierarchy follows the dominant `fctl ` grammar. -The root uses `fctl.NewStackCommand`, so `--organization` and `--stack` behave -like they do for Ledger and Payments. - -### Plugins - -`plugins list` calls `GET /plugins`. It supports the standard `--page-size` and -`--cursor` flags and maps them to the API's `limit` and `continue` parameters. -Plain output contains Name, Default Version, Description, Capabilities, and -Phase. JSON output preserves the complete plugin objects and continuation data -inside the standard `fctl` data envelope. - -`plugins show ` calls `GET /plugins/{name}`. Plain output includes the -plugin metadata, image, version choices, capabilities, status, documentation -URL, and a readable summary of its configuration schema. JSON output preserves -the complete plugin object. - -Aliases: - -- `plugins`: `plugin`, `p` -- `list`: `ls`, `l` -- `show`: `get`, `g`, `sh`, `s` - -### Instances - -`instances list` calls `GET /instances`. It supports `--plugin`, `--page-size`, -and `--cursor`. Plain output contains Name, Plugin, Version, Ledger, Phase, -State, Current Sequence, Source Tip Sequence, and Last Error. JSON output -preserves the complete instances and continuation data. - -`instances show ` calls `GET /instances/{name}`. Plain output shows -metadata, desired specification, effective image, lifecycle state, ingestion -progress, and error information. It lists configuration keys and their source -types but never prints inline configuration values. JSON output preserves the -API response, consistent with the existing `fctl -o json` contract. - -`instances install ` calls `GET /plugins/{plugin}` to obtain defaults -and the configuration schema, validates the requested configuration, and then -calls `POST /instances`. It accepts: - -- `--name`, defaulting to the plugin name; -- required `--ledger`; -- optional `--version` and `--poll-interval`; -- optional `--config `; -- repeatable `--env-file `; -- repeatable `--set KEY=VALUE`; -- the standard confirmation flag. - -`instances configure ` calls `GET /instances/{name}`, then -`GET /plugins/{plugin}` for schema-aware parsing, and finally -`PATCH /instances/{name}` with `application/merge-patch+json`. It accepts the -same configuration inputs as install. It also accepts optional `--version`, -`--ledger`, and `--poll-interval`; only explicitly supplied fields are patched. -At least one change must be supplied. - -Although the API 0.1.0 model includes `startSequence`, the pinned server drops -the CRD's opaque byte-valued `initialCursor` on reads and has no valid write -mapping from an integer sequence. The CLI intentionally does not expose a -`--start-sequence` flag rather than accepting a value that cannot take effect. - -`instances uninstall ` calls `DELETE /instances/{name}` after the -standard `fctl` confirmation flow. - -Aliases: - -- `instances`: `instance`, `i` -- `list`: `ls`, `l` -- `show`: `get`, `g`, `sh`, `s` -- `install`: `create`, `in` -- `configure`: `config`, `update`, `c` -- `uninstall`: `delete`, `remove`, `rm`, `u` - -## Configuration inputs - -The configuration model follows Connectivity's `InstanceConfig` schema and the -UX already proven by the experimental `cctl` on `connectivity@develop`. - -`--set` accepts these value sources: - -```text ---set KEY=value ---set KEY=@path/to/local/file ---set KEY=@- ---set KEY=secret://secret-name/key ---set KEY=configmap://config-map-name/key -``` - -For `@path`, the local file contents become the inline value. `@-` reads stdin -through the existing `fctl.ReadFile` behavior and therefore requires -`--confirm`. Secret and ConfigMap forms create references and do not read the -referenced Kubernetes objects. - -`--env-file` accepts dotenv-style `KEY=VALUE` records, ignores blank lines and -comments, and is repeatable. It represents environment entries only. - -`--config` accepts YAML or JSON. A flat mapping is interpreted as environment -values. A structured document may contain `env` and `files` matching the -Connectivity API's `InstanceConfig` shape. - -During install, values merge in this exact order, from lowest to highest -precedence: - -1. plugin defaults; -2. `--config`; -3. `--env-file` values, with later files winning; -4. `--set` values, with later occurrences winning. - -During configure, the instance's current configuration replaces plugin -defaults as the lowest-precedence base. This preserves every untouched env -entry and file mount. Controllers keep the API-shaped complete `config` object -inside the merge patch. Immediately before the HTTP request, the Connectivity -client adapts it to root CRD `spec.env` and `spec.files`, removing `config`, -because pinned commit `2ec12564` applies PATCH directly to the CRD and its -password materializer reads those root fields. Sending the complete `files` -array ensures changing one mount cannot accidentally replace the other mounts. - -The plugin's `configSchema` determines whether a key is an environment entry or -a file mount. Required keys are validated before the write request. Invalid -assignments, malformed references, unreadable files, unknown schema keys, and -missing required values return actionable errors. Password-formatted values are -never included in plain terminal output. - -## Client architecture - -A focused internal Connectivity client owns the API models and the seven -non-health operations used by the commands. It is handwritten rather than -generated because the contract is small, the upstream API is still `0.1.0`, and -generated code would add substantial repository and toolchain surface. - -The client accepts a base URL and `http.Client`, exposes an interface used by -the commands, escapes path and query values, and decodes the API's structured -`{code,message,details}` errors. Non-success responses include the HTTP status -and structured API error when available. - -Production wiring uses `fctl.NewStackClientsFromFlags`. Requests target: - -```text -/api/connectivity/plugins -/api/connectivity/instances -``` - -This reuses the existing OAuth token source, refresh behavior, transport, -debugging, TLS settings, and `fctl/` user agent. No Connectivity-specific -profile, context, token, or API URL flag is introduced. - -## Package boundaries - -- `internal/connectivityclient` contains HTTP models, the client interface, - implementation, and structured errors. -- `cmd/connectivity` wires the stack-scoped root and shared client factory. -- `cmd/connectivity/plugins` owns plugin commands, stores, renderers, and plugin - completion. -- `cmd/connectivity/instances` owns instance lifecycle commands, stores, - renderers, configuration assembly, validation, and instance completion. -- Small shared completion and file helpers stay inside `cmd/connectivity` unless - both resource packages require them; no unrelated `pkg` refactor is included. - -Commands receive the client through an overridable factory. Production uses the -authenticated stack client, while unit tests inject mocks without real network, -authentication, filesystem, or Kubernetes dependencies. - -## Completion contract - -Completion is part of the feature, not a follow-up: - -- plugin arguments complete from `GET /plugins?limit=500`; -- instance arguments complete from `GET /instances?limit=500`; -- `--version` completes from the selected plugin's declared versions; -- `--set` completes schema keys as `KEY=`, includes descriptions, prioritizes - required keys, and omits keys already supplied; -- after `KEY=@`, completion returns local file and directory candidates while - preserving the `KEY=@` prefix; -- `--config` and `--env-file` retain normal shell file completion; -- static commands, flags, aliases, and resource names continue to use Cobra's - generated completion scripts. - -Dynamic completion uses a two-second context timeout. Missing profiles, -authentication failures, unsupported Connectivity deployments, and network -errors produce no candidates and no terminal noise. Completion never triggers a -confirmation prompt. - -## Rendering and errors - -All commands use the existing controller/store/render flow. Plain output uses -`pterm` tables and detail views consistent with nearby `fctl` modules. JSON uses -the global `--output json` path and does not introduce a Connectivity-only -output flag. - -Mutating commands use `fctl.WithConfirmFlag` and -`fctl.CheckStackApprobation`. Client-side validation errors name the offending -flag or key. API errors preserve the upstream code, message, details, and HTTP -status. Empty or malformed success responses are treated as errors instead of -being rendered as zero-value resources. - -## Testing and verification - -Development follows red-green-refactor. Every new production behavior begins -with a focused failing test. - -Unit tests use Go's test tooling, `testify`, and mocks: - -- client request method, path, query, content type, response decoding, and API - error decoding through a mocked `RoundTripper`; -- command argument and flag validation through a mocked client interface; -- configuration parsing, source routing, precedence, schema validation, and - secret redaction; -- plain and JSON rendering; -- every dynamic completion path, including timeout/error silence and `@` file - candidates; -- root command registration and aliases. - -HTTP integration tests may use an in-process `httptest.Server`. Their names use -Given/When/Then and verify complete command-to-API flows without external -services. New handwritten packages must maintain at least 80% statement -coverage. - -Verification runs inside the repository's Nix development shell: - -```text -nix develop -c just tests -nix develop -c just completions -nix develop -c just pre-commit -``` - -The last command is the repository's actual precommit recipe and includes tidy, -generated API clients, and linting. Completion scripts are regenerated -explicitly because the precommit recipe does not include them. - -## Non-goals - -- Removing or deprecating `fctl payments`. -- Reusing `cctl`'s separate contexts, login flow, or output subsystem. -- Exposing Kubernetes objects or kubeconfig directly. -- Adding Connectivity health commands. -- Adding declarative `apply` or full-replacement instance updates. -- Generating and committing an OpenAPI client in this PR. From 0fdc07ab65b12212c20520f13688275defffd3e2 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:31:30 +0200 Subject: [PATCH 14/21] refactor(connectivity): align the CLI with the renamed connectivity API The connectivity API renamed its resources to Connector / ConnectorVersion / ConnectorInstance and now serves them under /connectors, /connectors/{name}/versions and /connectorinstances. Rename the CLI to match and re-model the client against the current openapi.yaml: - cmd/connectivity/plugins -> cmd/connectivity/connectors, cmd/connectivity/instances -> cmd/connectivity/connectorinstances. - ConnectorSpec is now lean metadata; the installable versions and their configSchema live in ConnectorVersion objects, so install/configure resolve a version (the pin, the applied version, or the newest) and validate configuration against that version's schema. - The API publishes no per-connector config defaults, so an install now starts from an empty configuration. - Drop the client-side patch shim: the API reshapes spec.config into the CRD layout itself, so the client sends the documented API shape. - Surface the resolved connector/version/digest and the spec channel in `connectorinstances show`; `--plugin` becomes `--connector`. --- .../completion.go | 60 +-- .../completion_test.go | 97 +++-- .../config.go | 56 ++- .../config_test.go | 95 +++-- .../configure.go | 68 ++-- .../configure_test.go | 238 +++++++----- .../install.go | 61 ++- .../connectorinstances/install_test.go | 347 ++++++++++++++++++ .../{instances => connectorinstances}/list.go | 46 +-- .../list_test.go | 46 +-- .../{instances => connectorinstances}/root.go | 8 +- .../{instances => connectorinstances}/show.go | 42 ++- .../show_test.go | 41 ++- .../connectorinstances/test_helpers_test.go | 194 ++++++++++ .../uninstall.go | 14 +- .../uninstall_test.go | 24 +- .../connectorinstances/version.go | 52 +++ .../{plugins => connectors}/completion.go | 18 +- .../completion_test.go | 24 +- .../{plugins => connectors}/list.go | 32 +- .../{plugins => connectors}/list_test.go | 45 ++- .../{plugins => connectors}/root.go | 8 +- .../{plugins => connectors}/show.go | 100 +++-- cmd/connectivity/connectors/show_test.go | 241 ++++++++++++ .../connectors/test_helpers_test.go | 71 ++++ cmd/connectivity/instances/install_test.go | 309 ---------------- .../instances/test_helpers_test.go | 153 -------- cmd/connectivity/plugins/show_test.go | 163 -------- cmd/connectivity/plugins/test_helpers_test.go | 54 --- cmd/connectivity/root.go | 10 +- cmd/connectivity/root_test.go | 14 +- internal/connectivityclient/client.go | 176 ++++----- internal/connectivityclient/client_test.go | 211 ++++++----- .../connectivityclient/integration_test.go | 77 ++-- internal/connectivityclient/models.go | 150 ++++---- 35 files changed, 1875 insertions(+), 1470 deletions(-) rename cmd/connectivity/{instances => connectorinstances}/completion.go (75%) rename cmd/connectivity/{instances => connectorinstances}/completion_test.go (65%) rename cmd/connectivity/{instances => connectorinstances}/config.go (85%) rename cmd/connectivity/{instances => connectorinstances}/config_test.go (67%) rename cmd/connectivity/{instances => connectorinstances}/configure.go (60%) rename cmd/connectivity/{instances => connectorinstances}/configure_test.go (55%) rename cmd/connectivity/{instances => connectorinstances}/install.go (65%) create mode 100644 cmd/connectivity/connectorinstances/install_test.go rename cmd/connectivity/{instances => connectorinstances}/list.go (62%) rename cmd/connectivity/{instances => connectorinstances}/list_test.go (62%) rename cmd/connectivity/{instances => connectorinstances}/root.go (72%) rename cmd/connectivity/{instances => connectorinstances}/show.go (70%) rename cmd/connectivity/{instances => connectorinstances}/show_test.go (71%) create mode 100644 cmd/connectivity/connectorinstances/test_helpers_test.go rename cmd/connectivity/{instances => connectorinstances}/uninstall.go (76%) rename cmd/connectivity/{instances => connectorinstances}/uninstall_test.go (76%) create mode 100644 cmd/connectivity/connectorinstances/version.go rename cmd/connectivity/{plugins => connectors}/completion.go (67%) rename cmd/connectivity/{plugins => connectors}/completion_test.go (65%) rename cmd/connectivity/{plugins => connectors}/list.go (70%) rename cmd/connectivity/{plugins => connectors}/list_test.go (59%) rename cmd/connectivity/{plugins => connectors}/root.go (72%) rename cmd/connectivity/{plugins => connectors}/show.go (61%) create mode 100644 cmd/connectivity/connectors/show_test.go create mode 100644 cmd/connectivity/connectors/test_helpers_test.go delete mode 100644 cmd/connectivity/instances/install_test.go delete mode 100644 cmd/connectivity/instances/test_helpers_test.go delete mode 100644 cmd/connectivity/plugins/show_test.go delete mode 100644 cmd/connectivity/plugins/test_helpers_test.go diff --git a/cmd/connectivity/instances/completion.go b/cmd/connectivity/connectorinstances/completion.go similarity index 75% rename from cmd/connectivity/instances/completion.go rename to cmd/connectivity/connectorinstances/completion.go index 1451f477..ddb688e3 100644 --- a/cmd/connectivity/instances/completion.go +++ b/cmd/connectivity/connectorinstances/completion.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -21,7 +21,14 @@ const ( type PathCompleter func(prefix string) ([]string, error) -func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { +// ConnectorResolver names the Connector a command is operating on. +type ConnectorResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (string, error) + +// ConnectorVersionResolver resolves the ConnectorVersion whose configSchema +// drives value completion for a command. +type ConnectorVersionResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.ConnectorVersion, error) + +func CompleteConnectorInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { client, completionCommand, cancel, ok := completionClient(cmd, factory) if !ok { @@ -29,7 +36,7 @@ func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.Com } defer cancel() - response, err := client.ListInstances(completionCommand.Context(), connectivityclient.ListOptions{Limit: instanceCompletionLimit}) + response, err := client.ListConnectorInstances(completionCommand.Context(), connectivityclient.ListOptions{Limit: instanceCompletionLimit}) if err != nil || response == nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -39,7 +46,7 @@ func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.Com if name == "" || !strings.HasPrefix(name, toComplete) { continue } - description := strings.Join(nonEmptyStrings(instance.Spec.Plugin, instance.Spec.Ledger), " ยท ") + description := strings.Join(nonEmptyStrings(instance.Spec.Connector, instance.Spec.Ledger), " ยท ") if description != "" { name += "\t" + description } @@ -50,25 +57,18 @@ func CompleteInstanceNames(factory connectivityinternal.ClientFactory) cobra.Com } } -func CompleteVersions(factory connectivityinternal.ClientFactory, pluginArg func(*cobra.Command, []string) string) cobra.CompletionFunc { - return completeVersions(factory, func(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.Plugin, error) { - if pluginArg == nil { - return nil, nil +func CompleteVersions(factory connectivityinternal.ClientFactory, connectorArg func(*cobra.Command, []string) string) cobra.CompletionFunc { + return completeVersions(factory, func(_ context.Context, _ connectivityclient.Client, cmd *cobra.Command, args []string) (string, error) { + if connectorArg == nil { + return "", nil } - pluginName := pluginArg(cmd, args) - if pluginName == "" { - return nil, nil - } - return client.GetPlugin(ctx, pluginName) + return connectorArg(cmd, args), nil }) } -func completeVersions( - factory connectivityinternal.ClientFactory, - resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), -) cobra.CompletionFunc { +func completeVersions(factory connectivityinternal.ClientFactory, resolveConnector ConnectorResolver) cobra.CompletionFunc { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if resolvePlugin == nil { + if resolveConnector == nil { return nil, cobra.ShellCompDirectiveNoFileComp } client, completionCommand, cancel, ok := completionClient(cmd, factory) @@ -77,17 +77,21 @@ func completeVersions( } defer cancel() - plugin, err := resolvePlugin(completionCommand.Context(), client, completionCommand, args) - if err != nil || plugin == nil { + connector, err := resolveConnector(completionCommand.Context(), client, completionCommand, args) + if err != nil || connector == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + versions, err := client.ListConnectorVersions(completionCommand.Context(), connector) + if err != nil || versions == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - candidates := make([]string, 0, len(plugin.Spec.Versions)) - for _, version := range plugin.Spec.Versions { + candidates := make([]string, 0, len(versions.Items)) + for _, version := range versions.Items { if version.Version == "" || !strings.HasPrefix(version.Version, toComplete) { continue } candidate := version.Version - description := stringValue(version.Image) + description := version.Image if description == "" { description = stringValue(version.Digest) } @@ -103,7 +107,7 @@ func completeVersions( func CompleteSetValues( factory connectivityinternal.ClientFactory, - resolvePlugin func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error), + resolveVersion ConnectorVersionResolver, paths PathCompleter, ) cobra.CompletionFunc { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -127,7 +131,7 @@ func CompleteSetValues( return candidates, cobra.ShellCompDirectiveNoFileComp } - if resolvePlugin == nil { + if resolveVersion == nil { return nil, cobra.ShellCompDirectiveNoFileComp } client, completionCommand, cancel, ok := completionClient(cmd, factory) @@ -135,11 +139,11 @@ func CompleteSetValues( return nil, cobra.ShellCompDirectiveNoFileComp } defer cancel() - plugin, err := resolvePlugin(completionCommand.Context(), client, completionCommand, args) - if err != nil || plugin == nil { + version, err := resolveVersion(completionCommand.Context(), client, completionCommand, args) + if err != nil || version == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - fields, err := SchemaFields(plugin) + fields, err := SchemaFields(version) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/connectivity/instances/completion_test.go b/cmd/connectivity/connectorinstances/completion_test.go similarity index 65% rename from cmd/connectivity/instances/completion_test.go rename to cmd/connectivity/connectorinstances/completion_test.go index 2be47954..6899338a 100644 --- a/cmd/connectivity/instances/completion_test.go +++ b/cmd/connectivity/connectorinstances/completion_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -14,23 +14,18 @@ import ( ) type completionClientMock struct { - connectivityclient.Client - listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) - getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) + connectorVersions + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) } -func (m completionClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { +func (m completionClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return m.listInstances(ctx, options) } -func (m completionClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - return m.getPlugin(ctx, name) -} - -func TestCompleteInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { +func TestCompleteConnectorInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { var gotOptions connectivityclient.ListOptions var remaining time.Duration - client := completionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + client := completionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { gotOptions = options deadline, ok := ctx.Deadline() if !ok { @@ -38,16 +33,16 @@ func TestCompleteInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPref } remaining = time.Until(deadline) alpha := instanceFixture("alpha") - alpha.Spec.Plugin = "stripe" + alpha.Spec.Connector = "stripe" alpha.Spec.Ledger = "eu" alpine := instanceFixture("alpine") - alpine.Spec.Plugin = "wise" + alpine.Spec.Connector = "wise" alpine.Spec.Ledger = "uk" - return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{ + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{ instanceFixture("beta"), alpine, alpha, }}, nil }} - completion := CompleteInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + completion := CompleteConnectorInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { if !connectivityinternal.IsNonInteractive(cmd.Context()) { t.Fatal("completion factory context is interactive") } @@ -57,7 +52,7 @@ func TestCompleteInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPref candidates, directive := completion(&cobra.Command{}, nil, "al") if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { - t.Fatalf("ListInstances options = %#v, want limit 500", gotOptions) + t.Fatalf("ListConnectorInstances options = %#v, want limit 500", gotOptions) } if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) @@ -71,20 +66,20 @@ func TestCompleteInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPref } } -func TestCompleteVersionsUsesSelectedPluginAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { +func TestCompleteVersionsUsesSelectedConnectorAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { var gotName string - client := completionClientMock{getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - if !hasTwoSecondDeadline(ctx) { - t.Fatal("version completion context does not have the expected deadline") - } - gotName = name - plugin := pluginWithFileSchema() - plugin.Spec.Versions = []connectivityclient.VersionEntry{ - {Version: "2.1.0", Digest: stringPtr("sha256:two")}, - {Version: "1.0.0", Image: stringPtr("registry/plugin:1")}, - {Version: "2.0.0", Image: stringPtr("registry/plugin:2")}, - } - return plugin, nil + client := completionClientMock{connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + if !hasTwoSecondDeadline(ctx) { + t.Fatal("version completion context does not have the expected deadline") + } + gotName = name + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "2.1.0", Image: "", Digest: stringPtr("sha256:two")}, + {Version: "1.0.0", Image: "registry/connector:1"}, + {Version: "2.0.0", Image: "registry/connector:2"}, + }}, nil + }, }} completion := CompleteVersions(func(cmd *cobra.Command) (connectivityclient.Client, error) { if !connectivityinternal.IsNonInteractive(cmd.Context()) { @@ -98,9 +93,9 @@ func TestCompleteVersionsUsesSelectedPluginAndReturnsSortedPrefixMatchesWithDesc candidates, directive := completion(&cobra.Command{}, []string{"stripe"}, "2") if gotName != "stripe" { - t.Fatalf("GetPlugin name = %q, want stripe", gotName) + t.Fatalf("ListConnectorVersions connector = %q, want stripe", gotName) } - want := []string{"2.0.0\tregistry/plugin:2", "2.1.0\tsha256:two"} + want := []string{"2.0.0\tregistry/connector:2", "2.1.0\tsha256:two"} if !reflect.DeepEqual(candidates, want) { t.Fatalf("candidates = %#v, want %#v", candidates, want) } @@ -112,22 +107,22 @@ func TestCompleteVersionsUsesSelectedPluginAndReturnsSortedPrefixMatchesWithDesc func TestCompleteSetValuesPrioritizesRequiredKeysDescribesAndOmitsSuppliedKeys(t *testing.T) { var resolvedClient connectivityclient.Client client := &completionClientMock{} - resolvePlugin := func(ctx context.Context, got connectivityclient.Client, _ *cobra.Command, _ []string) (*connectivityclient.Plugin, error) { + resolveVersion := func(ctx context.Context, got connectivityclient.Client, _ *cobra.Command, _ []string) (*connectivityclient.ConnectorVersion, error) { if !connectivityinternal.IsNonInteractive(ctx) || !hasTwoSecondDeadline(ctx) { t.Fatal("set completion resolver context must be non-interactive with a two-second deadline") } resolvedClient = got - return pluginWithSchemaAndDefaults(), nil + return versionWithFullSchema(), nil } completion := CompleteSetValues(func(cmd *cobra.Command) (connectivityclient.Client, error) { if !connectivityinternal.IsNonInteractive(cmd.Context()) { t.Fatal("set completion factory context is interactive") } return client, nil - }, resolvePlugin, mockPathCompleter(nil)) + }, resolveVersion, mockPathCompleter(nil)) command := &cobra.Command{} command.Flags().StringArray("set", nil, "") - if err := command.Flags().Set("set", "TOKEN=secret://plugin/token"); err != nil { + if err := command.Flags().Set("set", "TOKEN=secret://connector/token"); err != nil { t.Fatal(err) } @@ -137,7 +132,7 @@ func TestCompleteSetValuesPrioritizesRequiredKeysDescribesAndOmitsSuppliedKeys(t t.Fatal("resolver did not receive the factory client") } want := []string{ - "/etc/plugin/config.yaml=\tPlugin configuration", + "/etc/plugin/config.yaml=\tConnector configuration", "API_URL=\tAPI endpoint", "/etc/plugin/ca.pem=\tfile configuration", "TIMEOUT=\tenvironment configuration", @@ -175,11 +170,13 @@ func TestCompleteSetValuesPreservesKeyAtPrefixForInjectedPathCandidates(t *testi func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors(t *testing.T) { apiErrorClient := completionClientMock{ - listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return nil, errors.New("unsupported deployment") }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return nil, errors.New("unsupported deployment") + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return nil, errors.New("unsupported deployment") + }, }, } tests := map[string]struct { @@ -189,26 +186,26 @@ func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors prefix string }{ "instance authentication": { - completion: CompleteInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }), + completion: CompleteConnectorInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }), command: &cobra.Command{}, }, "instance nil client": { - completion: CompleteInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, nil }), + completion: CompleteConnectorInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, nil }), command: &cobra.Command{}, }, - "instance API": {completion: CompleteInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, + "instance API": {completion: CompleteConnectorInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, "instance timeout": { - completion: CompleteInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + completion: CompleteConnectorInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { <-ctx.Done() return nil, ctx.Err() }})), command: commandWithExpiredContext(), }, - "version empty plugin": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "" }), command: &cobra.Command{}}, - "version API": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "stripe" }), command: &cobra.Command{}}, + "version empty connector": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "" }), command: &cobra.Command{}}, + "version API": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "stripe" }), command: &cobra.Command{}}, "set resolver": { - completion: CompleteSetValues(factoryReturning(completionClientMock{}), func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.Plugin, error) { - return nil, errors.New("cannot resolve plugin") + completion: CompleteSetValues(factoryReturning(completionClientMock{}), func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.ConnectorVersion, error) { + return nil, errors.New("cannot resolve connector version") }, mockPathCompleter(nil)), command: &cobra.Command{}, }, @@ -236,12 +233,12 @@ func TestCompletionClientCopyDoesNotMutateOriginalCommandContext(t *testing.T) { originalContext := context.WithValue(context.Background(), struct{}{}, "original") command := &cobra.Command{} command.SetContext(originalContext) - completion := CompleteInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + completion := CompleteConnectorInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { if !connectivityinternal.IsNonInteractive(cmd.Context()) { t.Fatal("copied completion command must be non-interactive") } - return completionClientMock{listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { - return &connectivityclient.InstanceList{}, nil + return completionClientMock{listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return &connectivityclient.ConnectorInstanceList{}, nil }}, nil }) diff --git a/cmd/connectivity/instances/config.go b/cmd/connectivity/connectorinstances/config.go similarity index 85% rename from cmd/connectivity/instances/config.go rename to cmd/connectivity/connectorinstances/config.go index 880b3d38..bc5b4ba3 100644 --- a/cmd/connectivity/instances/config.go +++ b/cmd/connectivity/connectorinstances/config.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "bufio" @@ -35,11 +35,11 @@ type InputOptions struct { type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) -func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, error) { - if plugin == nil { - return nil, fmt.Errorf("plugin is required") +func SchemaFields(version *connectivityclient.ConnectorVersion) (map[string]SchemaField, error) { + if version == nil { + return nil, fmt.Errorf("connector version is required") } - if len(plugin.Spec.ConfigSchema) == 0 { + if len(version.ConfigSchema) == 0 { return map[string]SchemaField{}, nil } @@ -49,12 +49,12 @@ func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, er kind ConfigKind raw any }{ - {name: "env", kind: ConfigEnv, raw: plugin.Spec.ConfigSchema["env"]}, - {name: "files", kind: ConfigFile, raw: plugin.Spec.ConfigSchema["files"]}, + {name: "env", kind: ConfigEnv, raw: version.ConfigSchema["env"]}, + {name: "files", kind: ConfigFile, raw: version.ConfigSchema["files"]}, } if sections[0].raw == nil { - if _, hasLegacyProperties := plugin.Spec.ConfigSchema["properties"]; hasLegacyProperties { - sections[0].raw = plugin.Spec.ConfigSchema + if _, hasLegacyProperties := version.ConfigSchema["properties"]; hasLegacyProperties { + sections[0].raw = version.ConfigSchema } } @@ -66,19 +66,19 @@ func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, er } sectionSchema, ok := stringMap(rawSection) if !ok { - return nil, fmt.Errorf("plugin config schema %s must be an object", section.name) + return nil, fmt.Errorf("connector config schema %s must be an object", section.name) } sectionProperties, ok := stringMap(sectionSchema["properties"]) if !ok { if sectionSchema["properties"] == nil { sectionProperties = map[string]any{} } else { - return nil, fmt.Errorf("plugin config schema %s properties must be an object", section.name) + return nil, fmt.Errorf("connector config schema %s properties must be an object", section.name) } } required, err := requiredSet(sectionSchema["required"]) if err != nil { - return nil, fmt.Errorf("plugin config schema %s: %w", section.name, err) + return nil, fmt.Errorf("connector config schema %s: %w", section.name, err) } for key, rawDefinition := range sectionProperties { @@ -87,7 +87,7 @@ func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, er } definition, ok := stringMap(rawDefinition) if !ok { - return nil, fmt.Errorf("plugin config schema field %q must be an object", key) + return nil, fmt.Errorf("connector config schema field %q must be an object", key) } format, _ := definition["format"].(string) legacySecret, _ := definition["x-secret"].(bool) @@ -105,20 +105,16 @@ func SchemaFields(plugin *connectivityclient.Plugin) (map[string]SchemaField, er return fields, nil } -func BuildInstallConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { - var base *connectivityclient.InstanceConfig - if plugin != nil { - base = plugin.Spec.Defaults - } - return buildConfig(cmd, plugin, base, inputs, read) +func BuildInstallConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + return buildConfig(cmd, version, nil, inputs, read) } -func BuildConfigureConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, current *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { - return buildConfig(cmd, plugin, current, inputs, read) +func BuildConfigureConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, current *connectivityclient.ConnectorInstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + return buildConfig(cmd, version, current, inputs, read) } -func buildConfig(cmd *cobra.Command, plugin *connectivityclient.Plugin, base *connectivityclient.InstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.InstanceConfig, error) { - fields, err := SchemaFields(plugin) +func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, base *connectivityclient.ConnectorInstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + fields, err := SchemaFields(version) if err != nil { return nil, err } @@ -255,7 +251,7 @@ func parseAssignment(raw string) (string, string, error) { return key, parts[1], nil } -func applyDotenv(config *connectivityclient.InstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { +func applyDotenv(config *connectivityclient.ConnectorInstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { scanner := bufio.NewScanner(strings.NewReader(contents)) line := 0 for scanner.Scan() { @@ -355,7 +351,7 @@ func applyDoubleQuoteEscapes(value string) string { return decoded.String() } -func applyConfigDocument(config *connectivityclient.InstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { +func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { var root map[string]any if err := yaml.Unmarshal([]byte(contents), &root); err != nil { return err @@ -461,7 +457,7 @@ func validateSources(value *string, secretRef, configMapRef *connectivityclient. return nil } -func applyValue(config *connectivityclient.InstanceConfig, field SchemaField, value parsedValue) { +func applyValue(config *connectivityclient.ConnectorInstanceConfig, field SchemaField, value parsedValue) { if field.Kind == ConfigEnv { config.Env[field.Key] = connectivityclient.EnvValue{ Value: value.value, @@ -478,7 +474,7 @@ func applyValue(config *connectivityclient.InstanceConfig, field SchemaField, va }) } -func replaceFile(config *connectivityclient.InstanceConfig, replacement connectivityclient.FileMount) { +func replaceFile(config *connectivityclient.ConnectorInstanceConfig, replacement connectivityclient.FileMount) { for index := range config.Files { if config.Files[index].Path == replacement.Path { if replacement.Mode == nil { @@ -491,7 +487,7 @@ func replaceFile(config *connectivityclient.InstanceConfig, replacement connecti config.Files = append(config.Files, replacement) } -func hasValue(config *connectivityclient.InstanceConfig, field SchemaField) bool { +func hasValue(config *connectivityclient.ConnectorInstanceConfig, field SchemaField) bool { if field.Kind == ConfigEnv { value, exists := config.Env[field.Key] return exists && validateSources(value.Value, value.SecretRef, value.ConfigMapRef) == nil @@ -504,8 +500,8 @@ func hasValue(config *connectivityclient.InstanceConfig, field SchemaField) bool return false } -func cloneConfig(source *connectivityclient.InstanceConfig) *connectivityclient.InstanceConfig { - clone := &connectivityclient.InstanceConfig{Env: make(map[string]connectivityclient.EnvValue)} +func cloneConfig(source *connectivityclient.ConnectorInstanceConfig) *connectivityclient.ConnectorInstanceConfig { + clone := &connectivityclient.ConnectorInstanceConfig{Env: make(map[string]connectivityclient.EnvValue)} if source == nil { return clone } diff --git a/cmd/connectivity/instances/config_test.go b/cmd/connectivity/connectorinstances/config_test.go similarity index 67% rename from cmd/connectivity/instances/config_test.go rename to cmd/connectivity/connectorinstances/config_test.go index 8817f111..f42449e5 100644 --- a/cmd/connectivity/instances/config_test.go +++ b/cmd/connectivity/connectorinstances/config_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "testing" @@ -10,7 +10,7 @@ import ( ) func TestSchemaFieldsExtractsEnvironmentAndFileMetadata(t *testing.T) { - fields, err := SchemaFields(pluginWithSchemaAndDefaults()) + fields, err := SchemaFields(versionWithFullSchema()) require.NoError(t, err) require.Equal(t, SchemaField{ @@ -24,7 +24,7 @@ func TestSchemaFieldsExtractsEnvironmentAndFileMetadata(t *testing.T) { Key: "/etc/plugin/config.yaml", Kind: ConfigFile, Required: true, - Description: "Plugin configuration", + Description: "Connector configuration", }, fields["/etc/plugin/config.yaml"]) require.False(t, fields["TIMEOUT"].Required) require.True(t, fields["/etc/plugin/ca.pem"].Password) @@ -53,9 +53,9 @@ func TestSchemaFieldsRejectsAmbiguousAndMalformedSchemas(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ConfigSchema: tt.schema}} + version := &connectivityclient.ConnectorVersion{ConfigSchema: tt.schema} - _, err := SchemaFields(plugin) + _, err := SchemaFields(version) require.ErrorContains(t, err, tt.want) }) @@ -63,7 +63,7 @@ func TestSchemaFieldsRejectsAmbiguousAndMalformedSchemas(t *testing.T) { } func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { - plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ConfigSchema: map[string]any{ + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ "type": "object", "required": []any{"LEGACY_TOKEN"}, "properties": map[string]any{ @@ -73,9 +73,9 @@ func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { "description": "Legacy token", }, }, - }}} + }} - fields, err := SchemaFields(plugin) + fields, err := SchemaFields(version) require.NoError(t, err) require.Equal(t, SchemaField{ @@ -87,19 +87,18 @@ func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { }, fields["LEGACY_TOKEN"]) } -func TestBuildInstallConfigAllowsPluginWithoutSchema(t *testing.T) { - plugin := &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{Defaults: &connectivityclient.InstanceConfig{ - Env: map[string]connectivityclient.EnvValue{"SERVER_DEFAULT": {Value: stringPtr("kept")}}, - }}} +func TestBuildInstallConfigAllowsConnectorVersionWithoutSchema(t *testing.T) { + version := &connectivityclient.ConnectorVersion{Version: "2.0.0", Image: "example/connector:2.0.0"} - got, err := BuildInstallConfig(&cobra.Command{}, plugin, InputOptions{}, mapReadFile(nil)) + got, err := BuildInstallConfig(&cobra.Command{}, version, InputOptions{}, mapReadFile(nil)) require.NoError(t, err) - require.Equal(t, "kept", *got.Env["SERVER_DEFAULT"].Value) + require.Empty(t, got.Env) + require.Empty(t, got.Files) } func TestBuildInstallConfigParsesFlatYAMLAsEnvironmentValues(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ "config.yaml": "API_URL: https://config.example\nTOKEN: config-token\nTIMEOUT: 45s\n", })) @@ -110,18 +109,18 @@ func TestBuildInstallConfigParsesFlatYAMLAsEnvironmentValues(t *testing.T) { } func TestBuildInstallConfigParsesStructuredYAML(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ "config.yaml": `env: API_URL: value: https://structured.example TOKEN: secretRef: - name: plugin-secrets + name: connector-secrets key: token files: - path: /etc/plugin/config.yaml configMapRef: - name: plugin-config + name: connector-config key: config.yaml - path: /etc/plugin/ca.pem value: structured-ca @@ -130,14 +129,14 @@ files: require.NoError(t, err) require.Equal(t, "https://structured.example", *got.Env["API_URL"].Value) - require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) require.Nil(t, got.Env["TOKEN"].Value) - require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) require.Equal(t, "structured-ca", *got.Files[1].Value) } func TestBuildInstallConfigParsesDotenvFilesInOrder(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"first.env", "second.env"}}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"first.env", "second.env"}}, mapReadFile(map[string]string{ "first.env": "# comment\n\nAPI_URL=https://first.example\nTOKEN=first=token\n", "second.env": "API_URL=https://second.example\n", })) @@ -148,7 +147,7 @@ func TestBuildInstallConfigParsesDotenvFilesInOrder(t *testing.T) { } func TestBuildInstallConfigParsesEstablishedDotenvSyntax(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ "values.env": ` export API_URL = https://env.example # endpoint TOKEN="line-one\nline-two\t\"quoted\"\\tail" TIMEOUT=' literal # value ' @@ -162,7 +161,7 @@ TIMEOUT=' literal # value ' } func TestBuildInstallConfigUnquotesDotenvValueBeforeTrailingComment(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ "values.env": "TOKEN=\"abc#def\" # trailing comment\n", })) @@ -171,7 +170,7 @@ func TestBuildInstallConfigUnquotesDotenvValueBeforeTrailingComment(t *testing.T } func TestBuildInstallConfigPreservesHashWithinUnquotedDotenvValue(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ "values.env": "TOKEN=abc#def\n", })) @@ -180,7 +179,7 @@ func TestBuildInstallConfigPreservesHashWithinUnquotedDotenvValue(t *testing.T) } func TestBuildInstallConfigAppliesDocumentedSourcePrecedence(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ ConfigFile: "config.yaml", EnvFiles: []string{"first.env", "second.env"}, SetValues: []string{"API_URL=https://set-one.example", "API_URL=https://set-two.example"}, @@ -192,11 +191,11 @@ func TestBuildInstallConfigAppliesDocumentedSourcePrecedence(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://set-two.example", *got.Env["API_URL"].Value) - require.Equal(t, "default-token", *got.Env["TOKEN"].Value) + require.NotContains(t, got.Env, "TOKEN", "no source supplied TOKEN") } func TestBuildInstallConfigReadsInlineFileAndStdinSetValues(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ "TIMEOUT=90s", "TOKEN=@token.txt", "/etc/plugin/config.yaml=@-", @@ -212,40 +211,38 @@ func TestBuildInstallConfigReadsInlineFileAndStdinSetValues(t *testing.T) { } func TestBuildInstallConfigBuildsSecretAndConfigMapReferences(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ - "TOKEN=secret://plugin-secrets/token", - "/etc/plugin/config.yaml=configmap://plugin-config/config.yaml", + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ + "TOKEN=secret://connector-secrets/token", + "/etc/plugin/config.yaml=configmap://connector-config/config.yaml", }}, mapReadFile(nil)) require.NoError(t, err) - require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) require.Nil(t, got.Env["TOKEN"].Value) - require.Equal(t, &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) require.Nil(t, got.Files[0].Value) } func TestBuildInstallConfigRoutesAssignmentsUsingSchema(t *testing.T) { - got, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ "API_URL=https://routed.example", "/etc/plugin/ca.pem=routed-ca", }}, mapReadFile(nil)) require.NoError(t, err) require.Equal(t, "https://routed.example", *got.Env["API_URL"].Value) - require.Equal(t, "routed-ca", *got.Files[1].Value) + require.Equal(t, "/etc/plugin/ca.pem", got.Files[0].Path) + require.Equal(t, "routed-ca", *got.Files[0].Value) } func TestBuildInstallConfigReportsSortedMissingRequiredKeys(t *testing.T) { - plugin := pluginWithSchemaAndDefaults() - plugin.Spec.Defaults = nil - - _, err := BuildInstallConfig(&cobra.Command{}, plugin, InputOptions{}, mapReadFile(nil)) + _, err := BuildInstallConfig(&cobra.Command{}, versionWithFullSchema(), InputOptions{}, mapReadFile(nil)) require.EqualError(t, err, "missing required configuration keys: /etc/plugin/config.yaml, API_URL, TOKEN") } func TestBuildInstallConfigReportsSortedUnknownKeys(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{ + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ "WRONG=value", "BOGUS=value", }}, mapReadFile(nil)) @@ -268,7 +265,7 @@ func TestBuildInstallConfigRejectsMalformedAssignmentsAndReferences(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{SetValues: []string{tt.value}}, mapReadFile(nil)) + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{tt.value}}, mapReadFile(nil)) require.ErrorContains(t, err, tt.want) }) @@ -285,7 +282,7 @@ func TestBuildInstallConfigMalformedSetErrorDoesNotEchoSecretAssignment(t *testi for _, assignment := range tests { _, err := BuildInstallConfig( &cobra.Command{}, - pluginWithSchemaAndDefaults(), + versionWithOptionalSchema(), InputOptions{SetValues: []string{assignment}}, mapReadFile(nil), ) @@ -298,19 +295,19 @@ func TestBuildInstallConfigMalformedSetErrorDoesNotEchoSecretAssignment(t *testi func TestBuildInstallConfigReturnsInputReadAndParseErrors(t *testing.T) { t.Run("unreadable config", func(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "missing.yaml"}, mapReadFile(nil)) + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "missing.yaml"}, mapReadFile(nil)) require.ErrorContains(t, err, "missing.yaml") }) t.Run("malformed config", func(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{"config.yaml": "env: ["})) + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{"config.yaml": "env: ["})) require.ErrorContains(t, err, "config.yaml") }) t.Run("malformed dotenv", func(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{EnvFiles: []string{"bad.env"}}, mapReadFile(map[string]string{"bad.env": "TOKEN\n"})) + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"bad.env"}}, mapReadFile(map[string]string{"bad.env": "TOKEN\n"})) require.ErrorContains(t, err, "bad.env:1") }) @@ -318,11 +315,11 @@ func TestBuildInstallConfigReturnsInputReadAndParseErrors(t *testing.T) { func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { mode := int32(0o600) - current := &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ + current := &connectivityclient.ConnectorInstanceConfig{Files: []connectivityclient.FileMount{ {Path: "/etc/a", Value: stringPtr("a"), Mode: &mode}, {Path: "/etc/b", Value: stringPtr("b")}, }} - got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, + got, err := BuildConfigureConfig(&cobra.Command{}, versionWithFileSchema(), current, InputOptions{SetValues: []string{"/etc/a=@new"}}, func(_ *cobra.Command, name string) (string, error) { require.Equal(t, "new", name) @@ -338,7 +335,7 @@ func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { } func TestBuildInstallConfigRejectsStructuredFileWithoutPath(t *testing.T) { - _, err := BuildInstallConfig(&cobra.Command{}, pluginWithSchemaAndDefaults(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ "config.yaml": "files:\n - value: missing-path\n", })) @@ -346,7 +343,7 @@ func TestBuildInstallConfigRejectsStructuredFileWithoutPath(t *testing.T) { } func TestBuildConfigureConfigDeepCopiesCurrentConfiguration(t *testing.T) { - current := &connectivityclient.InstanceConfig{ + current := &connectivityclient.ConnectorInstanceConfig{ Env: map[string]connectivityclient.EnvValue{ "API_URL": {Value: stringPtr("https://current.example")}, }, @@ -355,7 +352,7 @@ func TestBuildConfigureConfigDeepCopiesCurrentConfiguration(t *testing.T) { }, } - got, err := BuildConfigureConfig(&cobra.Command{}, pluginWithFileSchema(), current, InputOptions{ + got, err := BuildConfigureConfig(&cobra.Command{}, versionWithFileSchema(), current, InputOptions{ SetValues: []string{"API_URL=https://changed.example", "/etc/a=changed-a"}, }, mapReadFile(nil)) diff --git a/cmd/connectivity/instances/configure.go b/cmd/connectivity/connectorinstances/configure.go similarity index 60% rename from cmd/connectivity/instances/configure.go rename to cmd/connectivity/connectorinstances/configure.go index 6d3a03c0..8515a1c9 100644 --- a/cmd/connectivity/instances/configure.go +++ b/cmd/connectivity/connectorinstances/configure.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -12,7 +12,7 @@ import ( ) type ConfigureStore struct { - Instance connectivityclient.Instance `json:"instance"` + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` } type ConfigureController struct { @@ -36,13 +36,13 @@ func NewConfigureController(factory connectivityinternal.ClientFactory, read Rea func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { controller := NewConfigureController(factory, read) command := fctl.NewCommand( - "configure ", + "configure ", fctl.WithAliases("config", "update", "c"), - fctl.WithShortDescription("Configure a Connectivity plugin instance"), + fctl.WithShortDescription("Configure a Connectivity connector instance"), fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), - fctl.WithStringFlag(versionFlag, "", "Plugin version"), + fctl.WithStringFlag(versionFlag, "", "Connector version"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -50,24 +50,39 @@ func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFi fctl.WithConfirmFlag(), fctl.WithController[*ConfigureStore](controller), ) - if err := command.RegisterFlagCompletionFunc(versionFlag, completeVersions(factory, resolveConfigurePlugin)); err != nil { + if err := command.RegisterFlagCompletionFunc(versionFlag, completeVersions(factory, resolveConfigureConnector)); err != nil { panic(err) } - if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveConfigurePlugin, paths)); err != nil { + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveConfigureConnectorVersion, paths)); err != nil { panic(err) } return command } -func resolveConfigurePlugin(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (*connectivityclient.Plugin, error) { +func resolveConfigureConnector(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (string, error) { + if len(args) == 0 { + return "", nil + } + instance, err := client.GetConnectorInstance(ctx, args[0]) + if err != nil || instance == nil { + return "", err + } + return instance.Spec.Connector, nil +} + +func resolveConfigureConnectorVersion(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.ConnectorVersion, error) { if len(args) == 0 { return nil, nil } - instance, err := client.GetInstance(ctx, args[0]) + instance, err := client.GetConnectorInstance(ctx, args[0]) if err != nil || instance == nil { return nil, err } - return client.GetPlugin(ctx, instance.Spec.Plugin) + pinned := fctl.GetString(cmd, versionFlag) + if pinned == "" { + pinned = instanceVersionPin(instance) + } + return resolveConnectorVersion(ctx, client, instance.Spec.Connector, pinned) } func (c *ConfigureController) GetStore() *ConfigureStore { @@ -84,24 +99,25 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende } name := args[0] - instance, err := client.GetInstance(cmd.Context(), name) + instance, err := client.GetConnectorInstance(cmd.Context(), name) if err != nil { return nil, err } if instance == nil { - return nil, fmt.Errorf("configure connectivity instance %q: empty instance response", name) - } - plugin, err := client.GetPlugin(cmd.Context(), instance.Spec.Plugin) - if err != nil { - return nil, err - } - if plugin == nil { - return nil, fmt.Errorf("configure connectivity instance %q: empty plugin response", name) + return nil, fmt.Errorf("configure connectivity connector instance %q: empty response", name) } specPatch := map[string]any{} configChanged := cmd.Flags().Changed(configFlag) || cmd.Flags().Changed(envFileFlag) || cmd.Flags().Changed(setFlag) if configChanged { + pinned := instanceVersionPin(instance) + if cmd.Flags().Changed(versionFlag) { + pinned = fctl.GetString(cmd, versionFlag) + } + version, err := resolveConnectorVersion(cmd.Context(), client, instance.Spec.Connector, pinned) + if err != nil { + return nil, err + } envFiles, err := cmd.Flags().GetStringArray(envFileFlag) if err != nil { return nil, err @@ -110,7 +126,7 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende if err != nil { return nil, err } - config, err := BuildConfigureConfig(cmd, plugin, instance.Spec.Config, InputOptions{ + config, err := BuildConfigureConfig(cmd, version, instance.Spec.Config, InputOptions{ ConfigFile: fctl.GetString(cmd, configFlag), EnvFiles: envFiles, SetValues: setValues, @@ -132,22 +148,22 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende if len(specPatch) == 0 { return nil, fmt.Errorf("no configuration changes requested") } - if !c.approve(cmd, "You are about to configure Connectivity instance %q", name) { + if !c.approve(cmd, "You are about to configure Connectivity connector instance %q", name) { return nil, fctl.ErrMissingApproval } - updated, err := client.PatchInstance(cmd.Context(), name, connectivityclient.InstancePatch{"spec": specPatch}) + updated, err := client.PatchConnectorInstance(cmd.Context(), name, connectivityclient.ConnectorInstancePatch{"spec": specPatch}) if err != nil { return nil, err } if updated == nil { - return nil, fmt.Errorf("configure connectivity instance %q: empty instance response", name) + return nil, fmt.Errorf("configure connectivity connector instance %q: empty response", name) } - c.store.Instance = *updated + c.store.ConnectorInstance = *updated return c, nil } func (c *ConfigureController) Render(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), "Instance %q configured.\n", stringValue(c.store.Instance.Metadata.Name)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q configured.\n", stringValue(c.store.ConnectorInstance.Metadata.Name)) return err } diff --git a/cmd/connectivity/instances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go similarity index 55% rename from cmd/connectivity/instances/configure_test.go rename to cmd/connectivity/connectorinstances/configure_test.go index 55c333f1..e98aa9b9 100644 --- a/cmd/connectivity/instances/configure_test.go +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -17,55 +17,49 @@ import ( ) type configureClientMock struct { - connectivityclient.Client - listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) - getInstance func(context.Context, string) (*connectivityclient.Instance, error) - getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) - patch func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) + connectorVersions + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) + getInstance func(context.Context, string) (*connectivityclient.ConnectorInstance, error) + patch func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) } -func (m configureClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { +func (m configureClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return m.listInstances(ctx, options) } -func (m configureClientMock) GetInstance(ctx context.Context, name string) (*connectivityclient.Instance, error) { +func (m configureClientMock) GetConnectorInstance(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { return m.getInstance(ctx, name) } -func (m configureClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - return m.getPlugin(ctx, name) -} - -func (m configureClientMock) PatchInstance(ctx context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { +func (m configureClientMock) PatchConnectorInstance(ctx context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { return m.patch(ctx, name, patch) } func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing.T) { mode := int32(384) current := instanceFixture("stripe-eu") - current.Spec.Config = &connectivityclient.InstanceConfig{ + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{ Env: map[string]connectivityclient.EnvValue{"API_URL": {Value: stringPtr("https://current.example")}}, Files: []connectivityclient.FileMount{ {Path: "/etc/a", Value: stringPtr("old"), Mode: &mode}, - {Path: "/etc/b", SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "b"}}, + {Path: "/etc/b", SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "b"}}, }, } - plugin := pluginWithFileSchema() - plugin.Metadata.Name = stringPtr("stripe") - plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{{Path: "/etc/b", Value: stringPtr("must-not-replace-current")}}} returned := instanceFixture("stripe-eu") var order []string - var gotPatch connectivityclient.InstancePatch + var gotPatch connectivityclient.ConnectorInstancePatch client := configureClientMock{ - getInstance: func(_ context.Context, name string) (*connectivityclient.Instance, error) { + getInstance: func(_ context.Context, name string) (*connectivityclient.ConnectorInstance, error) { order = append(order, "instance:"+name) return ¤t, nil }, - getPlugin: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { - order = append(order, "plugin:"+name) - return plugin, nil + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + order = append(order, "version:"+connector+"@"+version) + return versionWithFileSchema(), nil + }, }, - patch: func(_ context.Context, name string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + patch: func(_ context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { order = append(order, "patch:"+name) gotPatch = patch return &returned, nil @@ -79,36 +73,89 @@ func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing ) require.NoError(t, err) - require.Equal(t, []string{"instance:stripe-eu", "plugin:stripe", "patch:stripe-eu"}, order) + require.Equal(t, []string{"instance:stripe-eu", "version:stripe@3.0.0", "patch:stripe-eu"}, order, + "the requested version supplies the schema the new configuration is validated against") spec, ok := gotPatch["spec"].(map[string]any) require.True(t, ok) require.Equal(t, "3.0.0", spec["version"]) require.Equal(t, "archive", spec["ledger"]) require.Equal(t, "15s", spec["pollInterval"]) - require.Len(t, spec, 4, "the immutable plugin and unchanged fields must not be patched") - config, ok := spec["config"].(*connectivityclient.InstanceConfig) + require.Len(t, spec, 4, "the immutable connector and unchanged fields must not be patched") + config, ok := spec["config"].(*connectivityclient.ConnectorInstanceConfig) require.True(t, ok) - require.Equal(t, "https://current.example", *config.Env["API_URL"].Value, "current config must win over plugin defaults") + require.Equal(t, "https://current.example", *config.Env["API_URL"].Value, "the current configuration is the patch base") require.Len(t, config.Files, 2) require.Equal(t, "/etc/a", config.Files[0].Path) require.Equal(t, "changed", *config.Files[0].Value) require.Equal(t, mode, *config.Files[0].Mode) require.Equal(t, current.Spec.Config.Files[1], config.Files[1], "untouched file mounts must be preserved") - require.Equal(t, `Instance "stripe-eu" configured.`, strings.TrimSpace(output)) + require.Equal(t, `Connector instance "stripe-eu" configured.`, strings.TrimSpace(output)) +} + +func TestConfigureWithoutVersionFlagUsesTheInstancePin(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("1.4.2") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "1.4.2", gotVersion) +} + +func TestConfigureFallsBackToTheAppliedVersionWhenUnpinned(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Status.ResolvedVersion = stringPtr("1.9.9") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "1.9.9", gotVersion) } func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *testing.T) { current := instanceFixture("stripe-eu") - current.Spec.Config = &connectivityclient.InstanceConfig{} + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} returned := instanceFixture("stripe-eu") returned.Metadata.Labels = map[string]string{"region": "eu"} - var gotPatch connectivityclient.InstancePatch + var gotPatch connectivityclient.ConnectorInstancePatch client := configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - patch: func(_ context.Context, _ string, patch connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { gotPatch = patch return &returned, nil }, @@ -119,25 +166,22 @@ func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *test output, err := executeCommand(command, "stripe-eu", "--ledger=archive", "--confirm", "--output=json") require.NoError(t, err) - require.Equal(t, connectivityclient.InstancePatch{"spec": map[string]any{"ledger": "archive"}}, gotPatch) + require.Equal(t, connectivityclient.ConnectorInstancePatch{"spec": map[string]any{"ledger": "archive"}}, gotPatch) var envelope struct { Data ConfigureStore `json:"data"` } require.NoError(t, json.Unmarshal([]byte(output), &envelope)) - require.Equal(t, returned, envelope.Data.Instance) + require.Equal(t, returned, envelope.Data.ConnectorInstance) } func TestConfigureRejectsNoChangesWithoutPatch(t *testing.T) { current := instanceFixture("stripe-eu") patched := false client := configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { patched = true - return nil, errors.New("PatchInstance must not be called") + return nil, errors.New("PatchConnectorInstance must not be called") }, } @@ -151,13 +195,10 @@ func TestConfigureConfirmationRejectionPreventsPatch(t *testing.T) { current := instanceFixture("stripe-eu") patched := false client := configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { patched = true - return nil, errors.New("PatchInstance must not be called") + return nil, errors.New("PatchConnectorInstance must not be called") }, } controller := NewConfigureController(factoryReturning(client), mockReadFile(nil)) @@ -176,11 +217,15 @@ func TestConfigureValidationAndArgumentsPreventPatch(t *testing.T) { current := instanceFixture("stripe-eu") patched := false client := configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return pluginWithFileSchema(), nil }, - patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { patched = true - return nil, errors.New("PatchInstance must not be called") + return nil, errors.New("PatchConnectorInstance must not be called") }, } @@ -206,44 +251,52 @@ func TestConfigureValidationAndArgumentsPreventPatch(t *testing.T) { } func TestConfigurePreservesGetAndPatchAPIErrorsAndRejectsEmptyResponses(t *testing.T) { - instanceError := &connectivityclient.APIError{StatusCode: 404, Code: "INSTANCE_NOT_FOUND", Message: "missing"} - pluginError := &connectivityclient.APIError{StatusCode: 404, Code: "PLUGIN_NOT_FOUND", Message: "missing"} + instanceError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORINSTANCE_NOT_FOUND", Message: "missing"} + versionError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORVERSION_NOT_FOUND", Message: "missing"} patchError := &connectivityclient.APIError{StatusCode: 409, Code: "CONFLICT", Message: "stale"} current := instanceFixture("stripe-eu") tests := map[string]struct { client configureClientMock + args []string want error text string }{ "instance API": { - client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return nil, instanceError }}, - want: instanceError, + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { + return nil, instanceError + }}, + want: instanceError, }, "empty instance": { - client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return nil, nil }}, - text: "empty instance response", + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return nil, nil }}, + text: "empty response", }, - "plugin API": { + "version API": { client: configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, pluginError }, + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, versionError + }, + }, }, - want: pluginError, + args: []string{"--set=API_URL=https://example"}, + want: versionError, }, - "empty plugin": { + "empty version": { client: configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, nil }, + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, nil }, + }, }, - text: "empty plugin response", + args: []string{"--set=API_URL=https://example"}, + text: "empty response", }, "patch API": { client: configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { return nil, patchError }, }, @@ -251,21 +304,19 @@ func TestConfigurePreservesGetAndPatchAPIErrorsAndRejectsEmptyResponses(t *testi }, "empty patch": { client: configureClientMock{ - getInstance: func(context.Context, string) (*connectivityclient.Instance, error) { return ¤t, nil }, - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - patch: func(context.Context, string, connectivityclient.InstancePatch) (*connectivityclient.Instance, error) { + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { return nil, nil }, }, - text: "empty instance response", + text: "empty response", }, } for name, test := range tests { t.Run(name, func(t *testing.T) { - _, err := executeCommand(NewConfigureCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--ledger=archive", "--confirm") + args := append([]string{"stripe-eu", "--ledger=archive", "--confirm"}, test.args...) + _, err := executeCommand(NewConfigureCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), args...) require.Error(t, err) if test.want != nil { require.ErrorIs(t, err, test.want) @@ -278,24 +329,29 @@ func TestConfigurePreservesGetAndPatchAPIErrorsAndRejectsEmptyResponses(t *testi } func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { - plugin := pluginWithFileSchema() - plugin.Metadata.Name = stringPtr("stripe") current := instanceFixture("stripe-eu") var pathPrefix string client := configureClientMock{ - listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) - return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{current}}, nil + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{current}}, nil }, - getInstance: func(ctx context.Context, name string) (*connectivityclient.Instance, error) { + getInstance: func(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { require.True(t, connectivityinternal.IsNonInteractive(ctx)) require.Equal(t, "stripe-eu", name) return ¤t, nil }, - getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - require.True(t, connectivityinternal.IsNonInteractive(ctx)) - require.Equal(t, "stripe", name) - return plugin, nil + connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionListFixture(), nil + }, + getVersion: func(ctx context.Context, name, _ string) (*connectivityclient.ConnectorVersion, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionWithFileSchema(), nil + }, }, } factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { @@ -308,7 +364,7 @@ func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { } command := NewConfigureCommand(factory, mockReadFile(nil), paths) - require.Equal(t, "configure ", command.Use) + require.Equal(t, "configure ", command.Use) require.Equal(t, []string{"config", "update", "c"}, command.Aliases) require.Error(t, command.Args(command, nil)) require.NoError(t, command.Args(command, []string{"stripe-eu"})) @@ -320,7 +376,7 @@ func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { versionCompletion, ok := command.GetFlagCompletionFunc(versionFlag) require.True(t, ok) versions, directive := versionCompletion(command, []string{"stripe-eu"}, "2") - require.Equal(t, []string{"2.0.0\texample/plugin:2.0.0"}, versions) + require.Equal(t, []string{"2.0.0\texample/connector:2.0.0"}, versions) require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) setCompletion, ok := command.GetFlagCompletionFunc(setFlag) diff --git a/cmd/connectivity/instances/install.go b/cmd/connectivity/connectorinstances/install.go similarity index 65% rename from cmd/connectivity/instances/install.go rename to cmd/connectivity/connectorinstances/install.go index 3e5d193b..0f21a388 100644 --- a/cmd/connectivity/instances/install.go +++ b/cmd/connectivity/connectorinstances/install.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" - "github.com/formancehq/fctl/v3/cmd/connectivity/plugins" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" fctl "github.com/formancehq/fctl/v3/pkg" ) @@ -23,7 +23,7 @@ const ( ) type InstallStore struct { - Instance connectivityclient.Instance `json:"instance"` + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` } type approvalFunc func(*cobra.Command, string, ...any) bool @@ -49,14 +49,14 @@ func NewInstallController(factory connectivityinternal.ClientFactory, read ReadF func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { controller := NewInstallController(factory, read) command := fctl.NewCommand( - "install ", + "install ", fctl.WithAliases("create", "in"), - fctl.WithShortDescription("Install a Connectivity plugin instance"), + fctl.WithShortDescription("Install a Connectivity connector instance"), fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(plugins.CompletePluginNames(factory)), - fctl.WithStringFlag(nameFlag, "", "Instance name (defaults to the plugin name)"), + fctl.WithValidArgsFunction(connectors.CompleteConnectorNames(factory)), + fctl.WithStringFlag(nameFlag, "", "Connector instance name (defaults to the connector name)"), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), - fctl.WithStringFlag(versionFlag, "", "Plugin version"), + fctl.WithStringFlag(versionFlag, "", "Connector version"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -67,27 +67,27 @@ func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFile if err := command.MarkFlagRequired(ledgerFlag); err != nil { panic(err) } - if err := command.RegisterFlagCompletionFunc(versionFlag, CompleteVersions(factory, installPluginArgument)); err != nil { + if err := command.RegisterFlagCompletionFunc(versionFlag, CompleteVersions(factory, installConnectorArgument)); err != nil { panic(err) } - if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveInstallPlugin, paths)); err != nil { + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveInstallConnectorVersion, paths)); err != nil { panic(err) } return command } -func installPluginArgument(_ *cobra.Command, args []string) string { +func installConnectorArgument(_ *cobra.Command, args []string) string { if len(args) == 0 { return "" } return args[0] } -func resolveInstallPlugin(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (*connectivityclient.Plugin, error) { +func resolveInstallConnectorVersion(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.ConnectorVersion, error) { if len(args) == 0 { return nil, nil } - return client.GetPlugin(ctx, args[0]) + return resolveConnectorVersion(ctx, client, args[0], fctl.GetString(cmd, versionFlag)) } func (c *InstallController) GetStore() *InstallStore { @@ -103,14 +103,11 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera return nil, err } - pluginName := args[0] - plugin, err := client.GetPlugin(cmd.Context(), pluginName) + connectorName := args[0] + version, err := resolveConnectorVersion(cmd.Context(), client, connectorName, fctl.GetString(cmd, versionFlag)) if err != nil { return nil, err } - if plugin == nil { - return nil, fmt.Errorf("install connectivity plugin %q: empty plugin response", pluginName) - } envFiles, err := cmd.Flags().GetStringArray(envFileFlag) if err != nil { @@ -120,7 +117,7 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera if err != nil { return nil, err } - config, err := BuildInstallConfig(cmd, plugin, InputOptions{ + config, err := BuildInstallConfig(cmd, version, InputOptions{ ConfigFile: fctl.GetString(cmd, configFlag), EnvFiles: envFiles, SetValues: setValues, @@ -134,12 +131,12 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera name := fctl.GetString(cmd, nameFlag) if name == "" { - name = pluginName + name = connectorName } - spec := connectivityclient.InstanceSpec{ - Plugin: pluginName, - Ledger: fctl.GetString(cmd, ledgerFlag), - Config: config, + spec := connectivityclient.ConnectorInstanceSpec{ + Connector: connectorName, + Ledger: fctl.GetString(cmd, ledgerFlag), + Config: config, } if cmd.Flags().Changed(versionFlag) { value, err := cmd.Flags().GetString(versionFlag) @@ -156,31 +153,31 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera spec.PollInterval = fctl.Ptr(value) } - if !c.approve(cmd, "You are about to install Connectivity instance %q", name) { + if !c.approve(cmd, "You are about to install Connectivity connector instance %q", name) { return nil, fctl.ErrMissingApproval } - instance, err := client.CreateInstance(cmd.Context(), connectivityclient.InstanceCreate{Name: name, Spec: spec}) + instance, err := client.CreateConnectorInstance(cmd.Context(), connectivityclient.ConnectorInstanceCreate{Name: name, Spec: spec}) if err != nil { return nil, err } if instance == nil { - return nil, fmt.Errorf("install connectivity instance %q: empty instance response", name) + return nil, fmt.Errorf("install connectivity connector instance %q: empty response", name) } - c.store.Instance = *instance + c.store.ConnectorInstance = *instance return c, nil } -func configHasData(config *connectivityclient.InstanceConfig) bool { +func configHasData(config *connectivityclient.ConnectorInstanceConfig) bool { return config != nil && (len(config.Env) > 0 || len(config.Files) > 0) } func (c *InstallController) Render(cmd *cobra.Command, _ []string) error { - instance := c.store.Instance + instance := c.store.ConnectorInstance _, err := fmt.Fprintf( cmd.OutOrStdout(), - "Instance %q installed with plugin %q for ledger %q.\n", + "Connector instance %q installed with connector %q for ledger %q.\n", stringValue(instance.Metadata.Name), - instance.Spec.Plugin, + instance.Spec.Connector, instance.Spec.Ledger, ) return err diff --git a/cmd/connectivity/connectorinstances/install_test.go b/cmd/connectivity/connectorinstances/install_test.go new file mode 100644 index 00000000..1abe5c3a --- /dev/null +++ b/cmd/connectivity/connectorinstances/install_test.go @@ -0,0 +1,347 @@ +package connectorinstances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type mutationClientMock struct { + connectorVersions + listConnectors func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + create func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) +} + +func (m mutationClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.listConnectors(ctx, options) +} + +func (m mutationClientMock) CreateConnectorInstance(ctx context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return m.create(ctx, body) +} + +func TestInstallBuildsConnectorInstanceFromVersionSchemaAndOnlySetsChangedScalars(t *testing.T) { + var gotConnector, gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + gotConnector, gotVersion = connector, version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + read := mockReadFile(map[string]string{ + "install.yaml": "env:\n API_URL:\n value: https://config.example\n", + "connector.env": "TIMEOUT=45s\n", + "token.txt": "file-token", + }) + + output, err := executeCommand( + NewInstallCommand(factoryReturning(client), read, mockPathCompleter(nil)), + "stripe", "--name=stripe-eu", "--ledger=main", "--version=2.0.0", + "--poll-interval=3s", "--config=install.yaml", + "--env-file=connector.env", "--set=TOKEN=@token.txt", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "stripe", gotConnector) + require.Equal(t, "2.0.0", gotVersion, "the pinned version supplies the config schema") + require.Equal(t, "stripe-eu", gotBody.Name) + require.Equal(t, "stripe", gotBody.Spec.Connector) + require.Equal(t, "main", gotBody.Spec.Ledger) + require.Equal(t, "2.0.0", *gotBody.Spec.Version) + require.Equal(t, "3s", *gotBody.Spec.PollInterval) + require.Equal(t, "https://config.example", *gotBody.Spec.Config.Env["API_URL"].Value) + require.Equal(t, "file-token", *gotBody.Spec.Config.Env["TOKEN"].Value) + require.Equal(t, "45s", *gotBody.Spec.Config.Env["TIMEOUT"].Value) + require.Empty(t, gotBody.Spec.Config.Files) + require.Equal(t, `Connector instance "stripe-eu" installed with connector "stripe" for ledger "main".`, strings.TrimSpace(output)) +} + +func TestInstallWithoutVersionFlagUsesNewestPublishedVersionForTheSchema(t *testing.T) { + var gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--set=API_URL=https://example", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "2.0.0", gotVersion) + require.Nil(t, gotBody.Spec.Version, "an unpinned install must let the server resolve the version") +} + +func TestInstallReportsConnectorsWithoutAPublishedVersion(t *testing.T) { + client := mutationClientMock{connectorVersions: connectorVersions{ + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + }, + }} + + _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") + + require.ErrorContains(t, err, `connectivity connector "stripe" has no published version`) +} + +func TestInstallDefaultsNameToConnectorAndOmitsUnsetOptionalData(t *testing.T) { + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "wise", "--ledger=main", "--confirm") + + require.NoError(t, err) + require.Equal(t, "wise", gotBody.Name) + require.Nil(t, gotBody.Spec.Version) + require.Nil(t, gotBody.Spec.Channel) + require.Nil(t, gotBody.Spec.StartSequence) + require.Nil(t, gotBody.Spec.PollInterval) + require.Nil(t, gotBody.Spec.Config) +} + +func TestInstallRequiresLedgerBeforeUsingClient(t *testing.T) { + usedClient := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedClient = true + return nil, errors.New("client must not be used") + } + + _, err := executeCommand(NewInstallCommand(factory, mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--confirm") + + require.ErrorContains(t, err, `required flag(s) "ledger" not set`) + require.False(t, usedClient) +} + +func TestInstallConfirmationRejectionPreventsCreate(t *testing.T) { + created := false + client := mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + created = true + return nil, errors.New("CreateConnectorInstance must not be called") + }, + } + controller := NewInstallController(factoryReturning(client), mockReadFile(nil)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + require.NoError(t, command.ParseFlags([]string{"--ledger=main"})) + + _, err := controller.Run(command, []string{"stripe"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, created) +} + +func TestInstallValidationErrorPreventsCreate(t *testing.T) { + created := false + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return versionWithOptionalSchema(), nil + }, + }, + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + created = true + return nil, errors.New("CreateConnectorInstance must not be called") + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--set=UNKNOWN=value", "--confirm", + ) + + require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") + require.False(t, created) +} + +func TestInstallPreservesVersionAndCreateAPIErrorsAndRejectsEmptyResponses(t *testing.T) { + versionError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTOR_NOT_FOUND", Message: "missing"} + createError := &connectivityclient.APIError{StatusCode: 409, Code: "CONNECTORINSTANCE_EXISTS", Message: "duplicate"} + tests := map[string]struct { + client mutationClientMock + want error + text string + }{ + "version API": { + client: mutationClientMock{connectorVersions: connectorVersions{ + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return nil, versionError + }, + }}, + want: versionError, + }, + "empty version": { + client: mutationClientMock{connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, nil }, + }}, + text: "empty response", + }, + "create API": { + client: mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return nil, createError + }, + }, + want: createError, + }, + "empty create": { + client: mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return nil, nil + }, + }, + text: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewInstallCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") + require.Error(t, err) + if test.want != nil { + require.ErrorIs(t, err, test.want) + } + if test.text != "" { + require.ErrorContains(t, err, test.text) + } + }) + } +} + +func TestInstallJSONStoresCompleteReturnedConnectorInstance(t *testing.T) { + returned := instanceFixture("stripe-eu") + returned.Metadata.Labels = map[string]string{"region": "eu"} + returned.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "TOKEN": {Value: stringPtr("preserved-in-json")}, + }} + client := mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return &returned, nil + }, + } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "stripe", "--ledger=main", "--confirm", "--output=json") + + require.NoError(t, err) + var envelope struct { + Data InstallStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, returned, envelope.Data.ConnectorInstance) +} + +func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { + standalone := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + require.Equal(t, "install ", standalone.Use) + require.Equal(t, []string{"create", "in"}, standalone.Aliases) + require.Error(t, standalone.Args(standalone, nil)) + require.NoError(t, standalone.Args(standalone, []string{"stripe"})) + require.Error(t, standalone.Args(standalone, []string{"stripe", "extra"})) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"install"}) + require.NoError(t, err) + require.Equal(t, "install", child.Name()) + require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) +} + +func TestInstallDoesNotExposeUnsupportedStartSequence(t *testing.T) { + command := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + + require.Nil(t, command.Flags().Lookup("start-sequence")) +} + +func TestInstallWiresConnectorVersionSetAndFileCompletions(t *testing.T) { + connector := connectivityclient.Connector{ + Metadata: connectivityclient.ObjectMeta{Name: stringPtr("stripe")}, + Spec: connectivityclient.ConnectorSpec{Description: stringPtr("Stripe ingestion")}, + } + client := mutationClientMock{ + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}}, nil + }, + connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionListFixture(), nil + }, + getVersion: func(ctx context.Context, name, _ string) (*connectivityclient.ConnectorVersion, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionWithFullSchema(), nil + }, + }, + } + var pathPrefix string + paths := func(prefix string) ([]string, error) { + pathPrefix = prefix + return []string{"fixtures/token.txt"}, nil + } + factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { + require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) + return client, nil + } + command := NewInstallCommand(factory, mockReadFile(nil), paths) + + connectors, directive := command.ValidArgsFunction(command, nil, "str") + require.Equal(t, []string{"stripe\tStripe ingestion"}, connectors) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + versionCompletion, ok := command.GetFlagCompletionFunc("version") + require.True(t, ok) + versions, directive := versionCompletion(command, []string{"stripe"}, "2") + require.Equal(t, []string{"2.0.0\texample/connector:2.0.0"}, versions) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + setCompletion, ok := command.GetFlagCompletionFunc("set") + require.True(t, ok) + sets, directive := setCompletion(command, []string{"stripe"}, "API") + require.Equal(t, []string{"API_URL=\tAPI endpoint"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) + + sets, directive = setCompletion(command, []string{"stripe"}, "TOKEN=@fixtures/t") + require.Equal(t, "fixtures/t", pathPrefix) + require.Equal(t, []string{"TOKEN=@fixtures/token.txt"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + _, configRegistered := command.GetFlagCompletionFunc("config") + _, envFileRegistered := command.GetFlagCompletionFunc("env-file") + require.False(t, configRegistered, "--config must retain Cobra's normal file completion") + require.False(t, envFileRegistered, "--env-file must retain Cobra's normal file completion") +} diff --git a/cmd/connectivity/instances/list.go b/cmd/connectivity/connectorinstances/list.go similarity index 62% rename from cmd/connectivity/instances/list.go rename to cmd/connectivity/connectorinstances/list.go index 686508a6..3f7cc58d 100644 --- a/cmd/connectivity/instances/list.go +++ b/cmd/connectivity/connectorinstances/list.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "fmt" @@ -12,11 +12,11 @@ import ( fctl "github.com/formancehq/fctl/v3/pkg" ) -const pluginFlag = "plugin" +const connectorFlag = "connector" type ListStore struct { - Instances []connectivityclient.Instance `json:"instances"` - Cursor fctl.Cursor `json:"cursor"` + ConnectorInstances []connectivityclient.ConnectorInstance `json:"connectorInstances"` + Cursor fctl.Cursor `json:"cursor"` } type ListController struct { @@ -29,7 +29,7 @@ var _ fctl.Controller[*ListStore] = (*ListController)(nil) func NewListController(factory connectivityinternal.ClientFactory) *ListController { return &ListController{ factory: factory, - store: &ListStore{Instances: []connectivityclient.Instance{}}, + store: &ListStore{ConnectorInstances: []connectivityclient.ConnectorInstance{}}, } } @@ -38,10 +38,10 @@ func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { return fctl.NewCommand( "list", fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List Connectivity instances"), + fctl.WithShortDescription("List Connectivity connector instances"), fctl.WithArgs(cobra.ExactArgs(0)), fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(pluginFlag, "", "Filter instances by plugin"), + fctl.WithStringFlag(connectorFlag, "", "Filter connector instances by connector"), fctl.WithPageSizeFlag(), fctl.WithCursorFlag(), fctl.WithController[*ListStore](controller), @@ -69,19 +69,19 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } - response, err := client.ListInstances(cmd.Context(), connectivityclient.ListOptions{ - Plugin: fctl.GetString(cmd, pluginFlag), - Limit: pageSize, - Continue: cursor, + response, err := client.ListConnectorInstances(cmd.Context(), connectivityclient.ListOptions{ + Connector: fctl.GetString(cmd, connectorFlag), + Limit: pageSize, + Continue: cursor, }) if err != nil { return nil, err } if response == nil { - return nil, fmt.Errorf("list connectivity instances: empty response") + return nil, fmt.Errorf("list connectivity connector instances: empty response") } - c.store.Instances = response.Items + c.store.ConnectorInstances = response.Items c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} if response.Continue != "" { c.store.Cursor.HasMore = true @@ -91,21 +91,21 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e } func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - rows := fctl.Map(c.store.Instances, func(instance connectivityclient.Instance) []string { + rows := fctl.Map(c.store.ConnectorInstances, func(instance connectivityclient.ConnectorInstance) []string { return []string{ stringValue(instance.Metadata.Name), - instance.Spec.Plugin, + instance.Spec.Connector, stringValue(instance.Spec.Version), instance.Spec.Ledger, - instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Phase }), - instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.State }), - instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.CurrentSequence }), - instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.SourceTipSequence }), - instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.LastError }), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase }), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.CurrentSequence }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.SourceTipSequence }), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.LastError }), } }) rows = fctl.Prepend(rows, []string{ - "Name", "Plugin", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "Name", "Connector", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", }) if err := pterm.DefaultTable. WithHasHeader(). @@ -131,14 +131,14 @@ func int64Value(value *int64) string { return strconv.FormatInt(*value, 10) } -func instanceStatusValue(status *connectivityclient.InstanceStatus, field func(*connectivityclient.InstanceStatus) *string) string { +func instanceStatusValue(status *connectivityclient.ConnectorInstanceStatus, field func(*connectivityclient.ConnectorInstanceStatus) *string) string { if status == nil { return "" } return stringValue(field(status)) } -func instanceStatusInt64(status *connectivityclient.InstanceStatus, field func(*connectivityclient.InstanceStatus) *int64) string { +func instanceStatusInt64(status *connectivityclient.ConnectorInstanceStatus, field func(*connectivityclient.ConnectorInstanceStatus) *int64) string { if status == nil { return "" } diff --git a/cmd/connectivity/instances/list_test.go b/cmd/connectivity/connectorinstances/list_test.go similarity index 62% rename from cmd/connectivity/instances/list_test.go rename to cmd/connectivity/connectorinstances/list_test.go index 902c1f7d..5b7e4f93 100644 --- a/cmd/connectivity/instances/list_test.go +++ b/cmd/connectivity/connectorinstances/list_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -17,29 +17,29 @@ import ( type listInstanceClientMock struct { connectivityclient.Client - list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) } -func (m listInstanceClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { +func (m listInstanceClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return m.list(ctx, options) } -func TestListInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *testing.T) { +func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *testing.T) { var gotOptions connectivityclient.ListOptions - client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { gotOptions = options - return &connectivityclient.InstanceList{ - Items: []connectivityclient.Instance{instanceFixture("stripe-eu")}, + return &connectivityclient.ConnectorInstanceList{ + Items: []connectivityclient.ConnectorInstance{instanceFixture("stripe-eu")}, Continue: "next-page", }, nil }} - output, err := executeCommand(NewListCommand(factoryReturning(client)), "--plugin", "stripe", "--page-size", "7", "--cursor", "current-page") + output, err := executeCommand(NewListCommand(factoryReturning(client)), "--connector", "stripe", "--page-size", "7", "--cursor", "current-page") require.NoError(t, err) - require.Equal(t, connectivityclient.ListOptions{Plugin: "stripe", Limit: 7, Continue: "current-page"}, gotOptions) + require.Equal(t, connectivityclient.ListOptions{Connector: "stripe", Limit: 7, Continue: "current-page"}, gotOptions) for _, expected := range []string{ - "Name", "Plugin", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "Name", "Connector", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", "stripe-eu", "stripe", "2.0.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", "HasMore", "true", "PageSize", "7", "Next", "next-page", } { @@ -47,14 +47,14 @@ func TestListInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *tes } } -func TestListInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { +func TestListConnectorInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { instance := instanceFixture("stripe-eu") instance.Metadata.Labels = map[string]string{"region": "eu"} - instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ "API_KEY": {Value: stringPtr("json-keeps-full-model")}, }} - client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { - return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{instance}, Continue: "next-page"}, nil + client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}, Continue: "next-page"}, nil }} command := NewListCommand(factoryReturning(client)) command.Flags().String(fctl.OutputFlag, "plain", "") @@ -66,18 +66,18 @@ func TestListInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { Data ListStore `json:"data"` } require.NoError(t, json.Unmarshal([]byte(output), &envelope)) - require.Equal(t, []connectivityclient.Instance{instance}, envelope.Data.Instances) + require.Equal(t, []connectivityclient.ConnectorInstance{instance}, envelope.Data.ConnectorInstances) require.True(t, envelope.Data.Cursor.HasMore) require.Equal(t, int64(4), envelope.Data.Cursor.PageSize) require.NotNil(t, envelope.Data.Cursor.Next) require.Equal(t, "next-page", *envelope.Data.Cursor.Next) } -func TestInstanceRootRegistersReadCommandsAndAliases(t *testing.T) { +func TestConnectorInstanceRootRegistersReadCommandsAndAliases(t *testing.T) { command := NewCommand(factoryReturning(listInstanceClientMock{}), mockReadFile(nil), mockPathCompleter(nil)) - require.Equal(t, "instances", command.Use) - require.Equal(t, []string{"instance", "i"}, command.Aliases) + require.Equal(t, "connectorinstances", command.Use) + require.Equal(t, []string{"connectorinstance", "instances", "instance", "ci"}, command.Aliases) wantAliases := map[string][]string{ "list": {"ls", "l"}, "show": {"get", "g", "sh", "s"}, @@ -90,7 +90,7 @@ func TestInstanceRootRegistersReadCommandsAndAliases(t *testing.T) { } } -func TestListInstancesReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { +func TestListConnectorInstancesReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { tests := map[string]struct { factory func(*cobra.Command) (connectivityclient.Client, error) want string @@ -103,13 +103,13 @@ func TestListInstancesReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { want: "authentication failed", }, "API": { - factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { - return nil, errors.New("instances unavailable") + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return nil, errors.New("connector instances unavailable") }}), - want: "instances unavailable", + want: "connector instances unavailable", }, "empty response": { - factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return nil, nil }}), want: "empty response", diff --git a/cmd/connectivity/instances/root.go b/cmd/connectivity/connectorinstances/root.go similarity index 72% rename from cmd/connectivity/instances/root.go rename to cmd/connectivity/connectorinstances/root.go index c7e4bc3b..3a80b862 100644 --- a/cmd/connectivity/instances/root.go +++ b/cmd/connectivity/connectorinstances/root.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "github.com/spf13/cobra" @@ -9,9 +9,9 @@ import ( func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { return fctl.NewCommand( - "instances", - fctl.WithAliases("instance", "i"), - fctl.WithShortDescription("Manage Connectivity instances"), + "connectorinstances", + fctl.WithAliases("connectorinstance", "instances", "instance", "ci"), + fctl.WithShortDescription("Manage Connectivity connector instances"), fctl.WithChildCommands( NewListCommand(factory), NewShowCommand(factory), diff --git a/cmd/connectivity/instances/show.go b/cmd/connectivity/connectorinstances/show.go similarity index 70% rename from cmd/connectivity/instances/show.go rename to cmd/connectivity/connectorinstances/show.go index 9576ed83..e2f4a4ff 100644 --- a/cmd/connectivity/instances/show.go +++ b/cmd/connectivity/connectorinstances/show.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "fmt" @@ -17,7 +17,7 @@ import ( ) type ShowStore struct { - Instance connectivityclient.Instance `json:"instance"` + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` } type ShowController struct { @@ -34,11 +34,11 @@ func NewShowController(factory connectivityinternal.ClientFactory) *ShowControll func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { controller := NewShowController(factory) return fctl.NewCommand( - "show ", + "show ", fctl.WithAliases("get", "g", "sh", "s"), - fctl.WithShortDescription("Show a Connectivity instance"), + fctl.WithShortDescription("Show a Connectivity connector instance"), fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), fctl.WithController[*ShowStore](controller), ) } @@ -55,19 +55,19 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable if err != nil { return nil, err } - instance, err := client.GetInstance(cmd.Context(), args[0]) + instance, err := client.GetConnectorInstance(cmd.Context(), args[0]) if err != nil { return nil, err } if instance == nil { - return nil, fmt.Errorf("show connectivity instance %q: empty response", args[0]) + return nil, fmt.Errorf("show connectivity connector instance %q: empty response", args[0]) } - c.store.Instance = *instance + c.store.ConnectorInstance = *instance return c, nil } func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - instance := c.store.Instance + instance := c.store.ConnectorInstance out := cmd.OutOrStdout() if err := renderInstanceSection(out, "Information", pterm.TableData{ @@ -82,8 +82,9 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { return err } if err := renderInstanceSection(out, "Desired Specification", pterm.TableData{ - {pterm.LightCyan("Plugin"), instance.Spec.Plugin}, + {pterm.LightCyan("Connector"), instance.Spec.Connector}, {pterm.LightCyan("Version"), stringValue(instance.Spec.Version)}, + {pterm.LightCyan("Channel"), stringValue(instance.Spec.Channel)}, {pterm.LightCyan("Connectivity Reference"), stringValue(instance.Spec.ConnectivityRef)}, {pterm.LightCyan("Ledger"), instance.Spec.Ledger}, {pterm.LightCyan("Poll Interval"), stringValue(instance.Spec.PollInterval)}, @@ -91,18 +92,21 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { return err } if err := renderInstanceSection(out, "Lifecycle", pterm.TableData{ - {pterm.LightCyan("Resolved Image"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.ResolvedImage })}, - {pterm.LightCyan("Plugin Address"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.PluginAddress })}, - {pterm.LightCyan("Phase"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Phase })}, - {pterm.LightCyan("State"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.State })}, + {pterm.LightCyan("Resolved Connector"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedConnectorRef })}, + {pterm.LightCyan("Resolved Version"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedVersion })}, + {pterm.LightCyan("Resolved Image"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedImage })}, + {pterm.LightCyan("Resolved Digest"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedDigest })}, + {pterm.LightCyan("Connector Address"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ConnectorAddress })}, + {pterm.LightCyan("Phase"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase })}, + {pterm.LightCyan("State"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State })}, }); err != nil { return err } if err := renderInstanceSection(out, "Ingestion Progress", pterm.TableData{ - {pterm.LightCyan("Current Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.CurrentSequence })}, - {pterm.LightCyan("Source Tip Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.InstanceStatus) *int64 { return status.SourceTipSequence })}, - {pterm.LightCyan("Last Error"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.LastError })}, - {pterm.LightCyan("Message"), instanceStatusValue(instance.Status, func(status *connectivityclient.InstanceStatus) *string { return status.Message })}, + {pterm.LightCyan("Current Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.CurrentSequence })}, + {pterm.LightCyan("Source Tip Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.SourceTipSequence })}, + {pterm.LightCyan("Last Error"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.LastError })}, + {pterm.LightCyan("Message"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Message })}, }); err != nil { return err } @@ -122,7 +126,7 @@ func renderInstanceSection(out io.Writer, title string, data pterm.TableData) er return pterm.DefaultTable.WithWriter(out).WithData(data).Render() } -func configRows(config *connectivityclient.InstanceConfig) [][]string { +func configRows(config *connectivityclient.ConnectorInstanceConfig) [][]string { if config == nil { return nil } diff --git a/cmd/connectivity/instances/show_test.go b/cmd/connectivity/connectorinstances/show_test.go similarity index 71% rename from cmd/connectivity/instances/show_test.go rename to cmd/connectivity/connectorinstances/show_test.go index fc55975f..7fd4c8d2 100644 --- a/cmd/connectivity/instances/show_test.go +++ b/cmd/connectivity/connectorinstances/show_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -17,14 +17,14 @@ import ( type showInstanceClientMock struct { connectivityclient.Client - get func(context.Context, string) (*connectivityclient.Instance, error) + get func(context.Context, string) (*connectivityclient.ConnectorInstance, error) } -func (m showInstanceClientMock) GetInstance(ctx context.Context, name string) (*connectivityclient.Instance, error) { +func (m showInstanceClientMock) GetConnectorInstance(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { return m.get(ctx, name) } -func TestShowInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConfigSources(t *testing.T) { +func TestShowConnectorInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConfigSources(t *testing.T) { created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) instance := instanceFixture("stripe-eu") instance.Metadata.Namespace = stringPtr("formance") @@ -34,13 +34,13 @@ func TestShowInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConf instance.Metadata.Labels = map[string]string{"region": "eu"} instance.Metadata.Annotations = map[string]string{"owner": "platform"} mode := int32(420) - instance.Spec.Config = &connectivityclient.InstanceConfig{ + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{ Env: map[string]connectivityclient.EnvValue{ "API_KEY": {Value: stringPtr("must-not-leak")}, - "TOKEN": {SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "token"}}, + "TOKEN": {SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}}, }, Files: []connectivityclient.FileMount{ - {Path: "/etc/plugin/config.yaml", ConfigMapRef: &connectivityclient.KeyRef{Name: "plugin-config", Key: "config.yaml"}, Mode: &mode}, + {Path: "/etc/plugin/config.yaml", ConfigMapRef: &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, Mode: &mode}, {Path: "/etc/plugin/inline.pem", Value: stringPtr("also-must-not-leak")}, }, } @@ -51,11 +51,12 @@ func TestShowInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConf for _, expected := range []string{ "Information", "Name", "stripe-eu", "Namespace", "formance", "UID", "instance-uid", "Resource Version", "42", "Created At", created.Format(time.RFC3339), "Labels", "region=eu", "Annotations", "owner=platform", - "Desired Specification", "Plugin", "stripe", "Version", "2.0.0", "Ledger", "main", "Poll Interval", "5s", - "Lifecycle", "Resolved Image", "registry/plugin:2.0.0", "Plugin Address", "http://stripe.default.svc", "Phase", "Ready", "State", "Running", + "Desired Specification", "Connector", "stripe", "Version", "2.0.0", "Ledger", "main", "Poll Interval", "5s", + "Lifecycle", "Resolved Connector", "Resolved Version", "Resolved Image", "registry/connector:2.0.0", + "Resolved Digest", "sha256:deadbeef", "Connector Address", "http://stripe.default.svc", "Phase", "Ready", "State", "Running", "Ingestion Progress", "Current Sequence", "42", "Source Tip Sequence", "48", "Last Error", "source temporarily unavailable", "Message", "retrying ingestion", - "Configuration", "API_KEY", "environment", "inline", "TOKEN", "secret:plugin-secrets/token", - "/etc/plugin/config.yaml", "file", "configmap:plugin-config/config.yaml", "420", "/etc/plugin/inline.pem", + "Configuration", "API_KEY", "environment", "inline", "TOKEN", "secret:connector-secrets/token", + "/etc/plugin/config.yaml", "file", "configmap:connector-config/config.yaml", "420", "/etc/plugin/inline.pem", } { require.Contains(t, output, expected) } @@ -90,9 +91,9 @@ func TestShowPlainOutputOmitsUnsupportedStartSequence(t *testing.T) { require.NotContains(t, output, "987654321") } -func TestShowInstanceJSONPreservesCompleteModel(t *testing.T) { +func TestShowConnectorInstanceJSONPreservesCompleteModel(t *testing.T) { instance := instanceFixture("stripe-eu") - instance.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ "API_KEY": {Value: stringPtr("json-keeps-full-model")}, }} command := NewShowCommand(factoryWithInstance(instance)) @@ -105,10 +106,10 @@ func TestShowInstanceJSONPreservesCompleteModel(t *testing.T) { Data ShowStore `json:"data"` } require.NoError(t, json.Unmarshal([]byte(output), &envelope)) - require.Equal(t, instance, envelope.Data.Instance) + require.Equal(t, instance, envelope.Data.ConnectorInstance) } -func TestShowInstanceHandlesMissingStatusAndConfig(t *testing.T) { +func TestShowConnectorInstanceHandlesMissingStatusAndConfig(t *testing.T) { instance := instanceFixture("stripe-eu") instance.Status = nil instance.Spec.Config = nil @@ -119,7 +120,7 @@ func TestShowInstanceHandlesMissingStatusAndConfig(t *testing.T) { require.Contains(t, output, "No configuration entries.") } -func TestShowInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { +func TestShowConnectorInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { tests := map[string]struct { factory func(*cobra.Command) (connectivityclient.Client, error) want string @@ -132,13 +133,13 @@ func TestShowInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { want: "authentication failed", }, "API": { - factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.Instance, error) { - return nil, errors.New("instance unavailable") + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { + return nil, errors.New("connector instance unavailable") }}), - want: "instance unavailable", + want: "connector instance unavailable", }, "empty response": { - factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.Instance, error) { + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return nil, nil }}), want: "empty response", diff --git a/cmd/connectivity/connectorinstances/test_helpers_test.go b/cmd/connectivity/connectorinstances/test_helpers_test.go new file mode 100644 index 00000000..e0c443e0 --- /dev/null +++ b/cmd/connectivity/connectorinstances/test_helpers_test.go @@ -0,0 +1,194 @@ +package connectorinstances + +import ( + "bytes" + "context" + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func stringPtr(value string) *string { + return &value +} + +// connectorVersions serves the catalog half of the client interface so command +// mocks only spell out the calls their own test exercises. The zero value +// publishes two versions whose newest carries no schema, which is what a test +// that does not care about configuration wants. +type connectorVersions struct { + connectivityclient.Client + listVersions func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) + getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) +} + +func (v connectorVersions) ListConnectorVersions(ctx context.Context, connector string) (*connectivityclient.ConnectorVersionList, error) { + if v.listVersions == nil { + return versionListFixture(), nil + } + return v.listVersions(ctx, connector) +} + +func (v connectorVersions) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + if v.getVersion == nil { + return &connectivityclient.ConnectorVersion{Version: version, Image: "example/connector:" + version}, nil + } + return v.getVersion(ctx, connector, version) +} + +func versionListFixture() *connectivityclient.ConnectorVersionList { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "1.0.0", Image: "example/connector:1.0.0"}, + {Version: "2.0.0", Image: "example/connector:2.0.0"}, + }} +} + +func instanceFixture(name string) connectivityclient.ConnectorInstance { + return connectivityclient.ConnectorInstance{ + Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}, + Spec: connectivityclient.ConnectorInstanceSpec{ + Connector: "stripe", + Version: stringPtr("2.0.0"), + Ledger: "main", + PollInterval: stringPtr("5s"), + StartSequence: fctl.Ptr(int64(10)), + }, + Status: &connectivityclient.ConnectorInstanceStatus{ + Phase: stringPtr("Ready"), + State: stringPtr("Running"), + ResolvedImage: stringPtr("registry/connector:2.0.0"), + ResolvedConnectorRef: stringPtr("stripe"), + ResolvedVersion: stringPtr("2.0.0"), + ResolvedDigest: stringPtr("sha256:deadbeef"), + ConnectorAddress: stringPtr("http://stripe.default.svc"), + CurrentSequence: fctl.Ptr(int64(42)), + SourceTipSequence: fctl.Ptr(int64(48)), + LastError: stringPtr("source temporarily unavailable"), + Message: stringPtr("retrying ingestion"), + }, + } +} + +func instanceWithTwoFiles() *connectivityclient.ConnectorInstance { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", Value: stringPtr("private config")}, + {Path: "/etc/plugin/ca.pem", SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "ca.pem"}}, + }} + return &instance +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func factoryWithInstance(instance connectivityclient.ConnectorInstance) connectivityinternal.ClientFactory { + return factoryReturning(showInstanceClientMock{get: func(_ context.Context, _ string) (*connectivityclient.ConnectorInstance, error) { + return &instance, nil + }}) +} + +func mockReadFile(files map[string]string) ReadFileFunc { + return func(_ *cobra.Command, name string) (string, error) { + value, ok := files[name] + if !ok { + return "", fmt.Errorf("test file %q not found", name) + } + return value, nil + } +} + +func mapReadFile(files map[string]string) ReadFileFunc { + return mockReadFile(files) +} + +func mockPathCompleter(paths []string) PathCompleter { + return func(string) ([]string, error) { + return paths, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} + +// versionWithFileSchema declares one optional environment key and two file +// keys, the first of them required. +func versionWithFileSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + }, + "required": []any{}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{ + "/etc/a": map[string]any{"type": "string", "description": "Primary config"}, + "/etc/b": map[string]any{"type": "string", "format": "password"}, + }, + "required": []any{"/etc/a"}, + }, + }, + } +} + +// versionWithFullSchema is the reference schema: two required environment keys, +// one optional, one required file and one optional password file. +func versionWithFullSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: fullConfigSchema([]any{"API_URL", "TOKEN"}, []any{"/etc/plugin/config.yaml"}), + } +} + +// versionWithOptionalSchema exposes the same keys as versionWithFullSchema with +// nothing required, so input-parsing tests need not satisfy every key. +func versionWithOptionalSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: fullConfigSchema([]any{}, []any{}), + } +} + +func fullConfigSchema(requiredEnv, requiredFiles []any) map[string]any { + return map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + "TOKEN": map[string]any{"type": "string", "format": "password", "description": "Authentication token"}, + "TIMEOUT": map[string]any{"type": "string"}, + }, + "required": requiredEnv, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{ + "/etc/plugin/config.yaml": map[string]any{"type": "string", "description": "Connector configuration"}, + "/etc/plugin/ca.pem": map[string]any{"type": "string", "format": "password"}, + }, + "required": requiredFiles, + }, + } +} diff --git a/cmd/connectivity/instances/uninstall.go b/cmd/connectivity/connectorinstances/uninstall.go similarity index 76% rename from cmd/connectivity/instances/uninstall.go rename to cmd/connectivity/connectorinstances/uninstall.go index b95d0f93..46823f1a 100644 --- a/cmd/connectivity/instances/uninstall.go +++ b/cmd/connectivity/connectorinstances/uninstall.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "fmt" @@ -32,11 +32,11 @@ func NewUninstallController(factory connectivityinternal.ClientFactory) *Uninsta func NewUninstallCommand(factory connectivityinternal.ClientFactory) *cobra.Command { controller := NewUninstallController(factory) return fctl.NewCommand( - "uninstall ", + "uninstall ", fctl.WithAliases("delete", "remove", "rm", "u"), - fctl.WithShortDescription("Uninstall a Connectivity plugin instance"), + fctl.WithShortDescription("Uninstall a Connectivity connector instance"), fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(CompleteInstanceNames(factory)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), fctl.WithConfirmFlag(), fctl.WithController[*UninstallStore](controller), ) @@ -55,10 +55,10 @@ func (c *UninstallController) Run(cmd *cobra.Command, args []string) (fctl.Rende return nil, err } name := args[0] - if !c.approve(cmd, "You are about to uninstall Connectivity instance %q", name) { + if !c.approve(cmd, "You are about to uninstall Connectivity connector instance %q", name) { return nil, fctl.ErrMissingApproval } - if err := client.DeleteInstance(cmd.Context(), name); err != nil { + if err := client.DeleteConnectorInstance(cmd.Context(), name); err != nil { return nil, err } c.store.Name = name @@ -66,6 +66,6 @@ func (c *UninstallController) Run(cmd *cobra.Command, args []string) (fctl.Rende } func (c *UninstallController) Render(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), "Instance %q uninstalled.\n", c.store.Name) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q uninstalled.\n", c.store.Name) return err } diff --git a/cmd/connectivity/instances/uninstall_test.go b/cmd/connectivity/connectorinstances/uninstall_test.go similarity index 76% rename from cmd/connectivity/instances/uninstall_test.go rename to cmd/connectivity/connectorinstances/uninstall_test.go index efc3de4f..47c50f94 100644 --- a/cmd/connectivity/instances/uninstall_test.go +++ b/cmd/connectivity/connectorinstances/uninstall_test.go @@ -1,4 +1,4 @@ -package instances +package connectorinstances import ( "context" @@ -17,19 +17,19 @@ import ( type uninstallClientMock struct { connectivityclient.Client - listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) delete func(context.Context, string) error } -func (m uninstallClientMock) ListInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { +func (m uninstallClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { return m.listInstances(ctx, options) } -func (m uninstallClientMock) DeleteInstance(ctx context.Context, name string) error { +func (m uninstallClientMock) DeleteConnectorInstance(ctx context.Context, name string) error { return m.delete(ctx, name) } -func TestUninstallDeletesConfirmedInstanceAndRendersSuccess(t *testing.T) { +func TestUninstallDeletesConfirmedConnectorInstanceAndRendersSuccess(t *testing.T) { var deletedName string client := uninstallClientMock{delete: func(_ context.Context, name string) error { deletedName = name @@ -40,14 +40,14 @@ func TestUninstallDeletesConfirmedInstanceAndRendersSuccess(t *testing.T) { require.NoError(t, err) require.Equal(t, "stripe-eu", deletedName) - require.Equal(t, `Instance "stripe-eu" uninstalled.`, strings.TrimSpace(output)) + require.Equal(t, `Connector instance "stripe-eu" uninstalled.`, strings.TrimSpace(output)) } func TestUninstallConfirmationRejectionPreventsDelete(t *testing.T) { deleted := false client := uninstallClientMock{delete: func(context.Context, string) error { deleted = true - return errors.New("DeleteInstance must not be called") + return errors.New("DeleteConnectorInstance must not be called") }} controller := NewUninstallController(factoryReturning(client)) controller.approve = func(*cobra.Command, string, ...any) bool { return false } @@ -59,7 +59,7 @@ func TestUninstallConfirmationRejectionPreventsDelete(t *testing.T) { } func TestUninstallPreservesDeleteAPIError(t *testing.T) { - apiError := &connectivityclient.APIError{StatusCode: 404, Code: "INSTANCE_NOT_FOUND", Message: "missing"} + apiError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORINSTANCE_NOT_FOUND", Message: "missing"} client := uninstallClientMock{delete: func(context.Context, string) error { return apiError }} _, err := executeCommand(NewUninstallCommand(factoryReturning(client)), "stripe-eu", "--confirm") @@ -67,18 +67,18 @@ func TestUninstallPreservesDeleteAPIError(t *testing.T) { require.ErrorIs(t, err, apiError) } -func TestUninstallRegistersAliasesRootAndInstanceCompletion(t *testing.T) { +func TestUninstallRegistersAliasesRootAndConnectorInstanceCompletion(t *testing.T) { instance := instanceFixture("stripe-eu") client := uninstallClientMock{ - listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.InstanceList, error) { + listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { require.True(t, connectivityinternal.IsNonInteractive(ctx)) require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) - return &connectivityclient.InstanceList{Items: []connectivityclient.Instance{instance}}, nil + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}}, nil }, } command := NewUninstallCommand(factoryReturning(client)) - require.Equal(t, "uninstall ", command.Use) + require.Equal(t, "uninstall ", command.Use) require.Equal(t, []string{"delete", "remove", "rm", "u"}, command.Aliases) require.Error(t, command.Args(command, nil)) require.NoError(t, command.Args(command, []string{"stripe-eu"})) diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go new file mode 100644 index 00000000..ad7808ac --- /dev/null +++ b/cmd/connectivity/connectorinstances/version.go @@ -0,0 +1,52 @@ +package connectorinstances + +import ( + "context" + "fmt" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +// resolveConnectorVersion returns the ConnectorVersion whose configSchema governs +// the configuration being assembled: the requested pin when there is one, the +// newest published version otherwise. The catalog serves versions ascending by +// semantic version, so the newest is the last item. +func resolveConnectorVersion(ctx context.Context, client connectivityclient.Client, connector, pinned string) (*connectivityclient.ConnectorVersion, error) { + if connector == "" { + return nil, fmt.Errorf("connector name is required") + } + if pinned == "" { + versions, err := client.ListConnectorVersions(ctx, connector) + if err != nil { + return nil, err + } + if versions == nil || len(versions.Items) == 0 { + return nil, fmt.Errorf("connectivity connector %q has no published version", connector) + } + pinned = versions.Items[len(versions.Items)-1].Version + } + version, err := client.GetConnectorVersion(ctx, connector, pinned) + if err != nil { + return nil, err + } + if version == nil { + return nil, fmt.Errorf("get connectivity connector %q version %q: empty response", connector, pinned) + } + return version, nil +} + +// instanceVersionPin reports the version an existing connector instance is +// configured against: its explicit pin when set, the version the operator +// actually applied otherwise. An empty result means "resolve the newest". +func instanceVersionPin(instance *connectivityclient.ConnectorInstance) string { + if instance == nil { + return "" + } + if instance.Spec.Version != nil && *instance.Spec.Version != "" { + return *instance.Spec.Version + } + if instance.Status != nil && instance.Status.ResolvedVersion != nil { + return *instance.Status.ResolvedVersion + } + return "" +} diff --git a/cmd/connectivity/plugins/completion.go b/cmd/connectivity/connectors/completion.go similarity index 67% rename from cmd/connectivity/plugins/completion.go rename to cmd/connectivity/connectors/completion.go index 6cdfaf0f..5fb47b8e 100644 --- a/cmd/connectivity/plugins/completion.go +++ b/cmd/connectivity/connectors/completion.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "context" @@ -13,11 +13,11 @@ import ( ) const ( - pluginCompletionLimit = int32(500) - pluginCompletionTimeout = 2 * time.Second + connectorCompletionLimit = int32(500) + connectorCompletionTimeout = 2 * time.Second ) -func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { +func CompleteConnectorNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { if factory == nil { return nil, cobra.ShellCompDirectiveNoFileComp @@ -26,7 +26,7 @@ func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.Compl if parent == nil { parent = context.Background() } - ctx, cancel := context.WithTimeout(parent, pluginCompletionTimeout) + ctx, cancel := context.WithTimeout(parent, connectorCompletionTimeout) defer cancel() completionCommand := *cmd @@ -35,18 +35,18 @@ func CompletePluginNames(factory connectivityinternal.ClientFactory) cobra.Compl if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - response, err := client.ListPlugins(ctx, connectivityclient.ListOptions{Limit: pluginCompletionLimit}) + response, err := client.ListConnectors(ctx, connectivityclient.ListOptions{Limit: connectorCompletionLimit}) if err != nil || response == nil { return nil, cobra.ShellCompDirectiveNoFileComp } candidates := make([]string, 0, len(response.Items)) - for _, plugin := range response.Items { - name := stringValue(plugin.Metadata.Name) + for _, connector := range response.Items { + name := stringValue(connector.Metadata.Name) if name == "" || !strings.HasPrefix(name, toComplete) { continue } - if description := stringValue(plugin.Spec.Description); description != "" { + if description := stringValue(connector.Spec.Description); description != "" { name += "\t" + description } candidates = append(candidates, name) diff --git a/cmd/connectivity/plugins/completion_test.go b/cmd/connectivity/connectors/completion_test.go similarity index 65% rename from cmd/connectivity/plugins/completion_test.go rename to cmd/connectivity/connectors/completion_test.go index 4e5442de..415b31d0 100644 --- a/cmd/connectivity/plugins/completion_test.go +++ b/cmd/connectivity/connectors/completion_test.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "context" @@ -13,22 +13,22 @@ import ( connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) -func TestCompletePluginNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDescriptions(t *testing.T) { +func TestCompleteConnectorNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDescriptions(t *testing.T) { var gotOptions connectivityclient.ListOptions var remaining time.Duration - client := pluginClientMock{list: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + client := connectorClientMock{list: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { gotOptions = options deadline, ok := ctx.Deadline() if !ok { t.Fatal("completion context has no deadline") } remaining = time.Until(deadline) - return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{ - pluginFixture("alpha"), pluginFixture("beta"), + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{ + connectorFixture("alpha"), connectorFixture("beta"), }}, nil }} - completion := CompletePluginNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + completion := CompleteConnectorNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { if !connectivityinternal.IsNonInteractive(cmd.Context()) { t.Fatal("completion factory context is interactive") } @@ -37,12 +37,12 @@ func TestCompletePluginNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDe candidates, directive := completion(&cobra.Command{}, nil, "al") if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { - t.Fatalf("ListPlugins options = %#v, want limit 500", gotOptions) + t.Fatalf("ListConnectors options = %#v, want limit 500", gotOptions) } if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) } - if !reflect.DeepEqual(candidates, []string{"alpha\tPlugin description"}) { + if !reflect.DeepEqual(candidates, []string{"alpha\tConnector description"}) { t.Fatalf("candidates = %#v, want prefix match with description", candidates) } if directive != cobra.ShellCompDirectiveNoFileComp { @@ -50,7 +50,7 @@ func TestCompletePluginNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDe } } -func TestCompletePluginNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testing.T) { +func TestCompleteConnectorNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testing.T) { tests := map[string]struct { command *cobra.Command factory func(*cobra.Command) (connectivityclient.Client, error) @@ -63,13 +63,13 @@ func TestCompletePluginNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testi }, "API": { command: &cobra.Command{}, - factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { return nil, errors.New("unsupported deployment") }}), }, "timeout": { command: commandWithExpiredContext(), - factory: factoryReturning(pluginClientMock{list: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + factory: factoryReturning(connectorClientMock{list: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { <-ctx.Done() return nil, ctx.Err() }}), @@ -78,7 +78,7 @@ func TestCompletePluginNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testi for name, test := range tests { t.Run(name, func(t *testing.T) { - candidates, directive := CompletePluginNames(test.factory)(test.command, nil, "") + candidates, directive := CompleteConnectorNames(test.factory)(test.command, nil, "") if len(candidates) != 0 { t.Fatalf("candidates = %#v, want none", candidates) } diff --git a/cmd/connectivity/plugins/list.go b/cmd/connectivity/connectors/list.go similarity index 70% rename from cmd/connectivity/plugins/list.go rename to cmd/connectivity/connectors/list.go index 3875ec34..64b5dcb0 100644 --- a/cmd/connectivity/plugins/list.go +++ b/cmd/connectivity/connectors/list.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "fmt" @@ -13,8 +13,8 @@ import ( ) type ListStore struct { - Plugins []connectivityclient.Plugin `json:"plugins"` - Cursor fctl.Cursor `json:"cursor"` + Connectors []connectivityclient.Connector `json:"connectors"` + Cursor fctl.Cursor `json:"cursor"` } type ListController struct { @@ -27,7 +27,7 @@ var _ fctl.Controller[*ListStore] = (*ListController)(nil) func NewListController(factory connectivityinternal.ClientFactory) *ListController { return &ListController{ factory: factory, - store: &ListStore{Plugins: []connectivityclient.Plugin{}}, + store: &ListStore{Connectors: []connectivityclient.Connector{}}, } } @@ -36,7 +36,7 @@ func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { return fctl.NewCommand( "list", fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List available Connectivity plugins"), + fctl.WithShortDescription("List available Connectivity connectors"), fctl.WithArgs(cobra.ExactArgs(0)), fctl.WithValidArgsFunction(cobra.NoFileCompletions), fctl.WithPageSizeFlag(), @@ -66,7 +66,7 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } - response, err := client.ListPlugins(cmd.Context(), connectivityclient.ListOptions{ + response, err := client.ListConnectors(cmd.Context(), connectivityclient.ListOptions{ Limit: pageSize, Continue: cursor, }) @@ -74,10 +74,10 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } if response == nil { - return nil, fmt.Errorf("list connectivity plugins: empty response") + return nil, fmt.Errorf("list connectivity connectors: empty response") } - c.store.Plugins = response.Items + c.store.Connectors = response.Items c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} if response.Continue != "" { c.store.Cursor.HasMore = true @@ -87,16 +87,16 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e } func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - rows := fctl.Map(c.store.Plugins, func(plugin connectivityclient.Plugin) []string { + rows := fctl.Map(c.store.Connectors, func(connector connectivityclient.Connector) []string { return []string{ - stringValue(plugin.Metadata.Name), - stringValue(plugin.Spec.DefaultVersion), - stringValue(plugin.Spec.Description), - strings.Join(plugin.Spec.Capabilities, ", "), - pluginPhase(plugin.Status), + stringValue(connector.Metadata.Name), + stringValue(connector.Spec.DisplayName), + stringValue(connector.Spec.Description), + strings.Join(connector.Spec.Tags, ", "), + connectorPhase(connector.Status), } }) - rows = fctl.Prepend(rows, []string{"Name", "Default Version", "Description", "Capabilities", "Phase"}) + rows = fctl.Prepend(rows, []string{"Name", "Display Name", "Description", "Tags", "Phase"}) if err := pterm.DefaultTable. WithHasHeader(). WithWriter(cmd.OutOrStdout()). @@ -114,7 +114,7 @@ func stringValue(value *string) string { return *value } -func pluginPhase(status *connectivityclient.PluginStatus) string { +func connectorPhase(status *connectivityclient.ConnectorStatus) string { if status == nil { return "" } diff --git a/cmd/connectivity/plugins/list_test.go b/cmd/connectivity/connectors/list_test.go similarity index 59% rename from cmd/connectivity/plugins/list_test.go rename to cmd/connectivity/connectors/list_test.go index 3d9e96a9..efca40b5 100644 --- a/cmd/connectivity/plugins/list_test.go +++ b/cmd/connectivity/connectors/list_test.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "context" @@ -14,12 +14,12 @@ import ( fctl "github.com/formancehq/fctl/v3/pkg" ) -func TestListPluginsPassesPaginationAndRendersApprovedColumnsAndContinuation(t *testing.T) { +func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation(t *testing.T) { var gotOptions connectivityclient.ListOptions - client := pluginClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { gotOptions = options - return &connectivityclient.PluginList{ - Items: []connectivityclient.Plugin{pluginFixture("stripe")}, + return &connectivityclient.ConnectorList{ + Items: []connectivityclient.Connector{connectorFixture("stripe")}, Continue: "next-page", }, nil }} @@ -32,11 +32,11 @@ func TestListPluginsPassesPaginationAndRendersApprovedColumnsAndContinuation(t * wantOptions := connectivityclient.ListOptions{Limit: 7, Continue: "current-page"} if !reflect.DeepEqual(gotOptions, wantOptions) { - t.Fatalf("ListPlugins options = %#v, want %#v", gotOptions, wantOptions) + t.Fatalf("ListConnectors options = %#v, want %#v", gotOptions, wantOptions) } for _, expected := range []string{ - "Name", "Default Version", "Description", "Capabilities", "Phase", - "stripe", "2.0.0", "Plugin description", "payments, webhooks", "Ready", + "Name", "Display Name", "Description", "Tags", "Phase", + "stripe", "Connector display name", "Connector description", "payments, webhooks", "Ready", "HasMore", "true", "PageSize", "7", "Next", "next-page", } { if !strings.Contains(output, expected) { @@ -45,12 +45,11 @@ func TestListPluginsPassesPaginationAndRendersApprovedColumnsAndContinuation(t * } } -func TestListPluginsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { - plugin := pluginFixture("stripe") - plugin.Metadata.Labels = map[string]string{"region": "eu"} - plugin.Spec.ConfigSchema = map[string]any{"type": "object"} - client := pluginClientMock{list: func(_ context.Context, _ connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { - return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{plugin}, Continue: "next-page"}, nil +func TestListConnectorsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { + connector := connectorFixture("stripe") + connector.Metadata.Labels = map[string]string{"region": "eu"} + client := connectorClientMock{list: func(_ context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}, Continue: "next-page"}, nil }} command := NewListCommand(factoryReturning(client)) @@ -66,18 +65,18 @@ func TestListPluginsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { if err := json.Unmarshal([]byte(output), &envelope); err != nil { t.Fatalf("decode JSON output %q: %v", output, err) } - if !reflect.DeepEqual(envelope.Data.Plugins, []connectivityclient.Plugin{plugin}) { - t.Fatalf("JSON plugins = %#v, want complete model %#v", envelope.Data.Plugins, plugin) + if !reflect.DeepEqual(envelope.Data.Connectors, []connectivityclient.Connector{connector}) { + t.Fatalf("JSON connectors = %#v, want complete model %#v", envelope.Data.Connectors, connector) } if !envelope.Data.Cursor.HasMore || envelope.Data.Cursor.PageSize != 4 || envelope.Data.Cursor.Next == nil || *envelope.Data.Cursor.Next != "next-page" { t.Fatalf("JSON cursor = %#v, want continuation and requested page size", envelope.Data.Cursor) } } -func TestPluginRootRegistersApprovedCommandsAndAliases(t *testing.T) { - command := NewCommand(factoryReturning(pluginClientMock{})) - if command.Use != "plugins" || !reflect.DeepEqual(command.Aliases, []string{"plugin", "p"}) { - t.Fatalf("plugin root = %q aliases %v", command.Use, command.Aliases) +func TestConnectorRootRegistersApprovedCommandsAndAliases(t *testing.T) { + command := NewCommand(factoryReturning(connectorClientMock{})) + if command.Use != "connectors" || !reflect.DeepEqual(command.Aliases, []string{"connector", "c"}) { + t.Fatalf("connector root = %q aliases %v", command.Use, command.Aliases) } wantAliases := map[string][]string{ @@ -95,7 +94,7 @@ func TestPluginRootRegistersApprovedCommandsAndAliases(t *testing.T) { } } -func TestListPluginsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { +func TestListConnectorsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { tests := map[string]struct { factory func(*cobra.Command) (connectivityclient.Client, error) want string @@ -107,13 +106,13 @@ func TestListPluginsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { want: "authentication failed", }, "API": { - factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { return nil, errors.New("catalog unavailable") }}), want: "catalog unavailable", }, "empty response": { - factory: factoryReturning(pluginClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { return nil, nil }}), want: "empty response", diff --git a/cmd/connectivity/plugins/root.go b/cmd/connectivity/connectors/root.go similarity index 72% rename from cmd/connectivity/plugins/root.go rename to cmd/connectivity/connectors/root.go index 2bfc9e43..afdbc594 100644 --- a/cmd/connectivity/plugins/root.go +++ b/cmd/connectivity/connectors/root.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "github.com/spf13/cobra" @@ -9,9 +9,9 @@ import ( func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command { return fctl.NewCommand( - "plugins", - fctl.WithAliases("plugin", "p"), - fctl.WithShortDescription("Browse Connectivity plugins"), + "connectors", + fctl.WithAliases("connector", "c"), + fctl.WithShortDescription("Browse Connectivity connectors"), fctl.WithChildCommands( NewListCommand(factory), NewShowCommand(factory), diff --git a/cmd/connectivity/plugins/show.go b/cmd/connectivity/connectors/show.go similarity index 61% rename from cmd/connectivity/plugins/show.go rename to cmd/connectivity/connectors/show.go index d8fa3a26..660c7822 100644 --- a/cmd/connectivity/plugins/show.go +++ b/cmd/connectivity/connectors/show.go @@ -1,4 +1,4 @@ -package plugins +package connectors import ( "fmt" @@ -15,7 +15,9 @@ import ( ) type ShowStore struct { - Plugin connectivityclient.Plugin `json:"plugin"` + Connector connectivityclient.Connector `json:"connector"` + Versions []connectivityclient.ConnectorVersionSummary `json:"versions"` + Version *connectivityclient.ConnectorVersion `json:"version,omitempty"` } type ShowController struct { @@ -26,17 +28,20 @@ type ShowController struct { var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) func NewShowController(factory connectivityinternal.ClientFactory) *ShowController { - return &ShowController{factory: factory, store: &ShowStore{}} + return &ShowController{ + factory: factory, + store: &ShowStore{Versions: []connectivityclient.ConnectorVersionSummary{}}, + } } func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { controller := NewShowController(factory) return fctl.NewCommand( - "show ", + "show ", fctl.WithAliases("get", "g", "sh", "s"), - fctl.WithShortDescription("Show a Connectivity plugin"), + fctl.WithShortDescription("Show a Connectivity connector"), fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(CompletePluginNames(factory)), + fctl.WithValidArgsFunction(CompleteConnectorNames(factory)), fctl.WithController[*ShowStore](controller), ) } @@ -53,56 +58,85 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable if err != nil { return nil, err } - plugin, err := client.GetPlugin(cmd.Context(), args[0]) + name := args[0] + connector, err := client.GetConnector(cmd.Context(), name) if err != nil { return nil, err } - if plugin == nil { - return nil, fmt.Errorf("show connectivity plugin %q: empty response", args[0]) + if connector == nil { + return nil, fmt.Errorf("show connectivity connector %q: empty response", name) + } + c.store.Connector = *connector + + versions, err := client.ListConnectorVersions(cmd.Context(), name) + if err != nil { + return nil, err + } + if versions == nil { + return nil, fmt.Errorf("show connectivity connector %q: empty version list response", name) + } + c.store.Versions = versions.Items + + // The catalog serves versions ascending by semantic version, so the + // configSchema worth rendering is the newest one's. + if len(versions.Items) > 0 { + newest := versions.Items[len(versions.Items)-1].Version + version, err := client.GetConnectorVersion(cmd.Context(), name, newest) + if err != nil { + return nil, err + } + if version == nil { + return nil, fmt.Errorf("show connectivity connector %q: empty version %q response", name, newest) + } + c.store.Version = version } - c.store.Plugin = *plugin return c, nil } func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - plugin := c.store.Plugin + connector := c.store.Connector out := cmd.OutOrStdout() fctl.Section.WithWriter(out).Println("Information") information := pterm.TableData{ - {pterm.LightCyan("Name"), stringValue(plugin.Metadata.Name)}, - {pterm.LightCyan("Namespace"), stringValue(plugin.Metadata.Namespace)}, - {pterm.LightCyan("UID"), stringValue(plugin.Metadata.UID)}, - {pterm.LightCyan("Resource Version"), stringValue(plugin.Metadata.ResourceVersion)}, - {pterm.LightCyan("Created At"), timeValue(plugin.Metadata.CreationTimestamp)}, - {pterm.LightCyan("Labels"), mapValue(plugin.Metadata.Labels)}, - {pterm.LightCyan("Annotations"), mapValue(plugin.Metadata.Annotations)}, - {pterm.LightCyan("Image"), plugin.Spec.Image}, - {pterm.LightCyan("Version"), stringValue(plugin.Spec.Version)}, - {pterm.LightCyan("Default Version"), stringValue(plugin.Spec.DefaultVersion)}, - {pterm.LightCyan("Description"), stringValue(plugin.Spec.Description)}, - {pterm.LightCyan("Capabilities"), strings.Join(plugin.Spec.Capabilities, ", ")}, - {pterm.LightCyan("Phase"), pluginPhase(plugin.Status)}, - {pterm.LightCyan("Status Message"), pluginStatusMessage(plugin.Status)}, - {pterm.LightCyan("Documentation"), stringValue(plugin.Spec.DocsURL)}, + {pterm.LightCyan("Name"), stringValue(connector.Metadata.Name)}, + {pterm.LightCyan("Namespace"), stringValue(connector.Metadata.Namespace)}, + {pterm.LightCyan("UID"), stringValue(connector.Metadata.UID)}, + {pterm.LightCyan("Resource Version"), stringValue(connector.Metadata.ResourceVersion)}, + {pterm.LightCyan("Created At"), timeValue(connector.Metadata.CreationTimestamp)}, + {pterm.LightCyan("Labels"), mapValue(connector.Metadata.Labels)}, + {pterm.LightCyan("Annotations"), mapValue(connector.Metadata.Annotations)}, + {pterm.LightCyan("Display Name"), stringValue(connector.Spec.DisplayName)}, + {pterm.LightCyan("Description"), stringValue(connector.Spec.Description)}, + {pterm.LightCyan("Image URL"), stringValue(connector.Spec.ImageURL)}, + {pterm.LightCyan("Catalog"), stringValue(connector.Spec.Catalog)}, + {pterm.LightCyan("Tags"), strings.Join(connector.Spec.Tags, ", ")}, + {pterm.LightCyan("Phase"), connectorPhase(connector.Status)}, + {pterm.LightCyan("Status Message"), connectorStatusMessage(connector.Status)}, } if err := pterm.DefaultTable.WithWriter(out).WithData(information).Render(); err != nil { return err } - if len(plugin.Spec.Versions) > 0 { + if len(c.store.Versions) > 0 { fctl.Section.WithWriter(out).Println("Versions") - versions := fctl.Map(plugin.Spec.Versions, func(version connectivityclient.VersionEntry) []string { - return []string{version.Version, stringValue(version.Digest), stringValue(version.Image)} + versions := fctl.Map(c.store.Versions, func(version connectivityclient.ConnectorVersionSummary) []string { + return []string{version.Version, stringValue(version.Digest), version.Image, timeValue(version.ReleaseDate)} }) - versions = fctl.Prepend(versions, []string{"Version", "Digest", "Image"}) + versions = fctl.Prepend(versions, []string{"Version", "Digest", "Image", "Released"}) if err := pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(versions).Render(); err != nil { return err } } - fctl.Section.WithWriter(out).Println("Configuration Schema") - schemaRows := summarizeSchema(plugin.Spec.ConfigSchema) + if c.store.Version == nil { + fctl.Section.WithWriter(out).Println("Configuration Schema") + _, err := fmt.Fprintln(out, "No published version.") + return err + } + + fctl.Section.WithWriter(out).Println("Configuration Schema (" + c.store.Version.Version + ")") + schemaRows := summarizeSchema(c.store.Version.ConfigSchema) if len(schemaRows) == 0 { _, err := fmt.Fprintln(out, "No configurable fields.") return err @@ -219,7 +253,7 @@ func mapValue(value map[string]string) string { return strings.Join(entries, ", ") } -func pluginStatusMessage(status *connectivityclient.PluginStatus) string { +func connectorStatusMessage(status *connectivityclient.ConnectorStatus) string { if status == nil { return "" } diff --git a/cmd/connectivity/connectors/show_test.go b/cmd/connectivity/connectors/show_test.go new file mode 100644 index 00000000..eb4f9064 --- /dev/null +++ b/cmd/connectivity/connectors/show_test.go @@ -0,0 +1,241 @@ +package connectors + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func versionsFixture() *connectivityclient.ConnectorVersionList { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "1.0.0", Image: "registry/connector:1", Digest: fctl.Ptr("sha256:one")}, + {Version: "2.0.0", Image: "registry/connector:2"}, + }} +} + +func TestShowConnectorRendersMetadataVersionsTagsStatusAndNewestVersionSchema(t *testing.T) { + created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) + connector := connectorFixture("stripe") + connector.Metadata.Namespace = fctl.Ptr("formance") + connector.Metadata.ResourceVersion = fctl.Ptr("42") + connector.Metadata.UID = fctl.Ptr("connector-uid") + connector.Metadata.CreationTimestamp = &created + connector.Metadata.Labels = map[string]string{"region": "eu"} + connector.Metadata.Annotations = map[string]string{"owner": "platform"} + connector.Status.Message = fctl.Ptr("Catalog entry is healthy") + + var gotVersion string + client := connectorClientMock{ + get: func(_ context.Context, name string) (*connectivityclient.Connector, error) { + if name != "stripe" { + t.Fatalf("GetConnector name = %q, want stripe", name) + } + return &connector, nil + }, + listVersions: func(_ context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + if name != "stripe" { + t.Fatalf("ListConnectorVersions name = %q, want stripe", name) + } + return versionsFixture(), nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return &connectivityclient.ConnectorVersion{ + Version: version, + Image: "registry/connector:2", + ConfigSchema: map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "required": []any{"API_KEY"}, + "properties": map[string]any{"API_KEY": map[string]any{"type": "string", "format": "password", "description": "API credential"}}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{"/etc/connector.yaml": map[string]any{"type": "string", "description": "Connector settings"}}, + }, + }, + }, nil + }, + } + command := NewShowCommand(factoryReturning(client)) + output, err := executeCommand(command, "stripe") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + + if gotVersion != "2.0.0" { + t.Fatalf("GetConnectorVersion version = %q, want the newest published version", gotVersion) + } + for _, expected := range []string{ + "stripe", "formance", "42", "connector-uid", created.Format(time.RFC3339), "region=eu", "owner=platform", + "Connector display name", "Connector description", "registry/connector", "public", + "payments, webhooks", "Ready", "Catalog entry is healthy", + "1.0.0", "sha256:one", "2.0.0", + "Configuration Schema (2.0.0)", "API_KEY", "environment", "required", "password", "API credential", + "/etc/connector.yaml", "file", "Connector settings", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } +} + +func TestShowConnectorWithoutVersionsReportsNoPublishedVersion(t *testing.T) { + connector := connectorFixture("fresh") + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + t.Fatal("GetConnectorVersion must not run without a published version") + return nil, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "fresh") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + if !strings.Contains(output, "No published version.") { + t.Fatalf("output missing the no-version notice:\n%s", output) + } +} + +func TestShowConnectorJSONPreservesCompleteModel(t *testing.T) { + connector := connectorFixture("stripe") + version := &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "registry/connector:2", + ConfigSchema: map[string]any{"type": "object"}, + } + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return version, nil + }, + } + + command := NewShowCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json", "stripe") + if err != nil { + t.Fatalf("execute JSON show command: %v", err) + } + + var envelope struct { + Data ShowStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if !reflect.DeepEqual(envelope.Data.Connector, connector) { + t.Fatalf("JSON connector = %#v, want complete model %#v", envelope.Data.Connector, connector) + } + if !reflect.DeepEqual(envelope.Data.Versions, versionsFixture().Items) { + t.Fatalf("JSON versions = %#v, want the complete version list", envelope.Data.Versions) + } + if !reflect.DeepEqual(envelope.Data.Version, version) { + t.Fatalf("JSON version = %#v, want complete model %#v", envelope.Data.Version, version) + } +} + +func TestShowConnectorReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(connectorClientMock{get: func(context.Context, string) (*connectivityclient.Connector, error) { + return nil, errors.New("connector unavailable") + }}), + want: "connector unavailable", + }, + "empty response": { + factory: factoryReturning(connectorClientMock{get: func(context.Context, string) (*connectivityclient.Connector, error) { + return nil, nil + }}), + want: "empty response", + }, + "version list API": { + factory: factoryReturning(connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + connector := connectorFixture("stripe") + return &connector, nil + }, + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return nil, errors.New("catalog unavailable") + }, + }), + want: "catalog unavailable", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewShowCommand(test.factory), "stripe") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} + +func TestShowConnectorSummarizesLegacyFlatSchema(t *testing.T) { + connector := connectorFixture("legacy") + connector.Status = nil + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + return &connectivityclient.ConnectorVersion{ + Version: version, + Image: "registry/connector:2", + ConfigSchema: map[string]any{ + "type": "object", + "required": []string{"ENDPOINT"}, + "properties": map[string]any{ + "ENDPOINT": map[string]any{"type": "string", "description": "Service endpoint"}, + }, + }, + }, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "legacy") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + for _, expected := range []string{"ENDPOINT", "environment", "required", "string", "Service endpoint"} { + if !strings.Contains(output, expected) { + t.Errorf("legacy schema output missing %q:\n%s", expected, output) + } + } +} diff --git a/cmd/connectivity/connectors/test_helpers_test.go b/cmd/connectivity/connectors/test_helpers_test.go new file mode 100644 index 00000000..96634e94 --- /dev/null +++ b/cmd/connectivity/connectors/test_helpers_test.go @@ -0,0 +1,71 @@ +package connectors + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type connectorClientMock struct { + connectivityclient.Client + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + get func(context.Context, string) (*connectivityclient.Connector, error) + listVersions func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) + getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) +} + +func (m connectorClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.list(ctx, options) +} + +func (m connectorClientMock) GetConnector(ctx context.Context, name string) (*connectivityclient.Connector, error) { + return m.get(ctx, name) +} + +func (m connectorClientMock) ListConnectorVersions(ctx context.Context, connector string) (*connectivityclient.ConnectorVersionList, error) { + if m.listVersions == nil { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + } + return m.listVersions(ctx, connector) +} + +func (m connectorClientMock) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + if m.getVersion == nil { + return &connectivityclient.ConnectorVersion{Version: version, Image: "registry/connector:" + version}, nil + } + return m.getVersion(ctx, connector, version) +} + +func connectorFixture(name string) connectivityclient.Connector { + return connectivityclient.Connector{ + Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, + Spec: connectivityclient.ConnectorSpec{ + DisplayName: fctl.Ptr("Connector display name"), + Description: fctl.Ptr("Connector description"), + ImageURL: fctl.Ptr("registry/connector"), + Catalog: fctl.Ptr("public"), + Tags: []string{"payments", "webhooks"}, + }, + Status: &connectivityclient.ConnectorStatus{Phase: fctl.Ptr("Ready")}, + } +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} diff --git a/cmd/connectivity/instances/install_test.go b/cmd/connectivity/instances/install_test.go deleted file mode 100644 index 2f271bb7..00000000 --- a/cmd/connectivity/instances/install_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package instances - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" - connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type mutationClientMock struct { - connectivityclient.Client - listPlugins func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) - getPlugin func(context.Context, string) (*connectivityclient.Plugin, error) - create func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) -} - -func (m mutationClientMock) ListPlugins(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { - return m.listPlugins(ctx, options) -} - -func (m mutationClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - return m.getPlugin(ctx, name) -} - -func (m mutationClientMock) CreateInstance(ctx context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - return m.create(ctx, body) -} - -func TestInstallBuildsInstanceFromPluginSchemaAndOnlySetsChangedScalars(t *testing.T) { - plugin := pluginWithSchemaAndDefaults() - var gotPlugin string - var gotBody connectivityclient.InstanceCreate - client := mutationClientMock{ - getPlugin: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { - gotPlugin = name - return plugin, nil - }, - create: func(_ context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - gotBody = body - return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil - }, - } - read := mockReadFile(map[string]string{ - "install.yaml": "env:\n API_URL:\n value: https://config.example\n", - "plugin.env": "TIMEOUT=45s\n", - "token.txt": "file-token", - }) - - output, err := executeCommand( - NewInstallCommand(factoryReturning(client), read, mockPathCompleter(nil)), - "stripe", "--name=stripe-eu", "--ledger=main", "--version=2.0.0", - "--poll-interval=3s", "--config=install.yaml", - "--env-file=plugin.env", "--set=TOKEN=@token.txt", "--confirm", - ) - - require.NoError(t, err) - require.Equal(t, "stripe", gotPlugin) - require.Equal(t, "stripe-eu", gotBody.Name) - require.Equal(t, "stripe", gotBody.Spec.Plugin) - require.Equal(t, "main", gotBody.Spec.Ledger) - require.Equal(t, "2.0.0", *gotBody.Spec.Version) - require.Equal(t, "3s", *gotBody.Spec.PollInterval) - require.Equal(t, "https://config.example", *gotBody.Spec.Config.Env["API_URL"].Value) - require.Equal(t, "file-token", *gotBody.Spec.Config.Env["TOKEN"].Value) - require.Equal(t, "45s", *gotBody.Spec.Config.Env["TIMEOUT"].Value) - require.Equal(t, plugin.Spec.Defaults.Files, gotBody.Spec.Config.Files) - require.Equal(t, "Instance \"stripe-eu\" installed with plugin \"stripe\" for ledger \"main\".", strings.TrimSpace(output)) -} - -func TestInstallDefaultsNameToPluginAndOmitsUnsetOptionalData(t *testing.T) { - plugin := &connectivityclient.Plugin{} - var gotBody connectivityclient.InstanceCreate - client := mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return plugin, nil }, - create: func(_ context.Context, body connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - gotBody = body - return &connectivityclient.Instance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil - }, - } - - _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "wise", "--ledger=main", "--confirm") - - require.NoError(t, err) - require.Equal(t, "wise", gotBody.Name) - require.Nil(t, gotBody.Spec.Version) - require.Nil(t, gotBody.Spec.StartSequence) - require.Nil(t, gotBody.Spec.PollInterval) - require.Nil(t, gotBody.Spec.Config) -} - -func TestInstallRequiresLedgerBeforeUsingClient(t *testing.T) { - usedClient := false - factory := func(*cobra.Command) (connectivityclient.Client, error) { - usedClient = true - return nil, errors.New("client must not be used") - } - - _, err := executeCommand(NewInstallCommand(factory, mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--confirm") - - require.ErrorContains(t, err, `required flag(s) "ledger" not set`) - require.False(t, usedClient) -} - -func TestInstallConfirmationRejectionPreventsCreateInstance(t *testing.T) { - created := false - client := mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - created = true - return nil, errors.New("CreateInstance must not be called") - }, - } - controller := NewInstallController(factoryReturning(client), mockReadFile(nil)) - controller.approve = func(*cobra.Command, string, ...any) bool { return false } - command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) - require.NoError(t, command.ParseFlags([]string{"--ledger=main"})) - - _, err := controller.Run(command, []string{"stripe"}) - - require.ErrorIs(t, err, fctl.ErrMissingApproval) - require.False(t, created) -} - -func TestInstallValidationErrorPreventsCreateInstance(t *testing.T) { - created := false - client := mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - created = true - return nil, errors.New("CreateInstance must not be called") - }, - } - - _, err := executeCommand( - NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), - "stripe", "--ledger=main", "--set=UNKNOWN=value", "--confirm", - ) - - require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") - require.False(t, created) -} - -func TestInstallPreservesPluginAndCreateAPIErrorsAndRejectsEmptyResponses(t *testing.T) { - pluginError := &connectivityclient.APIError{StatusCode: 404, Code: "PLUGIN_NOT_FOUND", Message: "missing"} - createError := &connectivityclient.APIError{StatusCode: 409, Code: "INSTANCE_EXISTS", Message: "duplicate"} - tests := map[string]struct { - client mutationClientMock - want error - text string - }{ - "plugin API": { - client: mutationClientMock{getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, pluginError }}, - want: pluginError, - }, - "empty plugin": { - client: mutationClientMock{getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { return nil, nil }}, - text: "empty plugin response", - }, - "create API": { - client: mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - return nil, createError - }, - }, - want: createError, - }, - "empty create": { - client: mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - return nil, nil - }, - }, - text: "empty instance response", - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - _, err := executeCommand(NewInstallCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") - require.Error(t, err) - if test.want != nil { - require.ErrorIs(t, err, test.want) - } - if test.text != "" { - require.ErrorContains(t, err, test.text) - } - }) - } -} - -func TestInstallJSONStoresCompleteReturnedInstance(t *testing.T) { - returned := instanceFixture("stripe-eu") - returned.Metadata.Labels = map[string]string{"region": "eu"} - returned.Spec.Config = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ - "TOKEN": {Value: stringPtr("preserved-in-json")}, - }} - client := mutationClientMock{ - getPlugin: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &connectivityclient.Plugin{}, nil - }, - create: func(context.Context, connectivityclient.InstanceCreate) (*connectivityclient.Instance, error) { - return &returned, nil - }, - } - command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) - command.Flags().String(fctl.OutputFlag, "plain", "") - - output, err := executeCommand(command, "stripe", "--ledger=main", "--confirm", "--output=json") - - require.NoError(t, err) - var envelope struct { - Data InstallStore `json:"data"` - } - require.NoError(t, json.Unmarshal([]byte(output), &envelope)) - require.Equal(t, returned, envelope.Data.Instance) -} - -func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { - standalone := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) - require.Equal(t, "install ", standalone.Use) - require.Equal(t, []string{"create", "in"}, standalone.Aliases) - require.Error(t, standalone.Args(standalone, nil)) - require.NoError(t, standalone.Args(standalone, []string{"stripe"})) - require.Error(t, standalone.Args(standalone, []string{"stripe", "extra"})) - - root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) - child, _, err := root.Find([]string{"install"}) - require.NoError(t, err) - require.Equal(t, "install", child.Name()) - require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) -} - -func TestInstallDoesNotExposeUnsupportedStartSequence(t *testing.T) { - command := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) - - require.Nil(t, command.Flags().Lookup("start-sequence")) -} - -func TestInstallWiresPluginVersionSetAndFileCompletions(t *testing.T) { - plugin := pluginWithSchemaAndDefaults() - plugin.Metadata.Name = stringPtr("stripe") - plugin.Spec.Description = stringPtr("Stripe ingestion") - client := mutationClientMock{ - listPlugins: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { - require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) - return &connectivityclient.PluginList{Items: []connectivityclient.Plugin{*plugin}}, nil - }, - getPlugin: func(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - require.True(t, connectivityinternal.IsNonInteractive(ctx)) - require.Equal(t, "stripe", name) - return plugin, nil - }, - } - var pathPrefix string - paths := func(prefix string) ([]string, error) { - pathPrefix = prefix - return []string{"fixtures/token.txt"}, nil - } - factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { - require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) - return client, nil - } - command := NewInstallCommand(factory, mockReadFile(nil), paths) - - plugins, directive := command.ValidArgsFunction(command, nil, "str") - require.Equal(t, []string{"stripe\tStripe ingestion"}, plugins) - require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) - - versionCompletion, ok := command.GetFlagCompletionFunc("version") - require.True(t, ok) - versions, directive := versionCompletion(command, []string{"stripe"}, "2") - require.Equal(t, []string{"2.0.0\texample/plugin:2.0.0"}, versions) - require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) - - setCompletion, ok := command.GetFlagCompletionFunc("set") - require.True(t, ok) - sets, directive := setCompletion(command, []string{"stripe"}, "API") - require.Equal(t, []string{"API_URL=\tAPI endpoint"}, sets) - require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) - - sets, directive = setCompletion(command, []string{"stripe"}, "TOKEN=@fixtures/t") - require.Equal(t, "fixtures/t", pathPrefix) - require.Equal(t, []string{"TOKEN=@fixtures/token.txt"}, sets) - require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) - - _, configRegistered := command.GetFlagCompletionFunc("config") - _, envFileRegistered := command.GetFlagCompletionFunc("env-file") - require.False(t, configRegistered, "--config must retain Cobra's normal file completion") - require.False(t, envFileRegistered, "--env-file must retain Cobra's normal file completion") -} diff --git a/cmd/connectivity/instances/test_helpers_test.go b/cmd/connectivity/instances/test_helpers_test.go deleted file mode 100644 index 03bfd4db..00000000 --- a/cmd/connectivity/instances/test_helpers_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package instances - -import ( - "bytes" - "context" - "fmt" - - "github.com/spf13/cobra" - - connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" - connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func stringPtr(value string) *string { - return &value -} - -func instanceFixture(name string) connectivityclient.Instance { - return connectivityclient.Instance{ - Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}, - Spec: connectivityclient.InstanceSpec{ - Plugin: "stripe", - Version: stringPtr("2.0.0"), - Ledger: "main", - PollInterval: stringPtr("5s"), - StartSequence: fctl.Ptr(int64(10)), - }, - Status: &connectivityclient.InstanceStatus{ - Phase: stringPtr("Ready"), - State: stringPtr("Running"), - ResolvedImage: stringPtr("registry/plugin:2.0.0"), - PluginAddress: stringPtr("http://stripe.default.svc"), - CurrentSequence: fctl.Ptr(int64(42)), - SourceTipSequence: fctl.Ptr(int64(48)), - LastError: stringPtr("source temporarily unavailable"), - Message: stringPtr("retrying ingestion"), - }, - } -} - -func instanceWithTwoFiles() *connectivityclient.Instance { - instance := instanceFixture("stripe-eu") - instance.Spec.Config = &connectivityclient.InstanceConfig{Files: []connectivityclient.FileMount{ - {Path: "/etc/plugin/config.yaml", Value: stringPtr("private config")}, - {Path: "/etc/plugin/ca.pem", SecretRef: &connectivityclient.KeyRef{Name: "plugin-secrets", Key: "ca.pem"}}, - }} - return &instance -} - -func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { - return func(*cobra.Command) (connectivityclient.Client, error) { - return client, nil - } -} - -func factoryWithInstance(instance connectivityclient.Instance) connectivityinternal.ClientFactory { - return factoryReturning(showInstanceClientMock{get: func(_ context.Context, _ string) (*connectivityclient.Instance, error) { - return &instance, nil - }}) -} - -func mockReadFile(files map[string]string) ReadFileFunc { - return func(_ *cobra.Command, name string) (string, error) { - value, ok := files[name] - if !ok { - return "", fmt.Errorf("test file %q not found", name) - } - return value, nil - } -} - -func mapReadFile(files map[string]string) ReadFileFunc { - return mockReadFile(files) -} - -func mockPathCompleter(paths []string) PathCompleter { - return func(string) ([]string, error) { - return paths, nil - } -} - -func executeCommand(command *cobra.Command, args ...string) (string, error) { - var output bytes.Buffer - command.SetOut(&output) - command.SetErr(&output) - command.SetArgs(args) - err := command.Execute() - return output.String(), err -} - -func pluginWithFileSchema() *connectivityclient.Plugin { - return &connectivityclient.Plugin{Spec: connectivityclient.PluginSpec{ - ConfigSchema: map[string]any{ - "type": "object", - "env": map[string]any{ - "type": "object", - "properties": map[string]any{ - "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, - }, - "required": []any{}, - }, - "files": map[string]any{ - "type": "object", - "properties": map[string]any{ - "/etc/a": map[string]any{"type": "string", "description": "Primary config"}, - "/etc/b": map[string]any{"type": "string", "format": "password"}, - }, - "required": []any{"/etc/a"}, - }, - }, - Versions: []connectivityclient.VersionEntry{ - {Version: "1.0.0", Image: stringPtr("example/plugin:1.0.0")}, - {Version: "2.0.0", Image: stringPtr("example/plugin:2.0.0")}, - }, - }} -} - -func pluginWithSchemaAndDefaults() *connectivityclient.Plugin { - plugin := pluginWithFileSchema() - plugin.Spec.ConfigSchema = map[string]any{ - "type": "object", - "env": map[string]any{ - "type": "object", - "properties": map[string]any{ - "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, - "TOKEN": map[string]any{"type": "string", "format": "password", "description": "Authentication token"}, - "TIMEOUT": map[string]any{"type": "string"}, - }, - "required": []any{"API_URL", "TOKEN"}, - }, - "files": map[string]any{ - "type": "object", - "properties": map[string]any{ - "/etc/plugin/config.yaml": map[string]any{"type": "string", "description": "Plugin configuration"}, - "/etc/plugin/ca.pem": map[string]any{"type": "string", "format": "password"}, - }, - "required": []any{"/etc/plugin/config.yaml"}, - }, - } - plugin.Spec.Defaults = &connectivityclient.InstanceConfig{ - Env: map[string]connectivityclient.EnvValue{ - "API_URL": {Value: stringPtr("https://default.example")}, - "TOKEN": {Value: stringPtr("default-token")}, - "TIMEOUT": {Value: stringPtr("30s")}, - }, - Files: []connectivityclient.FileMount{ - {Path: "/etc/plugin/config.yaml", Value: stringPtr("default: true\n")}, - {Path: "/etc/plugin/ca.pem", Value: stringPtr("default-ca")}, - }, - } - return plugin -} diff --git a/cmd/connectivity/plugins/show_test.go b/cmd/connectivity/plugins/show_test.go deleted file mode 100644 index 08490804..00000000 --- a/cmd/connectivity/plugins/show_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" - - "github.com/spf13/cobra" - - connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestShowPluginRendersMetadataVersionsCapabilitiesStatusAndSchemaWithoutDefaults(t *testing.T) { - created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) - plugin := pluginFixture("stripe") - plugin.Metadata.Namespace = fctl.Ptr("formance") - plugin.Metadata.ResourceVersion = fctl.Ptr("42") - plugin.Metadata.UID = fctl.Ptr("plugin-uid") - plugin.Metadata.CreationTimestamp = &created - plugin.Metadata.Labels = map[string]string{"region": "eu"} - plugin.Metadata.Annotations = map[string]string{"owner": "platform"} - plugin.Spec.Version = fctl.Ptr("2.0.0") - plugin.Spec.DocsURL = fctl.Ptr("https://docs.example/stripe") - plugin.Spec.Versions = []connectivityclient.VersionEntry{ - {Version: "1.0.0", Digest: fctl.Ptr("sha256:one")}, - {Version: "2.0.0", Image: fctl.Ptr("registry/plugin:2")}, - } - plugin.Spec.ConfigSchema = map[string]any{ - "type": "object", - "env": map[string]any{ - "type": "object", - "required": []any{"API_KEY"}, - "properties": map[string]any{"API_KEY": map[string]any{"type": "string", "format": "password", "description": "API credential"}}, - }, - "files": map[string]any{ - "type": "object", - "properties": map[string]any{"/etc/plugin.yaml": map[string]any{"type": "string", "description": "Plugin settings"}}, - }, - } - plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ - "API_KEY": {Value: fctl.Ptr("must-not-leak")}, - }} - plugin.Status.Message = fctl.Ptr("Catalog entry is healthy") - - client := pluginClientMock{get: func(_ context.Context, name string) (*connectivityclient.Plugin, error) { - if name != "stripe" { - t.Fatalf("GetPlugin name = %q, want stripe", name) - } - return &plugin, nil - }} - command := NewShowCommand(factoryReturning(client)) - output, err := executeCommand(command, "stripe") - if err != nil { - t.Fatalf("execute show command: %v", err) - } - - for _, expected := range []string{ - "stripe", "formance", "42", "plugin-uid", created.Format(time.RFC3339), "region=eu", "owner=platform", - "registry/plugin", "Plugin description", "https://docs.example/stripe", "2.0.0", "1.0.0", "sha256:one", - "payments, webhooks", "Ready", "Catalog entry is healthy", - "Configuration Schema", "API_KEY", "environment", "required", "password", "API credential", - "/etc/plugin.yaml", "file", "Plugin settings", - } { - if !strings.Contains(output, expected) { - t.Errorf("plain output missing %q:\n%s", expected, output) - } - } - if strings.Contains(output, "must-not-leak") { - t.Fatalf("plain output leaked an inline default value:\n%s", output) - } -} - -func TestShowPluginJSONPreservesCompleteModel(t *testing.T) { - plugin := pluginFixture("stripe") - plugin.Spec.Defaults = &connectivityclient.InstanceConfig{Env: map[string]connectivityclient.EnvValue{ - "API_KEY": {Value: fctl.Ptr("json-keeps-full-model")}, - }} - client := pluginClientMock{get: func(_ context.Context, _ string) (*connectivityclient.Plugin, error) { - return &plugin, nil - }} - - command := NewShowCommand(factoryReturning(client)) - command.Flags().String(fctl.OutputFlag, "plain", "") - output, err := executeCommand(command, "--output", "json", "stripe") - if err != nil { - t.Fatalf("execute JSON show command: %v", err) - } - - var envelope struct { - Data ShowStore `json:"data"` - } - if err := json.Unmarshal([]byte(output), &envelope); err != nil { - t.Fatalf("decode JSON output %q: %v", output, err) - } - if !reflect.DeepEqual(envelope.Data.Plugin, plugin) { - t.Fatalf("JSON plugin = %#v, want complete model %#v", envelope.Data.Plugin, plugin) - } -} - -func TestShowPluginReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { - tests := map[string]struct { - factory func(*cobra.Command) (connectivityclient.Client, error) - want string - }{ - "factory": { - factory: func(*cobra.Command) (connectivityclient.Client, error) { - return nil, errors.New("authentication failed") - }, - want: "authentication failed", - }, - "API": { - factory: factoryReturning(pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { - return nil, errors.New("plugin unavailable") - }}), - want: "plugin unavailable", - }, - "empty response": { - factory: factoryReturning(pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { - return nil, nil - }}), - want: "empty response", - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - _, err := executeCommand(NewShowCommand(test.factory), "stripe") - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("error = %v, want one containing %q", err, test.want) - } - }) - } -} - -func TestShowPluginSummarizesLegacyFlatSchema(t *testing.T) { - plugin := pluginFixture("legacy") - plugin.Status = nil - plugin.Spec.ConfigSchema = map[string]any{ - "type": "object", - "required": []string{"ENDPOINT"}, - "properties": map[string]any{ - "ENDPOINT": map[string]any{"type": "string", "description": "Service endpoint"}, - }, - } - client := pluginClientMock{get: func(context.Context, string) (*connectivityclient.Plugin, error) { - return &plugin, nil - }} - - output, err := executeCommand(NewShowCommand(factoryReturning(client)), "legacy") - if err != nil { - t.Fatalf("execute show command: %v", err) - } - for _, expected := range []string{"ENDPOINT", "environment", "required", "string", "Service endpoint"} { - if !strings.Contains(output, expected) { - t.Errorf("legacy schema output missing %q:\n%s", expected, output) - } - } -} diff --git a/cmd/connectivity/plugins/test_helpers_test.go b/cmd/connectivity/plugins/test_helpers_test.go deleted file mode 100644 index 7de51df5..00000000 --- a/cmd/connectivity/plugins/test_helpers_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package plugins - -import ( - "bytes" - "context" - - "github.com/spf13/cobra" - - connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" - connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type pluginClientMock struct { - connectivityclient.Client - list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.PluginList, error) - get func(context.Context, string) (*connectivityclient.Plugin, error) -} - -func (m pluginClientMock) ListPlugins(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.PluginList, error) { - return m.list(ctx, options) -} - -func (m pluginClientMock) GetPlugin(ctx context.Context, name string) (*connectivityclient.Plugin, error) { - return m.get(ctx, name) -} - -func pluginFixture(name string) connectivityclient.Plugin { - return connectivityclient.Plugin{ - Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, - Spec: connectivityclient.PluginSpec{ - Image: "registry/plugin", - Description: fctl.Ptr("Plugin description"), - DefaultVersion: fctl.Ptr("2.0.0"), - Capabilities: []string{"payments", "webhooks"}, - }, - Status: &connectivityclient.PluginStatus{Phase: fctl.Ptr("Ready")}, - } -} - -func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { - return func(*cobra.Command) (connectivityclient.Client, error) { - return client, nil - } -} - -func executeCommand(command *cobra.Command, args ...string) (string, error) { - var output bytes.Buffer - command.SetOut(&output) - command.SetErr(&output) - command.SetArgs(args) - err := command.Execute() - return output.String(), err -} diff --git a/cmd/connectivity/root.go b/cmd/connectivity/root.go index 15c1dabc..2c30403e 100644 --- a/cmd/connectivity/root.go +++ b/cmd/connectivity/root.go @@ -3,19 +3,19 @@ package connectivity import ( "github.com/spf13/cobra" - "github.com/formancehq/fctl/v3/cmd/connectivity/instances" + "github.com/formancehq/fctl/v3/cmd/connectivity/connectorinstances" + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" - "github.com/formancehq/fctl/v3/cmd/connectivity/plugins" fctl "github.com/formancehq/fctl/v3/pkg" ) func NewCommand() *cobra.Command { factory := connectivityinternal.NewClientFactory() return fctl.NewStackCommand("connectivity", - fctl.WithShortDescription("Manage Connectivity plugins and instances"), + fctl.WithShortDescription("Manage Connectivity connectors and connector instances"), fctl.WithChildCommands( - plugins.NewCommand(factory), - instances.NewCommand(factory, fctl.ReadFile, instances.OSPathCompleter), + connectors.NewCommand(factory), + connectorinstances.NewCommand(factory, fctl.ReadFile, connectorinstances.OSPathCompleter), ), ) } diff --git a/cmd/connectivity/root_test.go b/cmd/connectivity/root_test.go index 57ebff21..418f7bb4 100644 --- a/cmd/connectivity/root_test.go +++ b/cmd/connectivity/root_test.go @@ -11,13 +11,13 @@ func TestCommandTreeMatchesConnectivityUX(t *testing.T) { require.Equal(t, "connectivity", cmd.Name()) for _, path := range [][]string{ - {"plugins", "list"}, - {"plugins", "show"}, - {"instances", "list"}, - {"instances", "show"}, - {"instances", "install"}, - {"instances", "configure"}, - {"instances", "uninstall"}, + {"connectors", "list"}, + {"connectors", "show"}, + {"connectorinstances", "list"}, + {"connectorinstances", "show"}, + {"connectorinstances", "install"}, + {"connectorinstances", "configure"}, + {"connectorinstances", "uninstall"}, } { found, _, err := cmd.Find(path) require.NoError(t, err) diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go index 7f3a131b..e5751fdb 100644 --- a/internal/connectivityclient/client.go +++ b/internal/connectivityclient/client.go @@ -14,6 +14,12 @@ import ( const connectivityPath = "/api/connectivity" +const ( + connectorsResource = "connectors" + connectorVersionsResource = "versions" + connectorInstancesResource = "connectorinstances" +) + type client struct { stackURI string httpClient *http.Client @@ -23,100 +29,75 @@ func (e *APIError) Error() string { return fmt.Sprintf("connectivity API error: status %d, code %s: %s", e.StatusCode, e.Code, e.Message) } -func (c *client) ListPlugins(ctx context.Context, options ListOptions) (*PluginList, error) { - result := &PluginList{} - if err := c.requestJSON(ctx, http.MethodGet, "plugins", "", listQuery(options), nil, "", http.StatusOK, result, true); err != nil { +func (c *client) ListConnectors(ctx context.Context, options ListOptions) (*ConnectorList, error) { + result := &ConnectorList{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorsResource}, listQuery(options), nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -func (c *client) GetPlugin(ctx context.Context, name string) (*Plugin, error) { - result := &Plugin{} - if err := c.requestJSON(ctx, http.MethodGet, "plugins", name, nil, nil, "", http.StatusOK, result, true); err != nil { +func (c *client) GetConnector(ctx context.Context, name string) (*Connector, error) { + result := &Connector{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorsResource, name}, nil, nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -func (c *client) ListInstances(ctx context.Context, options ListOptions) (*InstanceList, error) { - result := &InstanceList{} - if err := c.requestJSON(ctx, http.MethodGet, "instances", "", listQuery(options), nil, "", http.StatusOK, result, true); err != nil { +func (c *client) ListConnectorVersions(ctx context.Context, connector string) (*ConnectorVersionList, error) { + result := &ConnectorVersionList{} + path := []string{connectorsResource, connector, connectorVersionsResource} + if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -func (c *client) CreateInstance(ctx context.Context, instance InstanceCreate) (*Instance, error) { - result := &Instance{} - if err := c.requestJSON(ctx, http.MethodPost, "instances", "", nil, instance, "application/json", http.StatusCreated, result, true); err != nil { +func (c *client) GetConnectorVersion(ctx context.Context, connector, version string) (*ConnectorVersion, error) { + result := &ConnectorVersion{} + path := []string{connectorsResource, connector, connectorVersionsResource, version} + if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -func (c *client) GetInstance(ctx context.Context, name string) (*Instance, error) { - result := &Instance{} - if err := c.requestJSON(ctx, http.MethodGet, "instances", name, nil, nil, "", http.StatusOK, result, true); err != nil { +func (c *client) ListConnectorInstances(ctx context.Context, options ListOptions) (*ConnectorInstanceList, error) { + result := &ConnectorInstanceList{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorInstancesResource}, listQuery(options), nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -func (c *client) PatchInstance(ctx context.Context, name string, patch InstancePatch) (*Instance, error) { - wirePatch, err := adaptInstancePatchForPinnedAPI(patch) - if err != nil { +func (c *client) CreateConnectorInstance(ctx context.Context, instance ConnectorInstanceCreate) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + if err := c.requestJSON(ctx, http.MethodPost, []string{connectorInstancesResource}, nil, instance, "application/json", http.StatusCreated, result, true); err != nil { return nil, err } - result := &Instance{} - if err := c.requestJSON(ctx, http.MethodPatch, "instances", name, nil, wirePatch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { + return result, nil +} + +func (c *client) GetConnectorInstance(ctx context.Context, name string) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorInstancesResource, name}, nil, nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil } -// adaptInstancePatchForPinnedAPI bridges the API 0.1.0 model to Connectivity -// commit 2ec12564's PATCH handler. That handler applies the patch directly to -// the Instance CRD and materializes passwords by reading spec.env/spec.files, -// while read/create mapping exposes those fields as spec.config. -func adaptInstancePatchForPinnedAPI(patch InstancePatch) (InstancePatch, error) { - encoded, err := json.Marshal(patch) - if err != nil { - return nil, fmt.Errorf("marshal connectivity instance patch: %w", err) - } - wirePatch := InstancePatch{} - if err := json.Unmarshal(encoded, &wirePatch); err != nil { - return nil, fmt.Errorf("reshape connectivity instance patch: %w", err) - } - spec, ok := wirePatch["spec"].(map[string]any) - if !ok { - return wirePatch, nil - } - config, exists := spec["config"] - if !exists { - return wirePatch, nil - } - delete(spec, "config") - if config == nil { - spec["env"] = nil - spec["files"] = nil - return wirePatch, nil - } - configObject, ok := config.(map[string]any) - if !ok { - return nil, fmt.Errorf("reshape connectivity instance patch: spec.config must be an object") - } - if env, ok := configObject["env"]; ok { - spec["env"] = env - } - if files, ok := configObject["files"]; ok { - spec["files"] = files +func (c *client) PatchConnectorInstance(ctx context.Context, name string, patch ConnectorInstancePatch) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + path := []string{connectorInstancesResource, name} + if err := c.requestJSON(ctx, http.MethodPatch, path, nil, patch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { + return nil, err } - return wirePatch, nil + return result, nil } -func (c *client) DeleteInstance(ctx context.Context, name string) error { - return c.requestJSON(ctx, http.MethodDelete, "instances", name, nil, nil, "", http.StatusNoContent, nil, false) +func (c *client) DeleteConnectorInstance(ctx context.Context, name string) error { + return c.requestJSON(ctx, http.MethodDelete, []string{connectorInstancesResource, name}, nil, nil, "", http.StatusNoContent, nil, false) } func listQuery(options ListOptions) url.Values { @@ -127,14 +108,14 @@ func listQuery(options ListOptions) url.Values { if options.Continue != "" { query.Set("continue", options.Continue) } - if options.Plugin != "" { - query.Set("plugin", options.Plugin) + if options.Connector != "" { + query.Set("connector", options.Connector) } return query } -func (c *client) requestJSON(ctx context.Context, method, resource, name string, query url.Values, body any, contentType string, expectedStatus int, destination any, requireObject bool) error { - endpoint, err := endpointURL(c.stackURI, resource, name) +func (c *client) requestJSON(ctx context.Context, method string, path []string, query url.Values, body any, contentType string, expectedStatus int, destination any, requireObject bool) error { + endpoint, err := endpointURL(c.stackURI, path) if err != nil { return err } @@ -177,7 +158,7 @@ func (c *client) requestJSON(ctx context.Context, method, resource, name string, return decodeResponse(response.Body, destination, requireObject) } -func endpointURL(stackURI, resource, name string) (*url.URL, error) { +func endpointURL(stackURI string, segments []string) (*url.URL, error) { endpoint, err := url.Parse(stackURI) if err != nil { return nil, fmt.Errorf("parse stack URI: %w", err) @@ -191,11 +172,14 @@ func endpointURL(stackURI, resource, name string) (*url.URL, error) { if baseRawPath == "" { baseRawPath = basePath } - path := connectivityPath + "/" + resource - rawPath := path - if name != "" { - path += "/" + name - rawPath += "/" + url.PathEscape(name) + path := connectivityPath + rawPath := connectivityPath + for _, segment := range segments { + if segment == "" { + continue + } + path += "/" + segment + rawPath += "/" + url.PathEscape(segment) } endpoint.Path = basePath + path @@ -234,25 +218,36 @@ func decodeResponse(body io.Reader, destination any, requireObject bool) error { func validateResponse(destination any) error { switch value := destination.(type) { - case *Plugin: - return validatePlugin(value) - case *PluginList: + case *Connector: + return validateConnector(value) + case *ConnectorList: + if value.Items == nil { + return fmt.Errorf("items must be a non-null array") + } + for i := range value.Items { + if err := validateConnector(&value.Items[i]); err != nil { + return fmt.Errorf("items[%d]: %w", i, err) + } + } + case *ConnectorVersion: + return validateConnectorVersion(value.Version, value.Image) + case *ConnectorVersionList: if value.Items == nil { return fmt.Errorf("items must be a non-null array") } for i := range value.Items { - if err := validatePlugin(&value.Items[i]); err != nil { + if err := validateConnectorVersion(value.Items[i].Version, value.Items[i].Image); err != nil { return fmt.Errorf("items[%d]: %w", i, err) } } - case *Instance: - return validateInstance(value) - case *InstanceList: + case *ConnectorInstance: + return validateConnectorInstance(value) + case *ConnectorInstanceList: if value.Items == nil { return fmt.Errorf("items must be a non-null array") } for i := range value.Items { - if err := validateInstance(&value.Items[i]); err != nil { + if err := validateConnectorInstance(&value.Items[i]); err != nil { return fmt.Errorf("items[%d]: %w", i, err) } } @@ -260,25 +255,32 @@ func validateResponse(destination any) error { return nil } -func validatePlugin(plugin *Plugin) error { - if plugin == nil || plugin.Metadata.Name == nil || strings.TrimSpace(*plugin.Metadata.Name) == "" { - return fmt.Errorf("plugin metadata.name is required") +func validateConnector(connector *Connector) error { + if connector == nil || connector.Metadata.Name == nil || strings.TrimSpace(*connector.Metadata.Name) == "" { + return fmt.Errorf("connector metadata.name is required") + } + return nil +} + +func validateConnectorVersion(version, image string) error { + if strings.TrimSpace(version) == "" { + return fmt.Errorf("connector version version is required") } - if strings.TrimSpace(plugin.Spec.Image) == "" { - return fmt.Errorf("plugin spec.image is required") + if strings.TrimSpace(image) == "" { + return fmt.Errorf("connector version image is required") } return nil } -func validateInstance(instance *Instance) error { +func validateConnectorInstance(instance *ConnectorInstance) error { if instance == nil || instance.Metadata.Name == nil || strings.TrimSpace(*instance.Metadata.Name) == "" { - return fmt.Errorf("instance metadata.name is required") + return fmt.Errorf("connector instance metadata.name is required") } - if strings.TrimSpace(instance.Spec.Plugin) == "" { - return fmt.Errorf("instance spec.plugin is required") + if strings.TrimSpace(instance.Spec.Connector) == "" { + return fmt.Errorf("connector instance spec.connector is required") } if strings.TrimSpace(instance.Spec.Ledger) == "" { - return fmt.Errorf("instance spec.ledger is required") + return fmt.Errorf("connector instance spec.ledger is required") } return nil } diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go index 57df8899..7d296f2e 100644 --- a/internal/connectivityclient/client_test.go +++ b/internal/connectivityclient/client_test.go @@ -24,17 +24,17 @@ func jsonResponse(status int, body string) *http.Response { } } -func TestListPluginsBuildsStackConnectivityRequest(t *testing.T) { +func TestListConnectorsBuildsStackConnectivityRequest(t *testing.T) { var seen *http.Request httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { seen = req.Clone(req.Context()) - return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"img"}}],"continue":"next"}`), nil + return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}],"continue":"next"}`), nil })} - got, err := New("https://stack.example/base", httpClient).ListPlugins(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) + got, err := New("https://stack.example/base", httpClient).ListConnectors(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) require.NoError(t, err) - require.Equal(t, "/base/api/connectivity/plugins", seen.URL.Path) + require.Equal(t, "/base/api/connectivity/connectors", seen.URL.Path) require.Equal(t, "25", seen.URL.Query().Get("limit")) require.Equal(t, "cursor", seen.URL.Query().Get("continue")) require.Equal(t, "stripe", *got.Items[0].Metadata.Name) @@ -42,7 +42,7 @@ func TestListPluginsBuildsStackConnectivityRequest(t *testing.T) { } func TestClientMethodsRespectHTTPContracts(t *testing.T) { - pluginName := "stripe/primary" + connectorName := "stripe/primary" instanceName := "worker/one" tests := []struct { @@ -57,84 +57,106 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { call func(Client) error }{ { - name: "lists plugins", + name: "lists connectors", wantMethod: http.MethodGet, - wantPath: "/base/api/connectivity/plugins", - wantQuery: map[string]string{"plugin": "stripe", "limit": "5", "continue": "after"}, + wantPath: "/base/api/connectivity/connectors", + wantQuery: map[string]string{"connector": "stripe", "limit": "5", "continue": "after"}, responseStatus: http.StatusOK, responseBody: `{"items":[]}`, call: func(client Client) error { - _, err := client.ListPlugins(context.Background(), ListOptions{Plugin: "stripe", Limit: 5, Continue: "after"}) + _, err := client.ListConnectors(context.Background(), ListOptions{Connector: "stripe", Limit: 5, Continue: "after"}) return err }, }, { - name: "gets a plugin using an escaped name", + name: "gets a connector using an escaped name", wantMethod: http.MethodGet, - wantPath: "/base/api/connectivity/plugins/stripe%2Fprimary", + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary", responseStatus: http.StatusOK, - responseBody: `{"metadata":{"name":"stripe/primary"},"spec":{"image":"image"}}`, + responseBody: `{"metadata":{"name":"stripe/primary"},"spec":{"displayName":"Stripe"}}`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), pluginName) + _, err := client.GetConnector(context.Background(), connectorName) return err }, }, { - name: "lists instances", + name: "lists connector versions", wantMethod: http.MethodGet, - wantPath: "/base/api/connectivity/instances", - wantQuery: map[string]string{"plugin": "stripe", "limit": "5", "continue": "after"}, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions", + responseStatus: http.StatusOK, + responseBody: `{"items":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}`, + call: func(client Client) error { + _, err := client.ListConnectorVersions(context.Background(), connectorName) + return err + }, + }, + { + name: "gets a connector version using escaped segments", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions/v1.0.0-rc.1", + responseStatus: http.StatusOK, + responseBody: `{"version":"v1.0.0-rc.1","image":"registry/stripe:v1.0.0"}`, + call: func(client Client) error { + _, err := client.GetConnectorVersion(context.Background(), connectorName, "v1.0.0-rc.1") + return err + }, + }, + { + name: "lists connector instances", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectorinstances", + wantQuery: map[string]string{"connector": "stripe", "limit": "5", "continue": "after"}, responseStatus: http.StatusOK, responseBody: `{"items":[]}`, call: func(client Client) error { - _, err := client.ListInstances(context.Background(), ListOptions{Plugin: "stripe", Limit: 5, Continue: "after"}) + _, err := client.ListConnectorInstances(context.Background(), ListOptions{Connector: "stripe", Limit: 5, Continue: "after"}) return err }, }, { - name: "creates an instance", + name: "creates a connector instance", wantMethod: http.MethodPost, - wantPath: "/base/api/connectivity/instances", + wantPath: "/base/api/connectivity/connectorinstances", wantContentType: "application/json", - wantBody: `{"name":"worker","spec":{"plugin":"stripe","ledger":"ledger"}}`, + wantBody: `{"name":"worker","spec":{"connector":"stripe","ledger":"ledger"}}`, responseStatus: http.StatusCreated, - responseBody: `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + responseBody: `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`, call: func(client Client) error { - _, err := client.CreateInstance(context.Background(), InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "ledger"}}) + _, err := client.CreateConnectorInstance(context.Background(), ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "ledger"}}) return err }, }, { - name: "gets an instance using an escaped name", + name: "gets a connector instance using an escaped name", wantMethod: http.MethodGet, - wantPath: "/base/api/connectivity/instances/worker%2Fone", + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", responseStatus: http.StatusOK, - responseBody: `{"metadata":{"name":"worker/one"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"ledger"}}`, call: func(client Client) error { - _, err := client.GetInstance(context.Background(), instanceName) + _, err := client.GetConnectorInstance(context.Background(), instanceName) return err }, }, { - name: "patches an instance", + name: "patches a connector instance", wantMethod: http.MethodPatch, - wantPath: "/base/api/connectivity/instances/worker%2Fone", + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", wantContentType: "application/merge-patch+json", wantBody: `{"spec":{"pollInterval":"1m"}}`, responseStatus: http.StatusOK, - responseBody: `{"metadata":{"name":"worker/one"},"spec":{"plugin":"stripe","ledger":"ledger"}}`, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"ledger"}}`, call: func(client Client) error { - _, err := client.PatchInstance(context.Background(), instanceName, InstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + _, err := client.PatchConnectorInstance(context.Background(), instanceName, ConnectorInstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) return err }, }, { - name: "deletes an instance", + name: "deletes a connector instance", wantMethod: http.MethodDelete, - wantPath: "/base/api/connectivity/instances/worker%2Fone", + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", responseStatus: http.StatusNoContent, call: func(client Client) error { - return client.DeleteInstance(context.Background(), instanceName) + return client.DeleteConnectorInstance(context.Background(), instanceName) }, }, } @@ -170,40 +192,42 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { func TestClientReturnsStructuredAPIError(t *testing.T) { httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, `{"code":"invalid","message":"plugin is required","details":{"field":"plugin"}}`), nil + return jsonResponse(http.StatusBadRequest, `{"code":"invalid","message":"connector is required","details":{"field":"connector"}}`), nil })} - _, err := New("https://stack.example", httpClient).GetPlugin(context.Background(), "missing") + _, err := New("https://stack.example", httpClient).GetConnector(context.Background(), "missing") var apiErr *APIError require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusBadRequest, apiErr.StatusCode) require.Equal(t, "invalid", apiErr.Code) - require.Equal(t, "plugin is required", apiErr.Message) - require.Equal(t, map[string]any{"field": "plugin"}, apiErr.Details) + require.Equal(t, "connector is required", apiErr.Message) + require.Equal(t, map[string]any{"field": "connector"}, apiErr.Details) } -func TestPatchInstanceAdaptsAPIConfigToPinnedCRDWireShape(t *testing.T) { +// The API owns the CRD reshaping (spec.config -> spec.env/spec.files), so the +// client must put the patch on the wire in the documented API shape. +func TestPatchConnectorInstanceSendsAPIShapedConfig(t *testing.T) { var seenBody []byte httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { var err error seenBody, err = io.ReadAll(req.Body) require.NoError(t, err) - return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"main"}}`), nil + return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`), nil })} password := "env-password" privateKey := "file-password" - config := &InstanceConfig{ + config := &ConnectorInstanceConfig{ Env: map[string]EnvValue{ "API_PASSWORD": {Value: &password}, }, Files: []FileMount{ {Path: "/etc/plugin/key.pem", Value: &privateKey}, - {Path: "/etc/plugin/config.json", ConfigMapRef: &KeyRef{Name: "plugin-config", Key: "config.json"}}, + {Path: "/etc/plugin/config.json", ConfigMapRef: &KeyRef{Name: "connector-config", Key: "config.json"}}, }, } - _, err := New("https://stack.example", httpClient).PatchInstance(context.Background(), "worker", InstancePatch{ + _, err := New("https://stack.example", httpClient).PatchConnectorInstance(context.Background(), "worker", ConnectorInstancePatch{ "spec": map[string]any{ "config": config, "ledger": "main", @@ -214,16 +238,17 @@ func TestPatchInstanceAdaptsAPIConfigToPinnedCRDWireShape(t *testing.T) { require.NoError(t, err) require.JSONEq(t, `{ "spec": { - "env": {"API_PASSWORD": {"value": "env-password"}}, - "files": [ - {"path": "/etc/plugin/key.pem", "value": "file-password"}, - {"path": "/etc/plugin/config.json", "configMapRef": {"name": "plugin-config", "key": "config.json"}} - ], + "config": { + "env": {"API_PASSWORD": {"value": "env-password"}}, + "files": [ + {"path": "/etc/plugin/key.pem", "value": "file-password"}, + {"path": "/etc/plugin/config.json", "configMapRef": {"name": "connector-config", "key": "config.json"}} + ] + }, "ledger": "main", "pollInterval": "15s" } }`, string(seenBody)) - require.NotContains(t, string(seenBody), `"config"`) } func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { @@ -236,31 +261,39 @@ func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { name: "malformed JSON", body: `{`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "empty connector object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") return err }, }, { - name: "empty plugin object", + name: "empty connector list object", body: `{}`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.ListConnectors(context.Background(), ListOptions{}) return err }, }, { - name: "empty plugin list object", + name: "empty connector version list object", body: `{}`, call: func(client Client) error { - _, err := client.ListPlugins(context.Background(), ListOptions{}) + _, err := client.ListConnectorVersions(context.Background(), "stripe") return err }, }, { - name: "empty instance list object", + name: "empty connector instance list object", body: "null", call: func(client Client) error { - _, err := client.ListInstances(context.Background(), ListOptions{}) + _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) return err }, }, @@ -287,92 +320,101 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { call func(Client) error }{ { - name: "plugin root is not an object", + name: "connector root is not an object", status: http.StatusOK, body: `[]`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.GetConnector(context.Background(), "stripe") return err }, }, { - name: "whitespace-only plugin object", + name: "whitespace-only connector object", status: http.StatusOK, body: "{ \n\t }", call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.GetConnector(context.Background(), "stripe") return err }, }, { - name: "plugin list missing items", + name: "connector list missing items", status: http.StatusOK, body: `{"continue":"next"}`, call: func(client Client) error { - _, err := client.ListPlugins(context.Background(), ListOptions{}) + _, err := client.ListConnectors(context.Background(), ListOptions{}) return err }, }, { - name: "instance list has null items", + name: "connector instance list has null items", status: http.StatusOK, body: `{"items":null}`, call: func(client Client) error { - _, err := client.ListInstances(context.Background(), ListOptions{}) + _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) return err }, }, { - name: "plugin missing metadata name", + name: "connector missing metadata name", status: http.StatusOK, - body: `{"metadata":{},"spec":{"image":"image"}}`, + body: `{"metadata":{},"spec":{"displayName":"Stripe"}}`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.GetConnector(context.Background(), "stripe") return err }, }, { - name: "plugin missing image", + name: "connector list item missing metadata name", status: http.StatusOK, - body: `{"metadata":{"name":"stripe"},"spec":{}}`, + body: `{"items":[{"metadata":{},"spec":{}}]}`, call: func(client Client) error { - _, err := client.GetPlugin(context.Background(), "stripe") + _, err := client.ListConnectors(context.Background(), ListOptions{}) return err }, }, { - name: "plugin list item missing required fields", + name: "connector version missing image", status: http.StatusOK, - body: `{"items":[{"metadata":{"name":"stripe"},"spec":{}}]}`, + body: `{"version":"v1.0.0"}`, call: func(client Client) error { - _, err := client.ListPlugins(context.Background(), ListOptions{}) + _, err := client.GetConnectorVersion(context.Background(), "stripe", "v1.0.0") return err }, }, { - name: "instance missing metadata name", + name: "connector version list item missing version", status: http.StatusOK, - body: `{"metadata":{},"spec":{"plugin":"stripe","ledger":"main"}}`, + body: `{"items":[{"image":"registry/stripe:v1.0.0"}]}`, call: func(client Client) error { - _, err := client.GetInstance(context.Background(), "worker") + _, err := client.ListConnectorVersions(context.Background(), "stripe") return err }, }, { - name: "instance missing plugin", + name: "connector instance missing metadata name", + status: http.StatusOK, + body: `{"metadata":{},"spec":{"connector":"stripe","ledger":"main"}}`, + call: func(client Client) error { + _, err := client.GetConnectorInstance(context.Background(), "worker") + return err + }, + }, + { + name: "connector instance missing connector", status: http.StatusCreated, body: `{"metadata":{"name":"worker"},"spec":{"ledger":"main"}}`, call: func(client Client) error { - _, err := client.CreateInstance(context.Background(), InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "main"}}) + _, err := client.CreateConnectorInstance(context.Background(), ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "main"}}) return err }, }, { - name: "instance list item missing ledger", + name: "connector instance list item missing ledger", status: http.StatusOK, - body: `{"items":[{"metadata":{"name":"worker"},"spec":{"plugin":"stripe"}}]}`, + body: `{"items":[{"metadata":{"name":"worker"},"spec":{"connector":"stripe"}}]}`, call: func(client Client) error { - _, err := client.ListInstances(context.Background(), ListOptions{}) + _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) return err }, }, @@ -397,10 +439,13 @@ func TestClientAcceptsValidEmptyLists(t *testing.T) { })} client := New("https://stack.example", httpClient) - plugins, err := client.ListPlugins(context.Background(), ListOptions{}) + connectors, err := client.ListConnectors(context.Background(), ListOptions{}) + require.NoError(t, err) + require.Empty(t, connectors.Items) + versions, err := client.ListConnectorVersions(context.Background(), "stripe") require.NoError(t, err) - require.Empty(t, plugins.Items) - instances, err := client.ListInstances(context.Background(), ListOptions{}) + require.Empty(t, versions.Items) + instances, err := client.ListConnectorInstances(context.Background(), ListOptions{}) require.NoError(t, err) require.Empty(t, instances.Items) } diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go index 740b70cc..25b95cc0 100644 --- a/internal/connectivityclient/integration_test.go +++ b/internal/connectivityclient/integration_test.go @@ -16,23 +16,27 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") switch { - case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/plugins": + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors": require.Equal(t, "application/json", req.Header.Get("Accept")) - _, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"stripe"},"spec":{"image":"image"}}]}`) - case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/plugins/stripe": - _, _ = io.WriteString(w, `{"metadata":{"name":"stripe"},"spec":{"image":"image"}}`) - case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/instances": + _, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}]}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe": + _, _ = io.WriteString(w, `{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions": + _, _ = io.WriteString(w, `{"items":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions/v1.0.0": + _, _ = io.WriteString(w, `{"version":"v1.0.0","image":"registry/stripe:v1.0.0","configSchema":{"env":{"properties":{}}}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectorinstances": _, _ = io.WriteString(w, `{"items":[]}`) - case req.Method == http.MethodPost && req.URL.Path == "/api/connectivity/instances": + case req.Method == http.MethodPost && req.URL.Path == "/api/connectivity/connectorinstances": require.Equal(t, "application/json", req.Header.Get("Content-Type")) w.WriteHeader(http.StatusCreated) - _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) - case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/instances/worker": - _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) - case req.Method == http.MethodPatch && req.URL.Path == "/api/connectivity/instances/worker": + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectorinstances/worker": + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodPatch && req.URL.Path == "/api/connectivity/connectorinstances/worker": require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) - _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"ledger"}}`) - case req.Method == http.MethodDelete && req.URL.Path == "/api/connectivity/instances/worker": + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodDelete && req.URL.Path == "/api/connectivity/connectorinstances/worker": w.WriteHeader(http.StatusNoContent) default: http.NotFound(w, req) @@ -44,33 +48,41 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected ctx := context.Background() // When lifecycle methods use the server. - plugins, err := client.ListPlugins(ctx, ListOptions{}) + connectors, err := client.ListConnectors(ctx, ListOptions{}) require.NoError(t, err) - plugin, err := client.GetPlugin(ctx, "stripe") + connector, err := client.GetConnector(ctx, "stripe") require.NoError(t, err) - instances, err := client.ListInstances(ctx, ListOptions{}) + versions, err := client.ListConnectorVersions(ctx, "stripe") require.NoError(t, err) - created, err := client.CreateInstance(ctx, InstanceCreate{Name: "worker", Spec: InstanceSpec{Plugin: "stripe", Ledger: "ledger"}}) + version, err := client.GetConnectorVersion(ctx, "stripe", "v1.0.0") require.NoError(t, err) - fetched, err := client.GetInstance(ctx, "worker") + instances, err := client.ListConnectorInstances(ctx, ListOptions{}) require.NoError(t, err) - patched, err := client.PatchInstance(ctx, "worker", InstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + created, err := client.CreateConnectorInstance(ctx, ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "ledger"}}) require.NoError(t, err) - err = client.DeleteInstance(ctx, "worker") + fetched, err := client.GetConnectorInstance(ctx, "worker") + require.NoError(t, err) + patched, err := client.PatchConnectorInstance(ctx, "worker", ConnectorInstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + require.NoError(t, err) + err = client.DeleteConnectorInstance(ctx, "worker") require.NoError(t, err) // Then each response is decoded from the API contract. - require.Len(t, plugins.Items, 1) - require.Equal(t, "stripe", *plugins.Items[0].Metadata.Name) - require.Equal(t, "stripe", *plugin.Metadata.Name) + require.Len(t, connectors.Items, 1) + require.Equal(t, "stripe", *connectors.Items[0].Metadata.Name) + require.Equal(t, "stripe", *connector.Metadata.Name) + require.Len(t, versions.Items, 1) + require.Equal(t, "v1.0.0", versions.Items[0].Version) + require.Equal(t, "v1.0.0", version.Version) + require.NotEmpty(t, version.ConfigSchema) require.Empty(t, instances.Items) require.Equal(t, "worker", *created.Metadata.Name) require.Equal(t, "worker", *fetched.Metadata.Name) require.Equal(t, "worker", *patched.Metadata.Name) } -func TestGivenPinnedConnectivityPatchHandler_WhenAPIConfigIsPatched_ThenCRDShapeIsSent(t *testing.T) { - // Given a server that applies the PATCH body directly to the pinned Instance CRD. +func TestGivenConnectivityAPI_WhenConfigIsPatched_ThenAPIShapeIsSent(t *testing.T) { + // Given a server asserting the documented ConnectorInstancePatch shape. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { require.Equal(t, http.MethodPatch, req.Method) require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) @@ -79,18 +91,21 @@ func TestGivenPinnedConnectivityPatchHandler_WhenAPIConfigIsPatched_ThenCRDShape require.NoError(t, json.NewDecoder(req.Body).Decode(&patch)) spec, ok := patch["spec"].(map[string]any) require.True(t, ok) - require.NotContains(t, spec, "config") + require.NotContains(t, spec, "env") + require.NotContains(t, spec, "files") require.Equal(t, "30s", spec["pollInterval"]) + config, ok := spec["config"].(map[string]any) + require.True(t, ok) require.Equal(t, map[string]any{ "API_PASSWORD": map[string]any{"value": "env-password"}, - }, spec["env"]) + }, config["env"]) require.Equal(t, []any{ map[string]any{"path": "/etc/plugin/key.pem", "value": "file-password"}, map[string]any{"path": "/etc/plugin/config.json", "value": "complete-file-array"}, - }, spec["files"]) + }, config["files"]) w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"plugin":"stripe","ledger":"main"}}`) + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`) })) defer server.Close() @@ -100,9 +115,9 @@ func TestGivenPinnedConnectivityPatchHandler_WhenAPIConfigIsPatched_ThenCRDShape otherFile := "complete-file-array" // When an API-shaped config patch contains password-compatible inline values. - _, err := client.PatchInstance(context.Background(), "worker", InstancePatch{ + _, err := client.PatchConnectorInstance(context.Background(), "worker", ConnectorInstancePatch{ "spec": map[string]any{ - "config": &InstanceConfig{ + "config": &ConnectorInstanceConfig{ Env: map[string]EnvValue{ "API_PASSWORD": {Value: &envPassword}, }, @@ -115,6 +130,6 @@ func TestGivenPinnedConnectivityPatchHandler_WhenAPIConfigIsPatched_ThenCRDShape }, }) - // Then the client sends root CRD env/files fields for materialisation and preserves the full files array. + // Then the API receives spec.config verbatim and preserves the full files array. require.NoError(t, err) } diff --git a/internal/connectivityclient/models.go b/internal/connectivityclient/models.go index 60b775ef..41211afe 100644 --- a/internal/connectivityclient/models.go +++ b/internal/connectivityclient/models.go @@ -35,90 +35,104 @@ type FileMount struct { Mode *int32 `json:"mode,omitempty"` } -type InstanceConfig struct { - Env map[string]EnvValue `json:"env,omitempty"` - Files []FileMount `json:"files,omitempty"` +type ConnectorSpec struct { + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` + Catalog *string `json:"catalog,omitempty"` + Tags []string `json:"tags,omitempty"` } -type VersionEntry struct { - Version string `json:"version"` - Digest *string `json:"digest,omitempty"` - Image *string `json:"image,omitempty"` +type ConnectorStatus struct { + Phase *string `json:"phase,omitempty"` + Message *string `json:"message,omitempty"` } -type PluginSpec struct { - Image string `json:"image"` - Version *string `json:"version,omitempty"` - Description *string `json:"description,omitempty"` - DocsURL *string `json:"docsURL,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - ConfigSchema map[string]any `json:"configSchema,omitempty"` - DefaultVersion *string `json:"defaultVersion,omitempty"` - Versions []VersionEntry `json:"versions,omitempty"` - Defaults *InstanceConfig `json:"defaults,omitempty"` +type Connector struct { + Metadata ObjectMeta `json:"metadata"` + Spec ConnectorSpec `json:"spec"` + Status *ConnectorStatus `json:"status,omitempty"` } -type PluginStatus struct { - Phase *string `json:"phase,omitempty"` - Message *string `json:"message,omitempty"` +type ConnectorList struct { + Items []Connector `json:"items"` + Continue string `json:"continue,omitempty"` } -type Plugin struct { - Metadata ObjectMeta `json:"metadata"` - Spec PluginSpec `json:"spec"` - Status *PluginStatus `json:"status,omitempty"` +type ConnectorVersionSummary struct { + Version string `json:"version"` + Image string `json:"image"` + Digest *string `json:"digest,omitempty"` + ReleaseDate *time.Time `json:"releaseDate,omitempty"` } -type PluginList struct { - Items []Plugin `json:"items"` - Continue string `json:"continue,omitempty"` +type ConnectorVersion struct { + Version string `json:"version"` + Image string `json:"image"` + Digest *string `json:"digest,omitempty"` + ReleaseDate *time.Time `json:"releaseDate,omitempty"` + ConfigSchema map[string]any `json:"configSchema,omitempty"` + AdditionalMetadata map[string]any `json:"additionalMetadata,omitempty"` +} + +type ConnectorVersionList struct { + Items []ConnectorVersionSummary `json:"items"` +} + +type ConnectorInstanceConfig struct { + Env map[string]EnvValue `json:"env,omitempty"` + Files []FileMount `json:"files,omitempty"` } -type InstanceSpec struct { - Plugin string `json:"plugin"` - Version *string `json:"version,omitempty"` - ConnectivityRef *string `json:"connectivityRef,omitempty"` - Ledger string `json:"ledger"` - StartSequence *int64 `json:"startSequence,omitempty"` - PollInterval *string `json:"pollInterval,omitempty"` - Config *InstanceConfig `json:"config,omitempty"` +type ConnectorInstanceSpec struct { + Connector string `json:"connector"` + Version *string `json:"version,omitempty"` + Channel *string `json:"channel,omitempty"` + ConnectivityRef *string `json:"connectivityRef,omitempty"` + Ledger string `json:"ledger"` + StartSequence *int64 `json:"startSequence,omitempty"` + PollInterval *string `json:"pollInterval,omitempty"` + Config *ConnectorInstanceConfig `json:"config,omitempty"` } -type InstanceStatus struct { - Phase *string `json:"phase,omitempty"` - State *string `json:"state,omitempty"` - PluginAddress *string `json:"pluginAddress,omitempty"` - ResolvedImage *string `json:"resolvedImage,omitempty"` - CurrentSequence *int64 `json:"currentSequence,omitempty"` - SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` - LastError *string `json:"lastError,omitempty"` - Message *string `json:"message,omitempty"` +type ConnectorInstanceStatus struct { + Phase *string `json:"phase,omitempty"` + State *string `json:"state,omitempty"` + ConnectorAddress *string `json:"connectorAddress,omitempty"` + ResolvedImage *string `json:"resolvedImage,omitempty"` + ResolvedConnectorRef *string `json:"resolvedConnectorRef,omitempty"` + ResolvedVersion *string `json:"resolvedVersion,omitempty"` + ResolvedDigest *string `json:"resolvedDigest,omitempty"` + CurrentSequence *int64 `json:"currentSequence,omitempty"` + SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` + LastError *string `json:"lastError,omitempty"` + Message *string `json:"message,omitempty"` } -type Instance struct { - Metadata ObjectMeta `json:"metadata"` - Spec InstanceSpec `json:"spec"` - Status *InstanceStatus `json:"status,omitempty"` +type ConnectorInstance struct { + Metadata ObjectMeta `json:"metadata"` + Spec ConnectorInstanceSpec `json:"spec"` + Status *ConnectorInstanceStatus `json:"status,omitempty"` } -type InstanceList struct { - Items []Instance `json:"items"` - Continue string `json:"continue,omitempty"` +type ConnectorInstanceList struct { + Items []ConnectorInstance `json:"items"` + Continue string `json:"continue,omitempty"` } -type InstanceCreate struct { - Name string `json:"name"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - Spec InstanceSpec `json:"spec"` +type ConnectorInstanceCreate struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Spec ConnectorInstanceSpec `json:"spec"` } -type InstancePatch map[string]any +type ConnectorInstancePatch map[string]any type ListOptions struct { - Limit int32 - Continue string - Plugin string + Limit int32 + Continue string + Connector string } type APIError struct { @@ -129,13 +143,15 @@ type APIError struct { } type Client interface { - ListPlugins(context.Context, ListOptions) (*PluginList, error) - GetPlugin(context.Context, string) (*Plugin, error) - ListInstances(context.Context, ListOptions) (*InstanceList, error) - CreateInstance(context.Context, InstanceCreate) (*Instance, error) - GetInstance(context.Context, string) (*Instance, error) - PatchInstance(context.Context, string, InstancePatch) (*Instance, error) - DeleteInstance(context.Context, string) error + ListConnectors(context.Context, ListOptions) (*ConnectorList, error) + GetConnector(context.Context, string) (*Connector, error) + ListConnectorVersions(context.Context, string) (*ConnectorVersionList, error) + GetConnectorVersion(context.Context, string, string) (*ConnectorVersion, error) + ListConnectorInstances(context.Context, ListOptions) (*ConnectorInstanceList, error) + CreateConnectorInstance(context.Context, ConnectorInstanceCreate) (*ConnectorInstance, error) + GetConnectorInstance(context.Context, string) (*ConnectorInstance, error) + PatchConnectorInstance(context.Context, string, ConnectorInstancePatch) (*ConnectorInstance, error) + DeleteConnectorInstance(context.Context, string) error } func New(stackURI string, httpClient *http.Client) Client { From d9775ff74f6dd4abe34c1ea6e8417003dc216a03 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:13:46 +0200 Subject: [PATCH 15/21] feat(connectivity): support read model --- .../connectorinstances/completion.go | 62 ++-- .../connectorinstances/completion_test.go | 20 +- .../connectorinstances/configure_test.go | 4 +- .../connectorinstances/install.go | 26 +- .../connectorinstances/install_test.go | 65 +++- cmd/connectivity/connectorinstances/list.go | 40 ++- .../connectorinstances/list_test.go | 62 +++- .../connectorinstances/test_helpers_test.go | 6 +- .../connectorinstances/uninstall_test.go | 2 +- .../connectorinstances/version.go | 20 +- cmd/connectivity/connectors/completion.go | 34 +-- .../connectors/completion_test.go | 29 +- cmd/connectivity/connectors/facets.go | 113 +++++++ cmd/connectivity/connectors/facets_test.go | 111 +++++++ cmd/connectivity/connectors/list.go | 33 +- cmd/connectivity/connectors/list_test.go | 64 +++- cmd/connectivity/connectors/root.go | 1 + cmd/connectivity/connectors/show.go | 48 ++- cmd/connectivity/connectors/show_test.go | 108 ++++++- .../connectors/test_helpers_test.go | 23 +- cmd/connectivity/internal/filtercompletion.go | 193 ++++++++++++ .../internal/filtercompletion_test.go | 282 ++++++++++++++++++ cmd/connectivity/internal/paging.go | 25 ++ cmd/connectivity/internal/query.go | 109 +++++++ cmd/connectivity/internal/query_test.go | 76 +++++ internal/connectivityclient/client.go | 133 ++++++--- internal/connectivityclient/client_test.go | 213 +++++++++++-- .../connectivityclient/integration_test.go | 24 +- internal/connectivityclient/models.go | 70 ++++- 29 files changed, 1760 insertions(+), 236 deletions(-) create mode 100644 cmd/connectivity/connectors/facets.go create mode 100644 cmd/connectivity/connectors/facets_test.go create mode 100644 cmd/connectivity/internal/filtercompletion.go create mode 100644 cmd/connectivity/internal/filtercompletion_test.go create mode 100644 cmd/connectivity/internal/paging.go create mode 100644 cmd/connectivity/internal/query.go create mode 100644 cmd/connectivity/internal/query_test.go diff --git a/cmd/connectivity/connectorinstances/completion.go b/cmd/connectivity/connectorinstances/completion.go index ddb688e3..ae7e6932 100644 --- a/cmd/connectivity/connectorinstances/completion.go +++ b/cmd/connectivity/connectorinstances/completion.go @@ -6,7 +6,6 @@ import ( "path/filepath" "sort" "strings" - "time" "github.com/spf13/cobra" @@ -14,10 +13,7 @@ import ( connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) -const ( - instanceCompletionLimit = int32(500) - instanceCompletionTimeout = 2 * time.Second -) +const instanceCompletionMaxPages = 5 type PathCompleter func(prefix string) ([]string, error) @@ -30,18 +26,26 @@ type ConnectorVersionResolver func(context.Context, connectivityclient.Client, * func CompleteConnectorInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - client, completionCommand, cancel, ok := completionClient(cmd, factory) + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) if !ok { return nil, cobra.ShellCompDirectiveNoFileComp } defer cancel() - response, err := client.ListConnectorInstances(completionCommand.Context(), connectivityclient.ListOptions{Limit: instanceCompletionLimit}) - if err != nil || response == nil { + instances, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorInstance, bool, string, error) { + page, err := client.ListConnectorInstances(completionCommand.Context(), options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - candidates := make([]string, 0, len(response.Items)) - for _, instance := range response.Items { + + candidates := make([]string, 0, len(instances)) + for _, instance := range instances { name := stringValue(instance.Metadata.Name) if name == "" || !strings.HasPrefix(name, toComplete) { continue @@ -71,7 +75,7 @@ func completeVersions(factory connectivityinternal.ClientFactory, resolveConnect if resolveConnector == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - client, completionCommand, cancel, ok := completionClient(cmd, factory) + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) if !ok { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -81,12 +85,19 @@ func completeVersions(factory connectivityinternal.ClientFactory, resolveConnect if err != nil || connector == "" { return nil, cobra.ShellCompDirectiveNoFileComp } - versions, err := client.ListConnectorVersions(completionCommand.Context(), connector) - if err != nil || versions == nil { + versions, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(completionCommand.Context(), connector, options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - candidates := make([]string, 0, len(versions.Items)) - for _, version := range versions.Items { + candidates := make([]string, 0, len(versions)) + for _, version := range versions { if version.Version == "" || !strings.HasPrefix(version.Version, toComplete) { continue } @@ -134,7 +145,7 @@ func CompleteSetValues( if resolveVersion == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - client, completionCommand, cancel, ok := completionClient(cmd, factory) + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) if !ok { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -204,25 +215,6 @@ func OSPathCompleter(prefix string) ([]string, error) { return candidates, nil } -func completionClient(cmd *cobra.Command, factory connectivityinternal.ClientFactory) (connectivityclient.Client, *cobra.Command, context.CancelFunc, bool) { - if factory == nil { - return nil, nil, func() {}, false - } - parent := cmd.Context() - if parent == nil { - parent = context.Background() - } - ctx, cancel := context.WithTimeout(parent, instanceCompletionTimeout) - completionCommand := *cmd - completionCommand.SetContext(connectivityinternal.WithNonInteractive(ctx)) - client, err := factory(&completionCommand) - if err != nil || client == nil { - cancel() - return nil, nil, func() {}, false - } - return client, &completionCommand, cancel, true -} - func suppliedSetKeys(cmd *cobra.Command, args []string) map[string]bool { values := append([]string(nil), args...) if cmd != nil && cmd.Flags().Lookup("set") != nil { diff --git a/cmd/connectivity/connectorinstances/completion_test.go b/cmd/connectivity/connectorinstances/completion_test.go index 6899338a..072695df 100644 --- a/cmd/connectivity/connectorinstances/completion_test.go +++ b/cmd/connectivity/connectorinstances/completion_test.go @@ -51,8 +51,8 @@ func TestCompleteConnectorInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsS candidates, directive := completion(&cobra.Command{}, nil, "al") - if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { - t.Fatalf("ListConnectorInstances options = %#v, want limit 500", gotOptions) + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{PageSize: 100}) { + t.Fatalf("ListConnectorInstances options = %#v, want page size 100", gotOptions) } if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) @@ -69,10 +69,13 @@ func TestCompleteConnectorInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsS func TestCompleteVersionsUsesSelectedConnectorAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { var gotName string client := completionClientMock{connectorVersions: connectorVersions{ - listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(ctx context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { if !hasTwoSecondDeadline(ctx) { t.Fatal("version completion context does not have the expected deadline") } + if options.PageSize != 100 { + t.Fatalf("ListConnectorVersions page size = %d, want 100", options.PageSize) + } gotName = name return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ {Version: "2.1.0", Image: "", Digest: stringPtr("sha256:two")}, @@ -174,7 +177,7 @@ func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors return nil, errors.New("unsupported deployment") }, connectorVersions: connectorVersions{ - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { return nil, errors.New("unsupported deployment") }, }, @@ -194,6 +197,15 @@ func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors command: &cobra.Command{}, }, "instance API": {completion: CompleteConnectorInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, + "instance pagination": { + completion: CompleteConnectorInstanceNames(factoryReturning(completionClientMock{listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + if options.Cursor != "" { + return nil, errors.New("second page unavailable") + } + return &connectivityclient.ConnectorInstanceList{HasMore: true, Next: "page-two"}, nil + }})), + command: &cobra.Command{}, + }, "instance timeout": { completion: CompleteConnectorInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { <-ctx.Done() diff --git a/cmd/connectivity/connectorinstances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go index e98aa9b9..0c94f046 100644 --- a/cmd/connectivity/connectorinstances/configure_test.go +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -333,7 +333,7 @@ func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { var pathPrefix string client := configureClientMock{ listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { - require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{current}}, nil }, getInstance: func(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { @@ -342,7 +342,7 @@ func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { return ¤t, nil }, connectorVersions: connectorVersions{ - listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(ctx context.Context, name string, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { require.True(t, connectivityinternal.IsNonInteractive(ctx)) require.Equal(t, "stripe", name) return versionListFixture(), nil diff --git a/cmd/connectivity/connectorinstances/install.go b/cmd/connectivity/connectorinstances/install.go index 0f21a388..2da6d6ae 100644 --- a/cmd/connectivity/connectorinstances/install.go +++ b/cmd/connectivity/connectorinstances/install.go @@ -16,6 +16,7 @@ const ( nameFlag = "name" ledgerFlag = "ledger" versionFlag = "version" + channelFlag = "channel" pollIntervalFlag = "poll-interval" configFlag = "config" envFileFlag = "env-file" @@ -57,6 +58,7 @@ func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFile fctl.WithStringFlag(nameFlag, "", "Connector instance name (defaults to the connector name)"), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), fctl.WithStringFlag(versionFlag, "", "Connector version"), + fctl.WithStringFlag(channelFlag, "", "Maturity channel to track (stable, rc, beta, alpha) when no version is pinned"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -70,6 +72,9 @@ func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFile if err := command.RegisterFlagCompletionFunc(versionFlag, CompleteVersions(factory, installConnectorArgument)); err != nil { panic(err) } + if err := command.RegisterFlagCompletionFunc(channelFlag, connectivityinternal.CompleteChannels(factory)); err != nil { + panic(err) + } if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveInstallConnectorVersion, paths)); err != nil { panic(err) } @@ -87,7 +92,17 @@ func resolveInstallConnectorVersion(ctx context.Context, client connectivityclie if len(args) == 0 { return nil, nil } - return resolveConnectorVersion(ctx, client, args[0], fctl.GetString(cmd, versionFlag)) + return resolveConnectorVersion(ctx, client, args[0], installVersionSelector(cmd)) +} + +// installVersionSelector picks the {version} slot value governing the config +// schema: the explicit pin, else the tracked channel alias (resolved with +// exactly the rules installation uses), else empty for `latest`. +func installVersionSelector(cmd *cobra.Command) string { + if pinned := fctl.GetString(cmd, versionFlag); pinned != "" { + return pinned + } + return fctl.GetString(cmd, channelFlag) } func (c *InstallController) GetStore() *InstallStore { @@ -104,7 +119,7 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera } connectorName := args[0] - version, err := resolveConnectorVersion(cmd.Context(), client, connectorName, fctl.GetString(cmd, versionFlag)) + version, err := resolveConnectorVersion(cmd.Context(), client, connectorName, installVersionSelector(cmd)) if err != nil { return nil, err } @@ -145,6 +160,13 @@ func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Rendera } spec.Version = fctl.Ptr(value) } + if cmd.Flags().Changed(channelFlag) { + value, err := cmd.Flags().GetString(channelFlag) + if err != nil { + return nil, err + } + spec.Channel = fctl.Ptr(value) + } if cmd.Flags().Changed(pollIntervalFlag) { value, err := cmd.Flags().GetString(pollIntervalFlag) if err != nil { diff --git a/cmd/connectivity/connectorinstances/install_test.go b/cmd/connectivity/connectorinstances/install_test.go index 1abe5c3a..6a6903e3 100644 --- a/cmd/connectivity/connectorinstances/install_test.go +++ b/cmd/connectivity/connectorinstances/install_test.go @@ -19,6 +19,7 @@ import ( type mutationClientMock struct { connectorVersions listConnectors func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + capabilities func(context.Context) (*connectivityclient.QueryCapabilities, error) create func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) } @@ -26,6 +27,10 @@ func (m mutationClientMock) ListConnectors(ctx context.Context, options connecti return m.listConnectors(ctx, options) } +func (m mutationClientMock) GetQueryCapabilities(ctx context.Context) (*connectivityclient.QueryCapabilities, error) { + return m.capabilities(ctx) +} + func (m mutationClientMock) CreateConnectorInstance(ctx context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { return m.create(ctx, body) } @@ -73,7 +78,7 @@ func TestInstallBuildsConnectorInstanceFromVersionSchemaAndOnlySetsChangedScalar require.Equal(t, `Connector instance "stripe-eu" installed with connector "stripe" for ledger "main".`, strings.TrimSpace(output)) } -func TestInstallWithoutVersionFlagUsesNewestPublishedVersionForTheSchema(t *testing.T) { +func TestInstallWithoutVersionFlagUsesServerResolvedLatestForTheSchema(t *testing.T) { var gotVersion string var gotBody connectivityclient.ConnectorInstanceCreate client := mutationClientMock{ @@ -95,14 +100,42 @@ func TestInstallWithoutVersionFlagUsesNewestPublishedVersionForTheSchema(t *test ) require.NoError(t, err) - require.Equal(t, "2.0.0", gotVersion) + require.Equal(t, connectivityclient.VersionAliasLatest, gotVersion) require.Nil(t, gotBody.Spec.Version, "an unpinned install must let the server resolve the version") } +func TestInstallWithChannelFlagTracksTheChannelAndUsesItsHeadForTheSchema(t *testing.T) { + var gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--channel=stable", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "stable", gotVersion, "the channel alias resolves the schema exactly as installation will") + require.NotNil(t, gotBody.Spec.Channel) + require.Equal(t, "stable", *gotBody.Spec.Channel) + require.Nil(t, gotBody.Spec.Version) +} + func TestInstallReportsConnectorsWithoutAPublishedVersion(t *testing.T) { client := mutationClientMock{connectorVersions: connectorVersions{ - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { - return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, &connectivityclient.APIError{StatusCode: 404, Code: "not_found", Message: "no Validated version"} }, }} @@ -196,7 +229,7 @@ func TestInstallPreservesVersionAndCreateAPIErrorsAndRejectsEmptyResponses(t *te }{ "version API": { client: mutationClientMock{connectorVersions: connectorVersions{ - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, versionError }, }}, @@ -279,6 +312,23 @@ func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) } +func TestInstallWiresChannelCompletionFromCapabilities(t *testing.T) { + client := mutationClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return &connectivityclient.QueryCapabilities{Resources: map[string]map[string]connectivityclient.QueryFieldCapability{ + connectivityclient.ResourceConnectorInstances: { + "channel": {Operators: []string{"$match"}, Enum: []string{"stable", "rc", "beta", "alpha"}}, + }, + }}, nil + }} + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + + channelCompletion, ok := command.GetFlagCompletionFunc("channel") + require.True(t, ok) + channels, directive := channelCompletion(command, nil, "s") + require.Equal(t, []string{"stable"}, channels) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) +} + func TestInstallDoesNotExposeUnsupportedStartSequence(t *testing.T) { command := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) @@ -292,13 +342,14 @@ func TestInstallWiresConnectorVersionSetAndFileCompletions(t *testing.T) { } client := mutationClientMock{ listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { - require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}}, nil }, connectorVersions: connectorVersions{ - listVersions: func(ctx context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(ctx context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { require.True(t, connectivityinternal.IsNonInteractive(ctx)) require.Equal(t, "stripe", name) + require.Equal(t, int32(100), options.PageSize) return versionListFixture(), nil }, getVersion: func(ctx context.Context, name, _ string) (*connectivityclient.ConnectorVersion, error) { diff --git a/cmd/connectivity/connectorinstances/list.go b/cmd/connectivity/connectorinstances/list.go index 3f7cc58d..d4f13447 100644 --- a/cmd/connectivity/connectorinstances/list.go +++ b/cmd/connectivity/connectorinstances/list.go @@ -7,6 +7,7 @@ import ( "github.com/pterm/pterm" "github.com/spf13/cobra" + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" fctl "github.com/formancehq/fctl/v3/pkg" @@ -35,17 +36,28 @@ func NewListController(factory connectivityinternal.ClientFactory) *ListControll func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { controller := NewListController(factory) - return fctl.NewCommand( + command := fctl.NewCommand( "list", fctl.WithAliases("ls", "l"), fctl.WithShortDescription("List Connectivity connector instances"), fctl.WithArgs(cobra.ExactArgs(0)), fctl.WithValidArgsFunction(cobra.NoFileCompletions), fctl.WithStringFlag(connectorFlag, "", "Filter connector instances by connector"), + connectivityinternal.WithListQueryFlags(), fctl.WithPageSizeFlag(), fctl.WithCursorFlag(), fctl.WithController[*ListStore](controller), ) + if err := command.RegisterFlagCompletionFunc(connectorFlag, connectors.CompleteConnectorNames(factory)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectorInstances), + ); err != nil { + panic(err) + } + return command } func (c *ListController) GetStore() *ListStore { @@ -68,11 +80,24 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e if err != nil { return nil, err } + filters, err := cmd.Flags().GetStringArray(connectivityinternal.FilterFlag) + if err != nil { + return nil, err + } + // --connector is sugar for one more filter clause, so it composes with + // --filter and conflicts with --query exactly like the other filters. + if connector := fctl.GetString(cmd, connectorFlag); connector != "" { + filters = append(filters, connectorFlag+"="+connector) + } + query, err := connectivityinternal.BuildListQuery(fctl.GetString(cmd, connectivityinternal.QueryFlag), filters) + if err != nil { + return nil, err + } response, err := client.ListConnectorInstances(cmd.Context(), connectivityclient.ListOptions{ - Connector: fctl.GetString(cmd, connectorFlag), - Limit: pageSize, - Continue: cursor, + Query: query, + PageSize: pageSize, + Cursor: cursor, }) if err != nil { return nil, err @@ -82,10 +107,9 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e } c.store.ConnectorInstances = response.Items - c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} - if response.Continue != "" { - c.store.Cursor.HasMore = true - c.store.Cursor.Next = fctl.Ptr(response.Continue) + c.store.Cursor = fctl.Cursor{PageSize: int64(response.PageSize), HasMore: response.HasMore} + if response.Next != "" { + c.store.Cursor.Next = fctl.Ptr(response.Next) } return c, nil } diff --git a/cmd/connectivity/connectorinstances/list_test.go b/cmd/connectivity/connectorinstances/list_test.go index 5b7e4f93..9b859aca 100644 --- a/cmd/connectivity/connectorinstances/list_test.go +++ b/cmd/connectivity/connectorinstances/list_test.go @@ -30,14 +30,21 @@ func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColum gotOptions = options return &connectivityclient.ConnectorInstanceList{ Items: []connectivityclient.ConnectorInstance{instanceFixture("stripe-eu")}, - Continue: "next-page", + PageSize: 7, + HasMore: true, + Next: "next-page", }, nil }} output, err := executeCommand(NewListCommand(factoryReturning(client)), "--connector", "stripe", "--page-size", "7", "--cursor", "current-page") require.NoError(t, err) - require.Equal(t, connectivityclient.ListOptions{Connector: "stripe", Limit: 7, Continue: "current-page"}, gotOptions) + wantOptions := connectivityclient.ListOptions{ + Query: `{"$match":{"connector":"stripe"}}`, + PageSize: 7, + Cursor: "current-page", + } + require.Equal(t, wantOptions, gotOptions) for _, expected := range []string{ "Name", "Connector", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", "stripe-eu", "stripe", "2.0.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", @@ -47,6 +54,55 @@ func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColum } } +func TestListConnectorInstancesConjoinsConnectorAndFilterExpressions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{}}, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--filter", "phase=Running", "--connector", "stripe") + + require.NoError(t, err) + want := `{"$and":[{"$match":{"phase":"Running"}},{"$match":{"connector":"stripe"}}]}` + require.Equal(t, want, gotOptions.Query) +} + +func TestListConnectorInstancesPassesRawQueryThrough(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{}}, nil + }} + raw := `{"$exists":{"channel":true}}` + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", raw) + + require.NoError(t, err) + require.Equal(t, raw, gotOptions.Query) +} + +func TestListConnectorInstancesRejectsRawQueryCombinedWithSugarFlags(t *testing.T) { + client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + t.Fatal("ListConnectorInstances must not run with conflicting flags") + return nil, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", `{"$match":{"phase":"Running"}}`, "--connector", "stripe") + + require.Error(t, err) + require.Contains(t, err.Error(), "--query cannot be combined with --filter") +} + +func TestListConnectorInstancesWiresFilterAndConnectorCompletions(t *testing.T) { + command := NewListCommand(factoryReturning(listInstanceClientMock{})) + + _, filterRegistered := command.GetFlagCompletionFunc("filter") + require.True(t, filterRegistered, "--filter must complete from /_query/capabilities") + _, connectorRegistered := command.GetFlagCompletionFunc("connector") + require.True(t, connectorRegistered, "--connector must complete from the connector catalogue") +} + func TestListConnectorInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { instance := instanceFixture("stripe-eu") instance.Metadata.Labels = map[string]string{"region": "eu"} @@ -54,7 +110,7 @@ func TestListConnectorInstancesJSONPreservesCompleteModelsAndContinuation(t *tes "API_KEY": {Value: stringPtr("json-keeps-full-model")}, }} client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { - return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}, Continue: "next-page"}, nil + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}, PageSize: 4, HasMore: true, Next: "next-page"}, nil }} command := NewListCommand(factoryReturning(client)) command.Flags().String(fctl.OutputFlag, "plain", "") diff --git a/cmd/connectivity/connectorinstances/test_helpers_test.go b/cmd/connectivity/connectorinstances/test_helpers_test.go index e0c443e0..da43c779 100644 --- a/cmd/connectivity/connectorinstances/test_helpers_test.go +++ b/cmd/connectivity/connectorinstances/test_helpers_test.go @@ -22,15 +22,15 @@ func stringPtr(value string) *string { // that does not care about configuration wants. type connectorVersions struct { connectivityclient.Client - listVersions func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) + listVersions func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) } -func (v connectorVersions) ListConnectorVersions(ctx context.Context, connector string) (*connectivityclient.ConnectorVersionList, error) { +func (v connectorVersions) ListConnectorVersions(ctx context.Context, connector string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { if v.listVersions == nil { return versionListFixture(), nil } - return v.listVersions(ctx, connector) + return v.listVersions(ctx, connector, options) } func (v connectorVersions) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { diff --git a/cmd/connectivity/connectorinstances/uninstall_test.go b/cmd/connectivity/connectorinstances/uninstall_test.go index 47c50f94..9a0dd79b 100644 --- a/cmd/connectivity/connectorinstances/uninstall_test.go +++ b/cmd/connectivity/connectorinstances/uninstall_test.go @@ -72,7 +72,7 @@ func TestUninstallRegistersAliasesRootAndConnectorInstanceCompletion(t *testing. client := uninstallClientMock{ listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { require.True(t, connectivityinternal.IsNonInteractive(ctx)) - require.Equal(t, connectivityclient.ListOptions{Limit: 500}, options) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}}, nil }, } diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go index ad7808ac..39e6ab61 100644 --- a/cmd/connectivity/connectorinstances/version.go +++ b/cmd/connectivity/connectorinstances/version.go @@ -2,30 +2,28 @@ package connectorinstances import ( "context" + "errors" "fmt" + "net/http" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) // resolveConnectorVersion returns the ConnectorVersion whose configSchema governs -// the configuration being assembled: the requested pin when there is one, the -// newest published version otherwise. The catalog serves versions ascending by -// semantic version, so the newest is the last item. +// the configuration being assembled: the requested pin (or channel alias) when +// there is one, the server-resolved `latest` alias otherwise. func resolveConnectorVersion(ctx context.Context, client connectivityclient.Client, connector, pinned string) (*connectivityclient.ConnectorVersion, error) { if connector == "" { return nil, fmt.Errorf("connector name is required") } if pinned == "" { - versions, err := client.ListConnectorVersions(ctx, connector) - if err != nil { - return nil, err - } - if versions == nil || len(versions.Items) == 0 { - return nil, fmt.Errorf("connectivity connector %q has no published version", connector) - } - pinned = versions.Items[len(versions.Items)-1].Version + pinned = connectivityclient.VersionAliasLatest } version, err := client.GetConnectorVersion(ctx, connector, pinned) + var apiErr *connectivityclient.APIError + if pinned == connectivityclient.VersionAliasLatest && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("connectivity connector %q has no published version: %w", connector, err) + } if err != nil { return nil, err } diff --git a/cmd/connectivity/connectors/completion.go b/cmd/connectivity/connectors/completion.go index 5fb47b8e..4edd2cee 100644 --- a/cmd/connectivity/connectors/completion.go +++ b/cmd/connectivity/connectors/completion.go @@ -1,10 +1,8 @@ package connectors import ( - "context" "sort" "strings" - "time" "github.com/spf13/cobra" @@ -12,36 +10,30 @@ import ( connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) -const ( - connectorCompletionLimit = int32(500) - connectorCompletionTimeout = 2 * time.Second -) +const connectorCompletionMaxPages = 5 func CompleteConnectorNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if factory == nil { + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) + if !ok { return nil, cobra.ShellCompDirectiveNoFileComp } - parent := cmd.Context() - if parent == nil { - parent = context.Background() - } - ctx, cancel := context.WithTimeout(parent, connectorCompletionTimeout) defer cancel() - completionCommand := *cmd - completionCommand.SetContext(connectivityinternal.WithNonInteractive(ctx)) - client, err := factory(&completionCommand) + connectors, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, connectorCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.Connector, bool, string, error) { + page, err := client.ListConnectors(completionCommand.Context(), options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - response, err := client.ListConnectors(ctx, connectivityclient.ListOptions{Limit: connectorCompletionLimit}) - if err != nil || response == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - candidates := make([]string, 0, len(response.Items)) - for _, connector := range response.Items { + candidates := make([]string, 0, len(connectors)) + for _, connector := range connectors { name := stringValue(connector.Metadata.Name) if name == "" || !strings.HasPrefix(name, toComplete) { continue diff --git a/cmd/connectivity/connectors/completion_test.go b/cmd/connectivity/connectors/completion_test.go index 415b31d0..3ac6c3a4 100644 --- a/cmd/connectivity/connectors/completion_test.go +++ b/cmd/connectivity/connectors/completion_test.go @@ -36,8 +36,8 @@ func TestCompleteConnectorNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWit }) candidates, directive := completion(&cobra.Command{}, nil, "al") - if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{Limit: 500}) { - t.Fatalf("ListConnectors options = %#v, want limit 500", gotOptions) + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{PageSize: 100}) { + t.Fatalf("ListConnectors options = %#v, want page size 100", gotOptions) } if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) @@ -50,6 +50,31 @@ func TestCompleteConnectorNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWit } } +func TestCompleteConnectorNamesFollowsCursorPagination(t *testing.T) { + var gotCursors []string + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotCursors = append(gotCursors, options.Cursor) + if options.Cursor == "" { + return &connectivityclient.ConnectorList{ + Items: []connectivityclient.Connector{connectorFixture("alpha")}, + HasMore: true, + Next: "page-two", + }, nil + } + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connectorFixture("alpine")}}, nil + }} + + candidates, _ := CompleteConnectorNames(factoryReturning(client))(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotCursors, []string{"", "page-two"}) { + t.Fatalf("list cursors = %#v, want the continuation to be followed", gotCursors) + } + want := []string{"alpha\tConnector description", "alpine\tConnector description"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want both pages merged %#v", candidates, want) + } +} + func TestCompleteConnectorNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testing.T) { tests := map[string]struct { command *cobra.Command diff --git a/cmd/connectivity/connectors/facets.go b/cmd/connectivity/connectors/facets.go new file mode 100644 index 00000000..efa030a0 --- /dev/null +++ b/cmd/connectivity/connectors/facets.go @@ -0,0 +1,113 @@ +package connectors + +import ( + "fmt" + "sort" + "strconv" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type FacetsStore struct { + Total int64 `json:"total"` + Facets map[string]map[string]int64 `json:"facets"` +} + +type FacetsController struct { + factory connectivityinternal.ClientFactory + store *FacetsStore +} + +var _ fctl.Controller[*FacetsStore] = (*FacetsController)(nil) + +func NewFacetsController(factory connectivityinternal.ClientFactory) *FacetsController { + return &FacetsController{ + factory: factory, + store: &FacetsStore{Facets: map[string]map[string]int64{}}, + } +} + +func NewFacetsCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewFacetsController(factory) + command := fctl.NewCommand( + "facets", + fctl.WithAliases("facet", "f"), + fctl.WithShortDescription("Show the facet-value distribution of the connector catalogue"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + connectivityinternal.WithListQueryFlags(), + fctl.WithController[*FacetsStore](controller), + ) + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectors), + ); err != nil { + panic(err) + } + return command +} + +func (c *FacetsController) GetStore() *FacetsStore { + return c.store +} + +func (c *FacetsController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + query, err := connectivityinternal.GetListQuery(cmd) + if err != nil { + return nil, err + } + + response, err := client.GetConnectorFacets(cmd.Context(), query) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("get connectivity connector facets: empty response") + } + + c.store.Total = response.Total + c.store.Facets = response.Facets + return c, nil +} + +func (c *FacetsController) Render(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "Total: %d\n", c.store.Total); err != nil { + return err + } + + rows := make([][]string, 0) + facets := make([]string, 0, len(c.store.Facets)) + for facet := range c.store.Facets { + facets = append(facets, facet) + } + sort.Strings(facets) + for _, facet := range facets { + values := make([]string, 0, len(c.store.Facets[facet])) + for value := range c.store.Facets[facet] { + values = append(values, value) + } + sort.Strings(values) + for _, value := range values { + rows = append(rows, []string{facet, value, strconv.FormatInt(c.store.Facets[facet][value], 10)}) + } + } + rows = fctl.Prepend(rows, []string{"Facet", "Value", "Connectors"}) + return pterm.DefaultTable. + WithHasHeader(). + WithWriter(out). + WithData(rows). + Render() +} diff --git a/cmd/connectivity/connectors/facets_test.go b/cmd/connectivity/connectors/facets_test.go new file mode 100644 index 00000000..44f1743f --- /dev/null +++ b/cmd/connectivity/connectors/facets_test.go @@ -0,0 +1,111 @@ +package connectors + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func facetDistributionFixture() *connectivityclient.FacetDistribution { + return &connectivityclient.FacetDistribution{Total: 8, Facets: map[string]map[string]int64{ + "provider": {"psp": 6, "bank": 2}, + "domain": {"payouts": 3}, + }} +} + +func TestFacetsRendersTotalAndSortedDistribution(t *testing.T) { + var gotQuery string + client := connectorClientMock{facets: func(_ context.Context, query string) (*connectivityclient.FacetDistribution, error) { + gotQuery = query + return facetDistributionFixture(), nil + }} + + output, err := executeCommand(NewFacetsCommand(factoryReturning(client)), "--filter", "catalog=ee") + if err != nil { + t.Fatalf("execute facets command: %v", err) + } + + if want := `{"$match":{"catalog":"ee"}}`; gotQuery != want { + t.Fatalf("GetConnectorFacets query = %q, want %q", gotQuery, want) + } + for _, expected := range []string{ + "Total", "8", + "Facet", "Value", "Connectors", + "domain", "payouts", "3", + "provider", "bank", "2", + "psp", "6", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } + if strings.Index(output, "domain") > strings.Index(output, "provider") { + t.Errorf("facets are not sorted by facet name:\n%s", output) + } +} + +func TestFacetsJSONPreservesCompleteDistribution(t *testing.T) { + client := connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return facetDistributionFixture(), nil + }} + + command := NewFacetsCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json") + if err != nil { + t.Fatalf("execute JSON facets command: %v", err) + } + + var envelope struct { + Data FacetsStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if envelope.Data.Total != 8 || !reflect.DeepEqual(envelope.Data.Facets, facetDistributionFixture().Facets) { + t.Fatalf("JSON facets = %#v, want the complete distribution", envelope.Data) + } +} + +func TestFacetsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, errors.New("catalog unavailable") + }}), + want: "catalog unavailable", + }, + "empty response": { + factory: factoryReturning(connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewFacetsCommand(test.factory)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} diff --git a/cmd/connectivity/connectors/list.go b/cmd/connectivity/connectors/list.go index 64b5dcb0..bd886dee 100644 --- a/cmd/connectivity/connectors/list.go +++ b/cmd/connectivity/connectors/list.go @@ -33,16 +33,24 @@ func NewListController(factory connectivityinternal.ClientFactory) *ListControll func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { controller := NewListController(factory) - return fctl.NewCommand( + command := fctl.NewCommand( "list", fctl.WithAliases("ls", "l"), fctl.WithShortDescription("List available Connectivity connectors"), fctl.WithArgs(cobra.ExactArgs(0)), fctl.WithValidArgsFunction(cobra.NoFileCompletions), + connectivityinternal.WithListQueryFlags(), fctl.WithPageSizeFlag(), fctl.WithCursorFlag(), fctl.WithController[*ListStore](controller), ) + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectors), + ); err != nil { + panic(err) + } + return command } func (c *ListController) GetStore() *ListStore { @@ -65,10 +73,15 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e if err != nil { return nil, err } + query, err := connectivityinternal.GetListQuery(cmd) + if err != nil { + return nil, err + } response, err := client.ListConnectors(cmd.Context(), connectivityclient.ListOptions{ - Limit: pageSize, - Continue: cursor, + PageSize: pageSize, + Cursor: cursor, + Query: query, }) if err != nil { return nil, err @@ -78,14 +91,18 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e } c.store.Connectors = response.Items - c.store.Cursor = fctl.Cursor{PageSize: int64(pageSize)} - if response.Continue != "" { - c.store.Cursor.HasMore = true - c.store.Cursor.Next = fctl.Ptr(response.Continue) - } + c.store.Cursor = cursorFromList(response.PageSize, response.HasMore, response.Next) return c, nil } +func cursorFromList(pageSize int32, hasMore bool, next string) fctl.Cursor { + cursor := fctl.Cursor{PageSize: int64(pageSize), HasMore: hasMore} + if next != "" { + cursor.Next = fctl.Ptr(next) + } + return cursor +} + func (c *ListController) Render(cmd *cobra.Command, _ []string) error { rows := fctl.Map(c.store.Connectors, func(connector connectivityclient.Connector) []string { return []string{ diff --git a/cmd/connectivity/connectors/list_test.go b/cmd/connectivity/connectors/list_test.go index efca40b5..1aa51f84 100644 --- a/cmd/connectivity/connectors/list_test.go +++ b/cmd/connectivity/connectors/list_test.go @@ -20,7 +20,9 @@ func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation( gotOptions = options return &connectivityclient.ConnectorList{ Items: []connectivityclient.Connector{connectorFixture("stripe")}, - Continue: "next-page", + PageSize: 7, + HasMore: true, + Next: "next-page", }, nil }} @@ -30,7 +32,7 @@ func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation( t.Fatalf("execute list command: %v", err) } - wantOptions := connectivityclient.ListOptions{Limit: 7, Continue: "current-page"} + wantOptions := connectivityclient.ListOptions{PageSize: 7, Cursor: "current-page"} if !reflect.DeepEqual(gotOptions, wantOptions) { t.Fatalf("ListConnectors options = %#v, want %#v", gotOptions, wantOptions) } @@ -45,11 +47,60 @@ func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation( } } +func TestListConnectorsBuildsQueryFromFilterFlags(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{}}, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--filter", "catalog=ee", "--filter", "phase=Ready") + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + want := `{"$and":[{"$match":{"catalog":"ee"}},{"$match":{"phase":"Ready"}}]}` + if gotOptions.Query != want { + t.Fatalf("ListConnectors query = %q, want %q", gotOptions.Query, want) + } +} + +func TestListConnectorsPassesRawQueryThrough(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{}}, nil + }} + raw := `{"$exists":{"catalog":false}}` + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", raw) + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + if gotOptions.Query != raw { + t.Fatalf("ListConnectors query = %q, want %q", gotOptions.Query, raw) + } +} + +func TestListConnectorsRejectsCombinedQueryAndFilter(t *testing.T) { + client := connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + t.Fatal("ListConnectors must not run with conflicting flags") + return nil, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", `{"$match":{"catalog":"ee"}}`, "--filter", "phase=Ready") + + if err == nil || !strings.Contains(err.Error(), "--query cannot be combined with --filter") { + t.Fatalf("error = %v, want the conflicting flags error", err) + } +} + func TestListConnectorsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { connector := connectorFixture("stripe") connector.Metadata.Labels = map[string]string{"region": "eu"} client := connectorClientMock{list: func(_ context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { - return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}, Continue: "next-page"}, nil + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}, PageSize: 4, HasMore: true, Next: "next-page"}, nil }} command := NewListCommand(factoryReturning(client)) @@ -69,7 +120,7 @@ func TestListConnectorsJSONPreservesCompleteModelsAndContinuation(t *testing.T) t.Fatalf("JSON connectors = %#v, want complete model %#v", envelope.Data.Connectors, connector) } if !envelope.Data.Cursor.HasMore || envelope.Data.Cursor.PageSize != 4 || envelope.Data.Cursor.Next == nil || *envelope.Data.Cursor.Next != "next-page" { - t.Fatalf("JSON cursor = %#v, want continuation and requested page size", envelope.Data.Cursor) + t.Fatalf("JSON cursor = %#v, want continuation and served page size", envelope.Data.Cursor) } } @@ -80,8 +131,9 @@ func TestConnectorRootRegistersApprovedCommandsAndAliases(t *testing.T) { } wantAliases := map[string][]string{ - "list": {"ls", "l"}, - "show": {"get", "g", "sh", "s"}, + "list": {"ls", "l"}, + "show": {"get", "g", "sh", "s"}, + "facets": {"facet", "f"}, } for name, aliases := range wantAliases { child, _, err := command.Find([]string{name}) diff --git a/cmd/connectivity/connectors/root.go b/cmd/connectivity/connectors/root.go index afdbc594..f3387abd 100644 --- a/cmd/connectivity/connectors/root.go +++ b/cmd/connectivity/connectors/root.go @@ -15,6 +15,7 @@ func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command { fctl.WithChildCommands( NewListCommand(factory), NewShowCommand(factory), + NewFacetsCommand(factory), ), ) } diff --git a/cmd/connectivity/connectors/show.go b/cmd/connectivity/connectors/show.go index 660c7822..fd4d2d41 100644 --- a/cmd/connectivity/connectors/show.go +++ b/cmd/connectivity/connectors/show.go @@ -1,7 +1,9 @@ package connectors import ( + "errors" "fmt" + "net/http" "sort" "strings" "time" @@ -14,6 +16,11 @@ import ( fctl "github.com/formancehq/fctl/v3/pkg" ) +const ( + showVersionsPageSize = int32(100) + showVersionsMaxPages = 20 +) + type ShowStore struct { Connector connectivityclient.Connector `json:"connector"` Versions []connectivityclient.ConnectorVersionSummary `json:"versions"` @@ -68,27 +75,36 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable } c.store.Connector = *connector - versions, err := client.ListConnectorVersions(cmd.Context(), name) + versions, err := connectivityinternal.CollectPages(showVersionsPageSize, showVersionsMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(cmd.Context(), name, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("show connectivity connector %q: empty version list response", name) + } + return page.Items, page.HasMore, page.Next, nil + }) if err != nil { return nil, err } - if versions == nil { - return nil, fmt.Errorf("show connectivity connector %q: empty version list response", name) - } - c.store.Versions = versions.Items + c.store.Versions = versions - // The catalog serves versions ascending by semantic version, so the - // configSchema worth rendering is the newest one's. - if len(versions.Items) > 0 { - newest := versions.Items[len(versions.Items)-1].Version - version, err := client.GetConnectorVersion(cmd.Context(), name, newest) - if err != nil { + // The server resolves the `latest` alias to the newest Validated version; + // published-but-unresolvable catalogues degrade to the no-version notice. + if len(versions) > 0 { + version, err := client.GetConnectorVersion(cmd.Context(), name, connectivityclient.VersionAliasLatest) + var apiErr *connectivityclient.APIError + switch { + case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound: + case err != nil: return nil, err + case version == nil: + return nil, fmt.Errorf("show connectivity connector %q: empty latest version response", name) + default: + c.store.Version = version } - if version == nil { - return nil, fmt.Errorf("show connectivity connector %q: empty version %q response", name, newest) - } - c.store.Version = version } return c, nil } @@ -111,6 +127,8 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { {pterm.LightCyan("Image URL"), stringValue(connector.Spec.ImageURL)}, {pterm.LightCyan("Catalog"), stringValue(connector.Spec.Catalog)}, {pterm.LightCyan("Tags"), strings.Join(connector.Spec.Tags, ", ")}, + {pterm.LightCyan("Tagline"), stringValue(connector.Spec.Tagline)}, + {pterm.LightCyan("Latest Version"), stringValue(connector.Spec.LatestVersion)}, {pterm.LightCyan("Phase"), connectorPhase(connector.Status)}, {pterm.LightCyan("Status Message"), connectorStatusMessage(connector.Status)}, } diff --git a/cmd/connectivity/connectors/show_test.go b/cmd/connectivity/connectors/show_test.go index eb4f9064..bfe6afb3 100644 --- a/cmd/connectivity/connectors/show_test.go +++ b/cmd/connectivity/connectors/show_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "reflect" "strings" "testing" @@ -22,7 +23,7 @@ func versionsFixture() *connectivityclient.ConnectorVersionList { }} } -func TestShowConnectorRendersMetadataVersionsTagsStatusAndNewestVersionSchema(t *testing.T) { +func TestShowConnectorRendersMetadataVersionsTagsStatusAndLatestVersionSchema(t *testing.T) { created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) connector := connectorFixture("stripe") connector.Metadata.Namespace = fctl.Ptr("formance") @@ -41,16 +42,19 @@ func TestShowConnectorRendersMetadataVersionsTagsStatusAndNewestVersionSchema(t } return &connector, nil }, - listVersions: func(_ context.Context, name string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(_ context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { if name != "stripe" { t.Fatalf("ListConnectorVersions name = %q, want stripe", name) } + if options.PageSize != 100 { + t.Fatalf("ListConnectorVersions page size = %d, want 100", options.PageSize) + } return versionsFixture(), nil }, getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { gotVersion = version return &connectivityclient.ConnectorVersion{ - Version: version, + Version: "2.0.0", Image: "registry/connector:2", ConfigSchema: map[string]any{ "type": "object", @@ -73,14 +77,15 @@ func TestShowConnectorRendersMetadataVersionsTagsStatusAndNewestVersionSchema(t t.Fatalf("execute show command: %v", err) } - if gotVersion != "2.0.0" { - t.Fatalf("GetConnectorVersion version = %q, want the newest published version", gotVersion) + if gotVersion != connectivityclient.VersionAliasLatest { + t.Fatalf("GetConnectorVersion version = %q, want the server-resolved latest alias", gotVersion) } for _, expected := range []string{ "stripe", "formance", "42", "connector-uid", created.Format(time.RFC3339), "region=eu", "owner=platform", "Connector display name", "Connector description", "registry/connector", "public", - "payments, webhooks", "Ready", "Catalog entry is healthy", - "1.0.0", "sha256:one", "2.0.0", + "payments, webhooks", "Payments infrastructure", "Ready", "Catalog entry is healthy", + "Latest Version", "2.0.0", + "1.0.0", "sha256:one", "Configuration Schema (2.0.0)", "API_KEY", "environment", "required", "password", "API credential", "/etc/connector.yaml", "file", "Connector settings", } { @@ -90,13 +95,50 @@ func TestShowConnectorRendersMetadataVersionsTagsStatusAndNewestVersionSchema(t } } +func TestShowConnectorFollowsVersionPagination(t *testing.T) { + connector := connectorFixture("stripe") + var gotCursors []string + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(_ context.Context, _ string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + gotCursors = append(gotCursors, options.Cursor) + if options.Cursor == "" { + return &connectivityclient.ConnectorVersionList{ + Items: []connectivityclient.ConnectorVersionSummary{{Version: "1.0.0", Image: "registry/connector:1"}}, + HasMore: true, + Next: "page-two", + }, nil + } + return &connectivityclient.ConnectorVersionList{ + Items: []connectivityclient.ConnectorVersionSummary{{Version: "2.0.0", Image: "registry/connector:2"}}, + }, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "stripe") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + + if !reflect.DeepEqual(gotCursors, []string{"", "page-two"}) { + t.Fatalf("version list cursors = %#v, want the continuation to be followed", gotCursors) + } + for _, expected := range []string{"1.0.0", "2.0.0"} { + if !strings.Contains(output, expected) { + t.Errorf("output missing version %q:\n%s", expected, output) + } + } +} + func TestShowConnectorWithoutVersionsReportsNoPublishedVersion(t *testing.T) { connector := connectorFixture("fresh") client := connectorClientMock{ get: func(context.Context, string) (*connectivityclient.Connector, error) { return &connector, nil }, - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil }, getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { @@ -114,6 +156,31 @@ func TestShowConnectorWithoutVersionsReportsNoPublishedVersion(t *testing.T) { } } +// `latest` resolves to the newest Validated version; published-but-unvalidated +// versions answer 404, which show degrades to the no-version notice. +func TestShowConnectorTreatsUnresolvableLatestAsNoPublishedVersion(t *testing.T) { + connector := connectorFixture("pending") + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, &connectivityclient.APIError{StatusCode: http.StatusNotFound, Code: "channel_empty"} + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "pending") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + if !strings.Contains(output, "No published version.") { + t.Fatalf("output missing the no-version notice:\n%s", output) + } +} + func TestShowConnectorJSONPreservesCompleteModel(t *testing.T) { connector := connectorFixture("stripe") version := &connectivityclient.ConnectorVersion{ @@ -125,7 +192,7 @@ func TestShowConnectorJSONPreservesCompleteModel(t *testing.T) { get: func(context.Context, string) (*connectivityclient.Connector, error) { return &connector, nil }, - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { return versionsFixture(), nil }, getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { @@ -186,7 +253,22 @@ func TestShowConnectorReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { connector := connectorFixture("stripe") return &connector, nil }, - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return nil, errors.New("catalog unavailable") + }, + }), + want: "catalog unavailable", + }, + "latest version API": { + factory: factoryReturning(connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + connector := connectorFixture("stripe") + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, errors.New("catalog unavailable") }, }), @@ -211,12 +293,12 @@ func TestShowConnectorSummarizesLegacyFlatSchema(t *testing.T) { get: func(context.Context, string) (*connectivityclient.Connector, error) { return &connector, nil }, - listVersions: func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) { + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { return versionsFixture(), nil }, - getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return &connectivityclient.ConnectorVersion{ - Version: version, + Version: "2.0.0", Image: "registry/connector:2", ConfigSchema: map[string]any{ "type": "object", diff --git a/cmd/connectivity/connectors/test_helpers_test.go b/cmd/connectivity/connectors/test_helpers_test.go index 96634e94..9ef752a2 100644 --- a/cmd/connectivity/connectors/test_helpers_test.go +++ b/cmd/connectivity/connectors/test_helpers_test.go @@ -14,8 +14,9 @@ import ( type connectorClientMock struct { connectivityclient.Client list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + facets func(context.Context, string) (*connectivityclient.FacetDistribution, error) get func(context.Context, string) (*connectivityclient.Connector, error) - listVersions func(context.Context, string) (*connectivityclient.ConnectorVersionList, error) + listVersions func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) } @@ -23,15 +24,19 @@ func (m connectorClientMock) ListConnectors(ctx context.Context, options connect return m.list(ctx, options) } +func (m connectorClientMock) GetConnectorFacets(ctx context.Context, query string) (*connectivityclient.FacetDistribution, error) { + return m.facets(ctx, query) +} + func (m connectorClientMock) GetConnector(ctx context.Context, name string) (*connectivityclient.Connector, error) { return m.get(ctx, name) } -func (m connectorClientMock) ListConnectorVersions(ctx context.Context, connector string) (*connectivityclient.ConnectorVersionList, error) { +func (m connectorClientMock) ListConnectorVersions(ctx context.Context, connector string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { if m.listVersions == nil { return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil } - return m.listVersions(ctx, connector) + return m.listVersions(ctx, connector, options) } func (m connectorClientMock) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { @@ -45,11 +50,13 @@ func connectorFixture(name string) connectivityclient.Connector { return connectivityclient.Connector{ Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, Spec: connectivityclient.ConnectorSpec{ - DisplayName: fctl.Ptr("Connector display name"), - Description: fctl.Ptr("Connector description"), - ImageURL: fctl.Ptr("registry/connector"), - Catalog: fctl.Ptr("public"), - Tags: []string{"payments", "webhooks"}, + DisplayName: fctl.Ptr("Connector display name"), + Description: fctl.Ptr("Connector description"), + ImageURL: fctl.Ptr("registry/connector"), + Catalog: fctl.Ptr("public"), + Tags: []string{"payments", "webhooks"}, + Tagline: fctl.Ptr("Payments infrastructure"), + LatestVersion: fctl.Ptr("2.0.0"), }, Status: &connectivityclient.ConnectorStatus{Phase: fctl.Ptr("Ready")}, } diff --git a/cmd/connectivity/internal/filtercompletion.go b/cmd/connectivity/internal/filtercompletion.go new file mode 100644 index 00000000..767c2882 --- /dev/null +++ b/cmd/connectivity/internal/filtercompletion.go @@ -0,0 +1,193 @@ +package internal + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const ( + CompletionTimeout = 2 * time.Second + CompletionPageSize = int32(100) +) + +// CompletionClient builds a non-interactive, deadline-bounded client for +// shell completion callbacks, on a copy of the command so the original +// context is left untouched. +func CompletionClient(cmd *cobra.Command, factory ClientFactory) (connectivityclient.Client, *cobra.Command, context.CancelFunc, bool) { + if factory == nil { + return nil, nil, func() {}, false + } + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, CompletionTimeout) + completionCommand := *cmd + completionCommand.SetContext(WithNonInteractive(ctx)) + client, err := factory(&completionCommand) + if err != nil || client == nil { + cancel() + return nil, nil, func() {}, false + } + return client, &completionCommand, cancel, true +} + +// CompleteFilterExpressions completes `--filter` expressions from the +// server's query allowlist: keys and operators come from +// `GET /_query/capabilities`, values from the capability enum, from +// `GET /connectors/_facets` for connector tags, and from the published +// connector names for the instance `connector` key. +func CompleteFilterExpressions(factory ClientFactory, resource string) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + ctx := completionCommand.Context() + + capabilities, err := client.GetQueryCapabilities(ctx) + if err != nil || capabilities == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + fields, ok := capabilities.Resources[resource] + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + key, valuePrefix, operator := splitFilter(toComplete) + if operator == "" { + return completeFilterKeys(fields, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + + capability, ok := fields[key] + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + values := filterValueCandidates(ctx, client, resource, key, capability) + candidates := make([]string, 0, len(values)) + for _, value := range values { + if !strings.HasPrefix(value.value, valuePrefix) { + continue + } + candidate := key + operator + value.value + if value.description != "" { + candidate += "\t" + value.description + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +// CompleteChannels serves the maturity channels from the server's capability +// enum for the instance `channel` key, so the CLI never hardcodes them. +func CompleteChannels(factory ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + capabilities, err := client.GetQueryCapabilities(completionCommand.Context()) + if err != nil || capabilities == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + channels := capabilities.Resources[connectivityclient.ResourceConnectorInstances]["channel"].Enum + candidates := make([]string, 0, len(channels)) + for _, channel := range channels { + if strings.HasPrefix(channel, toComplete) { + candidates = append(candidates, channel) + } + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func completeFilterKeys(fields map[string]connectivityclient.QueryFieldCapability, prefix string) []string { + candidates := make([]string, 0, len(fields)) + for key, capability := range fields { + if !strings.HasPrefix(key, prefix) { + continue + } + candidate := key + "=" + if len(capability.Operators) > 0 { + candidate += "\toperators: " + strings.Join(capability.Operators, ", ") + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates +} + +type filterValue struct { + value string + description string +} + +func filterValueCandidates(ctx context.Context, client connectivityclient.Client, resource, key string, capability connectivityclient.QueryFieldCapability) []filterValue { + if len(capability.Enum) > 0 { + values := make([]filterValue, 0, len(capability.Enum)) + for _, value := range capability.Enum { + values = append(values, filterValue{value: value}) + } + return values + } + switch { + case resource == connectivityclient.ResourceConnectors && key == "tags": + return facetFilterValues(ctx, client) + case resource == connectivityclient.ResourceConnectorInstances && key == "connector": + return connectorFilterValues(ctx, client) + } + return nil +} + +func facetFilterValues(ctx context.Context, client connectivityclient.Client) []filterValue { + facets, err := client.GetConnectorFacets(ctx, "") + if err != nil || facets == nil { + return nil + } + values := make([]filterValue, 0) + for facet, counts := range facets.Facets { + for value, count := range counts { + plural := "s" + if count == 1 { + plural = "" + } + values = append(values, filterValue{ + value: facet + ":" + value, + description: fmt.Sprintf("%d connector%s", count, plural), + }) + } + } + return values +} + +func connectorFilterValues(ctx context.Context, client connectivityclient.Client) []filterValue { + connectors, err := client.ListConnectors(ctx, connectivityclient.ListOptions{PageSize: CompletionPageSize}) + if err != nil || connectors == nil { + return nil + } + values := make([]filterValue, 0, len(connectors.Items)) + for _, connector := range connectors.Items { + if connector.Metadata.Name == nil || *connector.Metadata.Name == "" { + continue + } + value := filterValue{value: *connector.Metadata.Name} + if connector.Spec.DisplayName != nil { + value.description = *connector.Spec.DisplayName + } + values = append(values, value) + } + return values +} diff --git a/cmd/connectivity/internal/filtercompletion_test.go b/cmd/connectivity/internal/filtercompletion_test.go new file mode 100644 index 00000000..4821cbdc --- /dev/null +++ b/cmd/connectivity/internal/filtercompletion_test.go @@ -0,0 +1,282 @@ +package internal + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type filterCompletionClientMock struct { + connectivityclient.Client + capabilities func(context.Context) (*connectivityclient.QueryCapabilities, error) + facets func(context.Context, string) (*connectivityclient.FacetDistribution, error) + listConnectors func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) +} + +func (m filterCompletionClientMock) GetQueryCapabilities(ctx context.Context) (*connectivityclient.QueryCapabilities, error) { + return m.capabilities(ctx) +} + +func (m filterCompletionClientMock) GetConnectorFacets(ctx context.Context, query string) (*connectivityclient.FacetDistribution, error) { + return m.facets(ctx, query) +} + +func (m filterCompletionClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.listConnectors(ctx, options) +} + +func capabilitiesFixture() *connectivityclient.QueryCapabilities { + return &connectivityclient.QueryCapabilities{Resources: map[string]map[string]connectivityclient.QueryFieldCapability{ + connectivityclient.ResourceConnectors: { + "catalog": {Operators: []string{"$match", "$in", "$exists"}}, + "name": {Operators: []string{"$match", "$in", "$like"}}, + "tags": {Operators: []string{"$match", "$in", "$exists"}}, + }, + connectivityclient.ResourceConnectorInstances: { + "channel": {Operators: []string{"$match", "$in", "$exists"}, Enum: []string{"stable", "rc", "beta", "alpha"}}, + "connector": {Operators: []string{"$match", "$in"}}, + }, + }} +} + +func filterFactory(t *testing.T, client connectivityclient.Client) ClientFactory { + t.Helper() + return func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !IsNonInteractive(cmd.Context()) { + t.Fatal("filter completion factory context is interactive") + } + if _, ok := cmd.Context().Deadline(); !ok { + t.Fatal("filter completion context has no deadline") + } + return client, nil + } +} + +func TestCompleteFilterExpressionsOffersCapabilityKeysWithOperators(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + candidates, directive := completion(&cobra.Command{}, nil, "") + + want := []string{ + "catalog=\toperators: $match, $in, $exists", + "name=\toperators: $match, $in, $like", + "tags=\toperators: $match, $in, $exists", + } + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace { + t.Fatalf("directive = %v, want NoFileComp|NoSpace", directive) + } +} + +func TestCompleteFilterExpressionsFiltersKeysByPrefix(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + candidates, _ := completion(&cobra.Command{}, nil, "na") + + want := []string{"name=\toperators: $match, $in, $like"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestCompleteFilterExpressionsCompletesEnumValuesForTypedOperator(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances) + + equals, directive := completion(&cobra.Command{}, nil, "channel=st") + negated, _ := completion(&cobra.Command{}, nil, "channel!=r") + + if want := []string{"channel=stable"}; !reflect.DeepEqual(equals, want) { + t.Fatalf("equality candidates = %#v, want %#v", equals, want) + } + if want := []string{"channel!=rc"}; !reflect.DeepEqual(negated, want) { + t.Fatalf("negation candidates = %#v, want %#v", negated, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFilterExpressionsCompletesTagValuesFromFacetDistribution(t *testing.T) { + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return &connectivityclient.FacetDistribution{Total: 8, Facets: map[string]map[string]int64{ + "provider": {"psp": 6, "bank": 2}, + "domain": {"payouts": 3}, + }}, nil + }, + } + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + all, _ := completion(&cobra.Command{}, nil, "tags=") + scoped, _ := completion(&cobra.Command{}, nil, "tags=provider:") + + wantAll := []string{ + "tags=domain:payouts\t3 connectors", + "tags=provider:bank\t2 connectors", + "tags=provider:psp\t6 connectors", + } + if !reflect.DeepEqual(all, wantAll) { + t.Fatalf("candidates = %#v, want %#v", all, wantAll) + } + wantScoped := []string{ + "tags=provider:bank\t2 connectors", + "tags=provider:psp\t6 connectors", + } + if !reflect.DeepEqual(scoped, wantScoped) { + t.Fatalf("scoped candidates = %#v, want %#v", scoped, wantScoped) + } +} + +func TestCompleteFilterExpressionsCompletesConnectorNamesForInstanceConnectorKey(t *testing.T) { + displayName := "Stripe" + stripe := "stripe" + wise := "wise" + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + if options.PageSize != 100 { + t.Fatalf("ListConnectors page size = %d, want 100", options.PageSize) + } + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{ + {Metadata: connectivityclient.ObjectMeta{Name: &wise}}, + {Metadata: connectivityclient.ObjectMeta{Name: &stripe}, Spec: connectivityclient.ConnectorSpec{DisplayName: &displayName}}, + }}, nil + }, + } + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances) + + candidates, _ := completion(&cobra.Command{}, nil, "connector=") + + want := []string{"connector=stripe\tStripe", "connector=wise"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestCompleteChannelsServesTheCapabilityEnum(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteChannels(filterFactory(t, client)) + + all, directive := completion(&cobra.Command{}, nil, "") + prefixed, _ := completion(&cobra.Command{}, nil, "s") + + if want := []string{"alpha", "beta", "rc", "stable"}; !reflect.DeepEqual(all, want) { + t.Fatalf("candidates = %#v, want %#v", all, want) + } + if want := []string{"stable"}; !reflect.DeepEqual(prefixed, want) { + t.Fatalf("prefixed candidates = %#v, want %#v", prefixed, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteChannelsReturnsSilentlyWhenCapabilitiesFail(t *testing.T) { + completion := CompleteChannels(func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return nil, errors.New("unsupported deployment") + }}, nil + }) + + candidates, directive := completion(&cobra.Command{}, nil, "") + + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFilterExpressionsReturnsSilentlyWhenLookupsFail(t *testing.T) { + tests := map[string]struct { + factory ClientFactory + resource string + prefix string + }{ + "factory error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }, + resource: connectivityclient.ResourceConnectors, + }, + "nil factory": { + resource: connectivityclient.ResourceConnectors, + }, + "capabilities error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return nil, errors.New("unsupported deployment") + }}, nil + }, + resource: connectivityclient.ResourceConnectors, + }, + "unknown resource": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }}, nil + }, + resource: "unknown", + }, + "value for unknown key": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }}, nil + }, + resource: connectivityclient.ResourceConnectors, + prefix: "nope=", + }, + "facets error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, errors.New("unsupported deployment") + }, + }, nil + }, + resource: connectivityclient.ResourceConnectors, + prefix: "tags=", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + completion := CompleteFilterExpressions(test.factory, test.resource) + + candidates, directive := completion(&cobra.Command{}, nil, test.prefix) + + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} diff --git a/cmd/connectivity/internal/paging.go b/cmd/connectivity/internal/paging.go new file mode 100644 index 00000000..eececfe7 --- /dev/null +++ b/cmd/connectivity/internal/paging.go @@ -0,0 +1,25 @@ +package internal + +import ( + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +// CollectPages drains a cursor-paginated list endpoint, following +// `cursor.next` until the server reports the last page or maxPages is +// reached (a bound against pathological continuations). +func CollectPages[T any](pageSize int32, maxPages int, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + options := connectivityclient.ListOptions{PageSize: pageSize} + items := make([]T, 0) + for page := 0; page < maxPages; page++ { + pageItems, hasMore, next, err := fetch(options) + if err != nil { + return nil, err + } + items = append(items, pageItems...) + if !hasMore || next == "" { + break + } + options.Cursor = next + } + return items, nil +} diff --git a/cmd/connectivity/internal/query.go b/cmd/connectivity/internal/query.go new file mode 100644 index 00000000..200d7dcb --- /dev/null +++ b/cmd/connectivity/internal/query.go @@ -0,0 +1,109 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const ( + FilterFlag = "filter" + QueryFlag = "query" +) + +func WithListQueryFlags() fctl.CommandOptionFn { + return func(cmd *cobra.Command) { + cmd.Flags().StringArray(FilterFlag, nil, "Filter expression key=value, key!=value or key~pattern (repeatable)") + cmd.Flags().String(QueryFlag, "", "Raw filter in the go-libs query dialect (JSON); see /_query/capabilities") + } +} + +// GetListQuery resolves the --filter/--query flags into the query string the +// connectivity API accepts. +func GetListQuery(cmd *cobra.Command) (string, error) { + filters, err := cmd.Flags().GetStringArray(FilterFlag) + if err != nil { + return "", err + } + return BuildListQuery(fctl.GetString(cmd, QueryFlag), filters) +} + +// BuildListQuery turns --filter expressions into the go-libs query dialect the +// connectivity API accepts on `?query=`, or passes a raw --query through +// untouched. Supported expressions: `key=value` ($match), `key!=value` +// ($exists conjoined with a negated $match, because a bare $not also selects +// objects missing the key) and `key~pattern` ($like, SQL wildcards). +func BuildListQuery(rawQuery string, filters []string) (string, error) { + if rawQuery != "" { + if len(filters) > 0 { + return "", fmt.Errorf("--query cannot be combined with --filter") + } + var root any + if err := json.Unmarshal([]byte(rawQuery), &root); err != nil { + return "", fmt.Errorf("parse --query: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return "", fmt.Errorf("parse --query: root must be an object") + } + return rawQuery, nil + } + if len(filters) == 0 { + return "", nil + } + + clauses := make([]any, 0, len(filters)) + for _, filter := range filters { + clause, err := filterClause(filter) + if err != nil { + return "", err + } + clauses = append(clauses, clause) + } + + var query any = clauses[0] + if len(clauses) > 1 { + query = map[string]any{"$and": clauses} + } + encoded, err := json.Marshal(query) + if err != nil { + return "", fmt.Errorf("encode query: %w", err) + } + return string(encoded), nil +} + +func filterClause(filter string) (any, error) { + key, value, operator := splitFilter(filter) + if operator == "" { + return nil, fmt.Errorf("parse --filter %q: expected key=value, key!=value or key~pattern", filter) + } + if key == "" { + return nil, fmt.Errorf("parse --filter %q: key is required", filter) + } + if value == "" { + return nil, fmt.Errorf("parse --filter %q: value is required", filter) + } + switch operator { + case "!=": + return map[string]any{"$and": []any{ + map[string]any{"$exists": map[string]any{key: true}}, + map[string]any{"$not": map[string]any{"$match": map[string]any{key: value}}}, + }}, nil + case "~": + return map[string]any{"$like": map[string]any{key: value}}, nil + default: + return map[string]any{"$match": map[string]any{key: value}}, nil + } +} + +func splitFilter(filter string) (key, value, operator string) { + for _, candidate := range []string{"!=", "=", "~"} { + if index := strings.Index(filter, candidate); index >= 0 { + return filter[:index], filter[index+len(candidate):], candidate + } + } + return "", "", "" +} diff --git a/cmd/connectivity/internal/query_test.go b/cmd/connectivity/internal/query_test.go new file mode 100644 index 00000000..4f75dc02 --- /dev/null +++ b/cmd/connectivity/internal/query_test.go @@ -0,0 +1,76 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildListQueryReturnsEmptyWithoutInput(t *testing.T) { + query, err := BuildListQuery("", nil) + + require.NoError(t, err) + require.Empty(t, query) +} + +func TestBuildListQueryBuildsMatchLeafFromEquality(t *testing.T) { + query, err := BuildListQuery("", []string{"catalog=ee"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$match":{"catalog":"ee"}}`, query) +} + +func TestBuildListQueryConjoinsMultipleFilters(t *testing.T) { + query, err := BuildListQuery("", []string{"catalog=ee", "phase=Ready"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$and":[{"$match":{"catalog":"ee"}},{"$match":{"phase":"Ready"}}]}`, query) +} + +// A bare $not also selects objects missing the key, so the builder conjoins +// $exists with the negation (the openapi documents this trap). +func TestBuildListQueryBuildsGuardedNegation(t *testing.T) { + query, err := BuildListQuery("", []string{"phase!=Rejected"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$and":[{"$exists":{"phase":true}},{"$not":{"$match":{"phase":"Rejected"}}}]}`, query) +} + +func TestBuildListQueryBuildsLikeLeaf(t *testing.T) { + query, err := BuildListQuery("", []string{"name~stripe%"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$like":{"name":"stripe%"}}`, query) +} + +func TestBuildListQueryPassesRawQueryThrough(t *testing.T) { + raw := `{"$or":[{"$match":{"catalog":"ee"}},{"$exists":{"catalog":false}}]}` + + query, err := BuildListQuery(raw, nil) + + require.NoError(t, err) + require.JSONEq(t, raw, query) +} + +func TestBuildListQueryRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + raw string + filters []string + }{ + {name: "raw query combined with filters", raw: `{"$match":{"catalog":"ee"}}`, filters: []string{"phase=Ready"}}, + {name: "raw query is not JSON", raw: `{$match:`}, + {name: "raw query is not an object", raw: `["$match"]`}, + {name: "filter without operator", filters: []string{"catalog"}}, + {name: "filter without key", filters: []string{"=ee"}}, + {name: "filter without value", filters: []string{"catalog="}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildListQuery(tt.raw, tt.filters) + + require.Error(t, err) + }) + } +} diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go index e5751fdb..147a9cfa 100644 --- a/internal/connectivityclient/client.go +++ b/internal/connectivityclient/client.go @@ -18,6 +18,9 @@ const ( connectorsResource = "connectors" connectorVersionsResource = "versions" connectorInstancesResource = "connectorinstances" + facetsSegment = "_facets" + querySegment = "_query" + capabilitiesSegment = "capabilities" ) type client struct { @@ -29,9 +32,64 @@ func (e *APIError) Error() string { return fmt.Sprintf("connectivity API error: status %d, code %s: %s", e.StatusCode, e.Code, e.Message) } +type page[T any] struct { + Cursor *struct { + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next"` + Data []T `json:"data"` + } `json:"cursor"` +} + +func listPage[T any](ctx context.Context, c *client, path []string, options ListOptions, validate func(*T) error) (*page[T], error) { + envelope := &page[T]{} + if err := c.requestJSON(ctx, http.MethodGet, path, listQuery(options), nil, "", http.StatusOK, envelope, true); err != nil { + return nil, err + } + if envelope.Cursor == nil { + return nil, fmt.Errorf("decode connectivity response: cursor must be a non-null object") + } + if envelope.Cursor.Data == nil { + return nil, fmt.Errorf("decode connectivity response: cursor.data must be a non-null array") + } + for i := range envelope.Cursor.Data { + if err := validate(&envelope.Cursor.Data[i]); err != nil { + return nil, fmt.Errorf("decode connectivity response: cursor.data[%d]: %w", i, err) + } + } + return envelope, nil +} + func (c *client) ListConnectors(ctx context.Context, options ListOptions) (*ConnectorList, error) { - result := &ConnectorList{} - if err := c.requestJSON(ctx, http.MethodGet, []string{connectorsResource}, listQuery(options), nil, "", http.StatusOK, result, true); err != nil { + result, err := listPage(ctx, c, []string{connectorsResource}, options, validateConnector) + if err != nil { + return nil, err + } + return &ConnectorList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil +} + +func (c *client) GetConnectorFacets(ctx context.Context, query string) (*FacetDistribution, error) { + result := &FacetDistribution{} + values := url.Values{} + if query != "" { + values.Set("query", query) + } + path := []string{connectorsResource, facetsSegment} + if err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetQueryCapabilities(ctx context.Context) (*QueryCapabilities, error) { + result := &QueryCapabilities{} + path := []string{querySegment, capabilitiesSegment} + if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { return nil, err } return result, nil @@ -45,13 +103,18 @@ func (c *client) GetConnector(ctx context.Context, name string) (*Connector, err return result, nil } -func (c *client) ListConnectorVersions(ctx context.Context, connector string) (*ConnectorVersionList, error) { - result := &ConnectorVersionList{} +func (c *client) ListConnectorVersions(ctx context.Context, connector string, options ListOptions) (*ConnectorVersionList, error) { path := []string{connectorsResource, connector, connectorVersionsResource} - if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { + result, err := listPage(ctx, c, path, options, validateConnectorVersionSummary) + if err != nil { return nil, err } - return result, nil + return &ConnectorVersionList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil } func (c *client) GetConnectorVersion(ctx context.Context, connector, version string) (*ConnectorVersion, error) { @@ -64,11 +127,16 @@ func (c *client) GetConnectorVersion(ctx context.Context, connector, version str } func (c *client) ListConnectorInstances(ctx context.Context, options ListOptions) (*ConnectorInstanceList, error) { - result := &ConnectorInstanceList{} - if err := c.requestJSON(ctx, http.MethodGet, []string{connectorInstancesResource}, listQuery(options), nil, "", http.StatusOK, result, true); err != nil { + result, err := listPage(ctx, c, []string{connectorInstancesResource}, options, validateConnectorInstance) + if err != nil { return nil, err } - return result, nil + return &ConnectorInstanceList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil } func (c *client) CreateConnectorInstance(ctx context.Context, instance ConnectorInstanceCreate) (*ConnectorInstance, error) { @@ -102,14 +170,14 @@ func (c *client) DeleteConnectorInstance(ctx context.Context, name string) error func listQuery(options ListOptions) url.Values { query := url.Values{} - if options.Limit != 0 { - query.Set("limit", strconv.FormatInt(int64(options.Limit), 10)) + if options.PageSize != 0 { + query.Set("pageSize", strconv.FormatInt(int64(options.PageSize), 10)) } - if options.Continue != "" { - query.Set("continue", options.Continue) + if options.Cursor != "" { + query.Set("cursor", options.Cursor) } - if options.Connector != "" { - query.Set("connector", options.Connector) + if options.Query != "" { + query.Set("query", options.Query) } return query } @@ -220,36 +288,17 @@ func validateResponse(destination any) error { switch value := destination.(type) { case *Connector: return validateConnector(value) - case *ConnectorList: - if value.Items == nil { - return fmt.Errorf("items must be a non-null array") - } - for i := range value.Items { - if err := validateConnector(&value.Items[i]); err != nil { - return fmt.Errorf("items[%d]: %w", i, err) - } - } case *ConnectorVersion: return validateConnectorVersion(value.Version, value.Image) - case *ConnectorVersionList: - if value.Items == nil { - return fmt.Errorf("items must be a non-null array") - } - for i := range value.Items { - if err := validateConnectorVersion(value.Items[i].Version, value.Items[i].Image); err != nil { - return fmt.Errorf("items[%d]: %w", i, err) - } - } case *ConnectorInstance: return validateConnectorInstance(value) - case *ConnectorInstanceList: - if value.Items == nil { - return fmt.Errorf("items must be a non-null array") + case *FacetDistribution: + if value.Facets == nil { + return fmt.Errorf("facets must be a non-null object") } - for i := range value.Items { - if err := validateConnectorInstance(&value.Items[i]); err != nil { - return fmt.Errorf("items[%d]: %w", i, err) - } + case *QueryCapabilities: + if value.Resources == nil { + return fmt.Errorf("resources must be a non-null object") } } return nil @@ -272,6 +321,10 @@ func validateConnectorVersion(version, image string) error { return nil } +func validateConnectorVersionSummary(summary *ConnectorVersionSummary) error { + return validateConnectorVersion(summary.Version, summary.Image) +} + func validateConnectorInstance(instance *ConnectorInstance) error { if instance == nil || instance.Metadata.Name == nil || strings.TrimSpace(*instance.Metadata.Name) == "" { return fmt.Errorf("connector instance metadata.name is required") diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go index 7d296f2e..45a2820a 100644 --- a/internal/connectivityclient/client_test.go +++ b/internal/connectivityclient/client_test.go @@ -28,17 +28,49 @@ func TestListConnectorsBuildsStackConnectivityRequest(t *testing.T) { var seen *http.Request httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { seen = req.Clone(req.Context()) - return jsonResponse(200, `{"items":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}],"continue":"next"}`), nil + return jsonResponse(200, `{"cursor":{"pageSize":25,"hasMore":true,"next":"next-cursor","data":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}]}}`), nil })} - got, err := New("https://stack.example/base", httpClient).ListConnectors(context.Background(), ListOptions{Limit: 25, Continue: "cursor"}) + got, err := New("https://stack.example/base", httpClient).ListConnectors(context.Background(), ListOptions{ + PageSize: 25, + Cursor: "opaque", + Query: `{"$match":{"catalog":"ee"}}`, + }) require.NoError(t, err) require.Equal(t, "/base/api/connectivity/connectors", seen.URL.Path) - require.Equal(t, "25", seen.URL.Query().Get("limit")) - require.Equal(t, "cursor", seen.URL.Query().Get("continue")) + require.Equal(t, "25", seen.URL.Query().Get("pageSize")) + require.Equal(t, "opaque", seen.URL.Query().Get("cursor")) + require.Equal(t, `{"$match":{"catalog":"ee"}}`, seen.URL.Query().Get("query")) require.Equal(t, "stripe", *got.Items[0].Metadata.Name) - require.Equal(t, "next", got.Continue) + require.Equal(t, int32(25), got.PageSize) + require.True(t, got.HasMore) + require.Equal(t, "next-cursor", got.Next) +} + +func TestListConnectorsDecodesCatalogueIdentity(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(200, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{ + "metadata":{"name":"stripe"}, + "spec":{ + "displayName":"Stripe", + "tagline":"Payments for the internet", + "latestVersion":"v2.1.0", + "tags":["provider:psp"], + "branding":{"displayName":"Stripe, Inc.","accentColor":"#635bff"} + } + }]}}`), nil + })} + + got, err := New("https://stack.example", httpClient).ListConnectors(context.Background(), ListOptions{}) + + require.NoError(t, err) + spec := got.Items[0].Spec + require.Equal(t, "Payments for the internet", *spec.Tagline) + require.Equal(t, "v2.1.0", *spec.LatestVersion) + require.Equal(t, []string{"provider:psp"}, spec.Tags) + require.Equal(t, "Stripe, Inc.", *spec.Branding.DisplayName) + require.Equal(t, "#635bff", *spec.Branding.AccentColor) } func TestClientMethodsRespectHTTPContracts(t *testing.T) { @@ -60,11 +92,34 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { name: "lists connectors", wantMethod: http.MethodGet, wantPath: "/base/api/connectivity/connectors", - wantQuery: map[string]string{"connector": "stripe", "limit": "5", "continue": "after"}, + wantQuery: map[string]string{"query": `{"$match":{"catalog":"ee"}}`, "pageSize": "5", "cursor": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"cursor":{"pageSize":5,"hasMore":false,"data":[]}}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{Query: `{"$match":{"catalog":"ee"}}`, PageSize: 5, Cursor: "after"}) + return err + }, + }, + { + name: "gets the connector facet distribution", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/_facets", + wantQuery: map[string]string{"query": `{"$match":{"catalog":"ee"}}`}, responseStatus: http.StatusOK, - responseBody: `{"items":[]}`, + responseBody: `{"total":6,"facets":{"provider":{"psp":6}}}`, call: func(client Client) error { - _, err := client.ListConnectors(context.Background(), ListOptions{Connector: "stripe", Limit: 5, Continue: "after"}) + _, err := client.GetConnectorFacets(context.Background(), `{"$match":{"catalog":"ee"}}`) + return err + }, + }, + { + name: "gets the query capabilities", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/_query/capabilities", + responseStatus: http.StatusOK, + responseBody: `{"resources":{"connectors":{"name":{"operators":["$match"]}}}}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) return err }, }, @@ -80,13 +135,14 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { }, }, { - name: "lists connector versions", + name: "lists connector versions with pagination", wantMethod: http.MethodGet, wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions", + wantQuery: map[string]string{"pageSize": "100", "cursor": "after"}, responseStatus: http.StatusOK, - responseBody: `{"items":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}`, + responseBody: `{"cursor":{"pageSize":100,"hasMore":false,"data":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}}`, call: func(client Client) error { - _, err := client.ListConnectorVersions(context.Background(), connectorName) + _, err := client.ListConnectorVersions(context.Background(), connectorName, ListOptions{PageSize: 100, Cursor: "after"}) return err }, }, @@ -101,15 +157,26 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { return err }, }, + { + name: "resolves a version alias in the version slot", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions/latest", + responseStatus: http.StatusOK, + responseBody: `{"version":"v2.0.0","image":"registry/stripe:v2.0.0"}`, + call: func(client Client) error { + _, err := client.GetConnectorVersion(context.Background(), connectorName, VersionAliasLatest) + return err + }, + }, { name: "lists connector instances", wantMethod: http.MethodGet, wantPath: "/base/api/connectivity/connectorinstances", - wantQuery: map[string]string{"connector": "stripe", "limit": "5", "continue": "after"}, + wantQuery: map[string]string{"query": `{"$match":{"connector":"stripe"}}`, "pageSize": "5", "cursor": "after"}, responseStatus: http.StatusOK, - responseBody: `{"items":[]}`, + responseBody: `{"cursor":{"pageSize":5,"hasMore":false,"data":[]}}`, call: func(client Client) error { - _, err := client.ListConnectorInstances(context.Background(), ListOptions{Connector: "stripe", Limit: 5, Continue: "after"}) + _, err := client.ListConnectorInstances(context.Background(), ListOptions{Query: `{"$match":{"connector":"stripe"}}`, PageSize: 5, Cursor: "after"}) return err }, }, @@ -190,9 +257,52 @@ func TestClientMethodsRespectHTTPContracts(t *testing.T) { } } +func TestListRequestsOmitUnsetOptions(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`), nil + })} + + _, err := New("https://stack.example", httpClient).ListConnectors(context.Background(), ListOptions{}) + + require.NoError(t, err) + require.Empty(t, seen.URL.RawQuery) +} + +func TestGetConnectorFacetsOmitsEmptyQuery(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"total":0,"facets":{}}`), nil + })} + + got, err := New("https://stack.example", httpClient).GetConnectorFacets(context.Background(), "") + + require.NoError(t, err) + require.Empty(t, seen.URL.RawQuery) + require.Zero(t, got.Total) + require.Empty(t, got.Facets) +} + +func TestGetQueryCapabilitiesDecodesResources(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(200, `{"resources":{ + "connectors":{"tags":{"operators":["$match","$in","$exists"]}}, + "connectorinstances":{"channel":{"operators":["$match","$in","$exists"],"enum":["stable","rc","beta","alpha"]}} + }}`), nil + })} + + got, err := New("https://stack.example", httpClient).GetQueryCapabilities(context.Background()) + + require.NoError(t, err) + require.Equal(t, []string{"$match", "$in", "$exists"}, got.Resources[ResourceConnectors]["tags"].Operators) + require.Equal(t, []string{"stable", "rc", "beta", "alpha"}, got.Resources[ResourceConnectorInstances]["channel"].Enum) +} + func TestClientReturnsStructuredAPIError(t *testing.T) { httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, `{"code":"invalid","message":"connector is required","details":{"field":"connector"}}`), nil + return jsonResponse(http.StatusBadRequest, `{"code":"invalid_query","message":"key not allowed","details":{"key":"nope"}}`), nil })} _, err := New("https://stack.example", httpClient).GetConnector(context.Background(), "missing") @@ -200,9 +310,9 @@ func TestClientReturnsStructuredAPIError(t *testing.T) { var apiErr *APIError require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusBadRequest, apiErr.StatusCode) - require.Equal(t, "invalid", apiErr.Code) - require.Equal(t, "connector is required", apiErr.Message) - require.Equal(t, map[string]any{"field": "connector"}, apiErr.Details) + require.Equal(t, "invalid_query", apiErr.Code) + require.Equal(t, "key not allowed", apiErr.Message) + require.Equal(t, map[string]any{"key": "nope"}, apiErr.Details) } // The API owns the CRD reshaping (spec.config -> spec.env/spec.files), so the @@ -285,7 +395,7 @@ func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { name: "empty connector version list object", body: `{}`, call: func(client Client) error { - _, err := client.ListConnectorVersions(context.Background(), "stripe") + _, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) return err }, }, @@ -297,6 +407,22 @@ func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { return err }, }, + { + name: "empty facet distribution object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetConnectorFacets(context.Background(), "") + return err + }, + }, + { + name: "empty query capabilities object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) + return err + }, + }, } for _, tt := range tests { @@ -338,18 +464,27 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { }, }, { - name: "connector list missing items", + name: "connector list missing cursor", status: http.StatusOK, - body: `{"continue":"next"}`, + body: `{"data":[]}`, call: func(client Client) error { _, err := client.ListConnectors(context.Background(), ListOptions{}) return err }, }, { - name: "connector instance list has null items", + name: "connector list has null data", status: http.StatusOK, - body: `{"items":null}`, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":null}}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "connector instance list has null data", + status: http.StatusOK, + body: `{"cursor":{"pageSize":15,"hasMore":false}}`, call: func(client Client) error { _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) return err @@ -367,7 +502,7 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { { name: "connector list item missing metadata name", status: http.StatusOK, - body: `{"items":[{"metadata":{},"spec":{}}]}`, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{},"spec":{}}]}}`, call: func(client Client) error { _, err := client.ListConnectors(context.Background(), ListOptions{}) return err @@ -385,9 +520,9 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { { name: "connector version list item missing version", status: http.StatusOK, - body: `{"items":[{"image":"registry/stripe:v1.0.0"}]}`, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"image":"registry/stripe:v1.0.0"}]}}`, call: func(client Client) error { - _, err := client.ListConnectorVersions(context.Background(), "stripe") + _, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) return err }, }, @@ -412,12 +547,30 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { { name: "connector instance list item missing ledger", status: http.StatusOK, - body: `{"items":[{"metadata":{"name":"worker"},"spec":{"connector":"stripe"}}]}`, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{"name":"worker"},"spec":{"connector":"stripe"}}]}}`, call: func(client Client) error { _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) return err }, }, + { + name: "facet distribution missing facets", + status: http.StatusOK, + body: `{"total":3}`, + call: func(client Client) error { + _, err := client.GetConnectorFacets(context.Background(), "") + return err + }, + }, + { + name: "query capabilities missing resources", + status: http.StatusOK, + body: `{"resources":null}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) + return err + }, + }, } for _, tt := range tests { @@ -435,14 +588,16 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { func TestClientAcceptsValidEmptyLists(t *testing.T) { httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, `{"items":[]}`), nil + return jsonResponse(http.StatusOK, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`), nil })} client := New("https://stack.example", httpClient) connectors, err := client.ListConnectors(context.Background(), ListOptions{}) require.NoError(t, err) require.Empty(t, connectors.Items) - versions, err := client.ListConnectorVersions(context.Background(), "stripe") + require.False(t, connectors.HasMore) + require.Empty(t, connectors.Next) + versions, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) require.NoError(t, err) require.Empty(t, versions.Items) instances, err := client.ListConnectorInstances(context.Background(), ListOptions{}) diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go index 25b95cc0..2a6e788b 100644 --- a/internal/connectivityclient/integration_test.go +++ b/internal/connectivityclient/integration_test.go @@ -18,15 +18,19 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected switch { case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors": require.Equal(t, "application/json", req.Header.Get("Accept")) - _, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}]}`) + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe","latestVersion":"v1.0.0"}}]}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/_facets": + _, _ = io.WriteString(w, `{"total":1,"facets":{"provider":{"psp":1}}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/_query/capabilities": + _, _ = io.WriteString(w, `{"resources":{"connectors":{"name":{"operators":["$match","$in","$like"]}}}}`) case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe": _, _ = io.WriteString(w, `{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}`) case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions": - _, _ = io.WriteString(w, `{"items":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}`) - case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions/v1.0.0": + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions/latest": _, _ = io.WriteString(w, `{"version":"v1.0.0","image":"registry/stripe:v1.0.0","configSchema":{"env":{"properties":{}}}}`) case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectorinstances": - _, _ = io.WriteString(w, `{"items":[]}`) + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`) case req.Method == http.MethodPost && req.URL.Path == "/api/connectivity/connectorinstances": require.Equal(t, "application/json", req.Header.Get("Content-Type")) w.WriteHeader(http.StatusCreated) @@ -50,11 +54,15 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected // When lifecycle methods use the server. connectors, err := client.ListConnectors(ctx, ListOptions{}) require.NoError(t, err) + facets, err := client.GetConnectorFacets(ctx, "") + require.NoError(t, err) + capabilities, err := client.GetQueryCapabilities(ctx) + require.NoError(t, err) connector, err := client.GetConnector(ctx, "stripe") require.NoError(t, err) - versions, err := client.ListConnectorVersions(ctx, "stripe") + versions, err := client.ListConnectorVersions(ctx, "stripe", ListOptions{}) require.NoError(t, err) - version, err := client.GetConnectorVersion(ctx, "stripe", "v1.0.0") + version, err := client.GetConnectorVersion(ctx, "stripe", VersionAliasLatest) require.NoError(t, err) instances, err := client.ListConnectorInstances(ctx, ListOptions{}) require.NoError(t, err) @@ -70,6 +78,10 @@ func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected // Then each response is decoded from the API contract. require.Len(t, connectors.Items, 1) require.Equal(t, "stripe", *connectors.Items[0].Metadata.Name) + require.Equal(t, "v1.0.0", *connectors.Items[0].Spec.LatestVersion) + require.Equal(t, int64(1), facets.Total) + require.Equal(t, int64(1), facets.Facets["provider"]["psp"]) + require.Equal(t, []string{"$match", "$in", "$like"}, capabilities.Resources[ResourceConnectors]["name"].Operators) require.Equal(t, "stripe", *connector.Metadata.Name) require.Len(t, versions.Items, 1) require.Equal(t, "v1.0.0", versions.Items[0].Version) diff --git a/internal/connectivityclient/models.go b/internal/connectivityclient/models.go index 41211afe..af53cd7f 100644 --- a/internal/connectivityclient/models.go +++ b/internal/connectivityclient/models.go @@ -6,6 +6,19 @@ import ( "time" ) +const ( + ResourceConnectors = "connectors" + ResourceConnectorVersions = "connectorversions" + ResourceConnectorInstances = "connectorinstances" +) + +// VersionAliasLatest resolves to the newest Validated version across all +// channels; the channel aliases resolve to that channel's head with exactly +// the rules installation uses. Reserved by the API: never valid semver. +const VersionAliasLatest = "latest" + +var VersionAliases = []string{VersionAliasLatest, "stable", "rc", "beta", "alpha"} + type ObjectMeta struct { Name *string `json:"name,omitempty"` Namespace *string `json:"namespace,omitempty"` @@ -35,12 +48,22 @@ type FileMount struct { Mode *int32 `json:"mode,omitempty"` } +type ConnectorBranding struct { + DisplayName *string `json:"displayName,omitempty"` + AccentColor *string `json:"accentColor,omitempty"` + LogoSvg *string `json:"logoSvg,omitempty"` + LogoSvgDark *string `json:"logoSvgDark,omitempty"` +} + type ConnectorSpec struct { - DisplayName *string `json:"displayName,omitempty"` - Description *string `json:"description,omitempty"` - ImageURL *string `json:"imageUrl,omitempty"` - Catalog *string `json:"catalog,omitempty"` - Tags []string `json:"tags,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` + Catalog *string `json:"catalog,omitempty"` + Tags []string `json:"tags,omitempty"` + Tagline *string `json:"tagline,omitempty"` + Branding *ConnectorBranding `json:"branding,omitempty"` + LatestVersion *string `json:"latestVersion,omitempty"` } type ConnectorStatus struct { @@ -56,7 +79,9 @@ type Connector struct { type ConnectorList struct { Items []Connector `json:"items"` - Continue string `json:"continue,omitempty"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` } type ConnectorVersionSummary struct { @@ -76,7 +101,24 @@ type ConnectorVersion struct { } type ConnectorVersionList struct { - Items []ConnectorVersionSummary `json:"items"` + Items []ConnectorVersionSummary `json:"items"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` +} + +type FacetDistribution struct { + Total int64 `json:"total"` + Facets map[string]map[string]int64 `json:"facets"` +} + +type QueryFieldCapability struct { + Operators []string `json:"operators"` + Enum []string `json:"enum,omitempty"` +} + +type QueryCapabilities struct { + Resources map[string]map[string]QueryFieldCapability `json:"resources"` } type ConnectorInstanceConfig struct { @@ -117,7 +159,9 @@ type ConnectorInstance struct { type ConnectorInstanceList struct { Items []ConnectorInstance `json:"items"` - Continue string `json:"continue,omitempty"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` } type ConnectorInstanceCreate struct { @@ -130,9 +174,9 @@ type ConnectorInstanceCreate struct { type ConnectorInstancePatch map[string]any type ListOptions struct { - Limit int32 - Continue string - Connector string + PageSize int32 + Cursor string + Query string } type APIError struct { @@ -144,8 +188,10 @@ type APIError struct { type Client interface { ListConnectors(context.Context, ListOptions) (*ConnectorList, error) + GetConnectorFacets(context.Context, string) (*FacetDistribution, error) + GetQueryCapabilities(context.Context) (*QueryCapabilities, error) GetConnector(context.Context, string) (*Connector, error) - ListConnectorVersions(context.Context, string) (*ConnectorVersionList, error) + ListConnectorVersions(context.Context, string, ListOptions) (*ConnectorVersionList, error) GetConnectorVersion(context.Context, string, string) (*ConnectorVersion, error) ListConnectorInstances(context.Context, ListOptions) (*ConnectorInstanceList, error) CreateConnectorInstance(context.Context, ConnectorInstanceCreate) (*ConnectorInstance, error) From 49da22c06318ddb15e56fd382f066aa250df2d6f Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:42:02 +0200 Subject: [PATCH 16/21] fix(connectivity): align selector and schema handling --- .../connectorinstances/completion.go | 4 +- cmd/connectivity/connectorinstances/config.go | 339 +++++++++++++++--- .../connectorinstances/config_test.go | 41 +++ .../connectorinstances/configure.go | 47 ++- .../connectorinstances/configure_test.go | 52 +++ .../connectorinstances/install.go | 7 +- .../connectorinstances/install_test.go | 10 +- cmd/connectivity/connectorinstances/list.go | 4 +- .../connectorinstances/list_test.go | 10 +- .../connectorinstances/test_helpers_test.go | 12 +- .../connectorinstances/version.go | 110 +++++- cmd/connectivity/connectors/completion.go | 2 +- cmd/connectivity/internal/filtercompletion.go | 19 +- .../internal/filtercompletion_test.go | 26 ++ cmd/connectivity/internal/paging.go | 41 ++- cmd/connectivity/internal/paging_test.go | 58 +++ cmd/connectivity/internal/query.go | 12 +- cmd/connectivity/internal/query_test.go | 7 + docs/connectivity.md | 66 ++++ 19 files changed, 784 insertions(+), 83 deletions(-) create mode 100644 cmd/connectivity/internal/paging_test.go create mode 100644 docs/connectivity.md diff --git a/cmd/connectivity/connectorinstances/completion.go b/cmd/connectivity/connectorinstances/completion.go index ae7e6932..dc10075c 100644 --- a/cmd/connectivity/connectorinstances/completion.go +++ b/cmd/connectivity/connectorinstances/completion.go @@ -32,7 +32,7 @@ func CompleteConnectorInstanceNames(factory connectivityinternal.ClientFactory) } defer cancel() - instances, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + instances, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorInstance, bool, string, error) { page, err := client.ListConnectorInstances(completionCommand.Context(), options) if err != nil || page == nil { @@ -85,7 +85,7 @@ func completeVersions(factory connectivityinternal.ClientFactory, resolveConnect if err != nil || connector == "" { return nil, cobra.ShellCompDirectiveNoFileComp } - versions, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + versions, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { page, err := client.ListConnectorVersions(completionCommand.Context(), connector, options) if err != nil || page == nil { diff --git a/cmd/connectivity/connectorinstances/config.go b/cmd/connectivity/connectorinstances/config.go index bc5b4ba3..10704022 100644 --- a/cmd/connectivity/connectorinstances/config.go +++ b/cmd/connectivity/connectorinstances/config.go @@ -36,14 +36,38 @@ type InputOptions struct { type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) func SchemaFields(version *connectivityclient.ConnectorVersion) (map[string]SchemaField, error) { + contract, err := schemaContractFor(version) + if err != nil { + return nil, err + } + return contract.fields, nil +} + +type schemaContract struct { + fields map[string]SchemaField + open map[ConfigKind]bool +} + +type schemaFieldInfo struct { + required bool + password bool + description string +} + +type sectionContract struct { + fields map[string]schemaFieldInfo + open bool +} + +func schemaContractFor(version *connectivityclient.ConnectorVersion) (schemaContract, error) { if version == nil { - return nil, fmt.Errorf("connector version is required") + return schemaContract{}, fmt.Errorf("connector version is required") } if len(version.ConfigSchema) == 0 { - return map[string]SchemaField{}, nil + return schemaContract{fields: map[string]SchemaField{}, open: map[ConfigKind]bool{}}, nil } - fields := make(map[string]SchemaField) + contract := schemaContract{fields: make(map[string]SchemaField), open: make(map[ConfigKind]bool)} sections := []struct { name string kind ConfigKind @@ -66,43 +90,250 @@ func SchemaFields(version *connectivityclient.ConnectorVersion) (map[string]Sche } sectionSchema, ok := stringMap(rawSection) if !ok { - return nil, fmt.Errorf("connector config schema %s must be an object", section.name) + return schemaContract{}, fmt.Errorf("connector config schema %s must be an object", section.name) } - sectionProperties, ok := stringMap(sectionSchema["properties"]) - if !ok { - if sectionSchema["properties"] == nil { - sectionProperties = map[string]any{} - } else { - return nil, fmt.Errorf("connector config schema %s properties must be an object", section.name) + collected, err := collectSectionSchema(sectionSchema, sectionSchema, 0, map[string]bool{}) + if err != nil { + return schemaContract{}, fmt.Errorf("connector config schema %s: %w", section.name, err) + } + contract.open[section.kind] = collected.open + for key, definition := range collected.fields { + if _, exists := contract.fields[key]; exists { + return schemaContract{}, fmt.Errorf("configuration key %q is declared in both env and files", key) + } + contract.fields[key] = SchemaField{ + Key: key, + Kind: section.kind, + Required: definition.required, + Password: definition.password, + Description: definition.description, } } - required, err := requiredSet(sectionSchema["required"]) + } + + return contract, nil +} + +const maxSchemaCollectionDepth = 64 + +func collectSectionSchema(raw any, root map[string]any, depth int, refs map[string]bool) (sectionContract, error) { + if depth > maxSchemaCollectionDepth { + return sectionContract{}, fmt.Errorf("schema nesting exceeds %d", maxSchemaCollectionDepth) + } + node, ok := stringMap(raw) + if !ok { + return sectionContract{}, fmt.Errorf("schema must be an object") + } + result := sectionContract{fields: map[string]schemaFieldInfo{}, open: sectionOpen(node)} + properties, ok := stringMap(node["properties"]) + if !ok && node["properties"] != nil { + return sectionContract{}, fmt.Errorf("properties must be an object") + } + required, err := requiredSet(node["required"]) + if err != nil { + return sectionContract{}, err + } + for key, definition := range properties { + metadata, err := collectFieldMetadata(definition, root, depth+1, refs) if err != nil { - return nil, fmt.Errorf("connector config schema %s: %w", section.name, err) + return sectionContract{}, fmt.Errorf("field %q: %w", key, err) } - - for key, rawDefinition := range sectionProperties { - if _, exists := fields[key]; exists { - return nil, fmt.Errorf("configuration key %q is declared in both env and files", key) + metadata.required = required[key] + result.fields[key] = metadata + } + for key := range required { + if _, found := result.fields[key]; !found { + result.fields[key] = schemaFieldInfo{required: true} + } + } + if ref, _ := node["$ref"].(string); ref != "" { + target, err := resolveLocalSchemaRef(root, ref) + if err != nil { + return sectionContract{}, err + } + if refs[ref] { + return sectionContract{}, fmt.Errorf("cyclic local reference %q", ref) + } + refs[ref] = true + referenced, err := collectSectionSchema(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return sectionContract{}, err + } + result = mergeAllSections(result, referenced) + } + for _, keyword := range []string{"allOf"} { + parts, err := schemaArray(node[keyword]) + if err != nil { + return sectionContract{}, fmt.Errorf("%s: %w", keyword, err) + } + for _, part := range parts { + collected, err := collectSectionSchema(part, root, depth+1, refs) + if err != nil { + return sectionContract{}, err } - definition, ok := stringMap(rawDefinition) - if !ok { - return nil, fmt.Errorf("connector config schema field %q must be an object", key) + result = mergeAllSections(result, collected) + } + } + for _, keyword := range []string{"anyOf", "oneOf"} { + parts, err := schemaArray(node[keyword]) + if err != nil { + return sectionContract{}, fmt.Errorf("%s: %w", keyword, err) + } + if len(parts) == 0 { + continue + } + alternatives := make([]sectionContract, 0, len(parts)) + for _, part := range parts { + collected, err := collectSectionSchema(part, root, depth+1, refs) + if err != nil { + return sectionContract{}, err } - format, _ := definition["format"].(string) - legacySecret, _ := definition["x-secret"].(bool) - description, _ := definition["description"].(string) - fields[key] = SchemaField{ - Key: key, - Kind: section.kind, - Required: required[key], - Password: format == "password" || legacySecret, - Description: description, + alternatives = append(alternatives, collected) + } + result = mergeAllSections(result, mergeAlternativeSections(alternatives)) + } + return result, nil +} + +func schemaArray(raw any) ([]any, error) { + if raw == nil { + return nil, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("must be an array") + } + return values, nil +} + +func sectionOpen(node map[string]any) bool { + additional, present := node["additionalProperties"] + open := !present || additional != false + if patterns, ok := stringMap(node["patternProperties"]); ok && len(patterns) > 0 { + return true + } + return open +} + +func mergeAllSections(left, right sectionContract) sectionContract { + merged := sectionContract{fields: make(map[string]schemaFieldInfo), open: left.open && right.open} + for key, value := range left.fields { + merged.fields[key] = value + } + for key, value := range right.fields { + current, exists := merged.fields[key] + if !exists { + merged.fields[key] = value + continue + } + merged.fields[key] = schemaFieldInfo{required: current.required || value.required, password: current.password || value.password, description: firstDescription(current.description, value.description)} + } + return merged +} + +func mergeAlternativeSections(alternatives []sectionContract) sectionContract { + merged := sectionContract{fields: make(map[string]schemaFieldInfo)} + for index, alternative := range alternatives { + merged.open = merged.open || alternative.open + for key, value := range alternative.fields { + current, exists := merged.fields[key] + if !exists { + value.required = index == 0 && value.required + merged.fields[key] = value + continue + } + current.required = current.required && value.required + current.password = current.password || value.password + current.description = firstDescription(current.description, value.description) + merged.fields[key] = current + } + if index > 0 { + for key, value := range merged.fields { + if _, exists := alternative.fields[key]; !exists { + value.required = false + merged.fields[key] = value + } } } } + return merged +} - return fields, nil +func collectFieldMetadata(raw any, root map[string]any, depth int, refs map[string]bool) (schemaFieldInfo, error) { + node, ok := stringMap(raw) + if !ok { + return schemaFieldInfo{}, nil // Boolean schemas are delegated to the API. + } + result := schemaFieldInfo{} + if format, _ := node["format"].(string); format == "password" { + result.password = true + } + if secret, _ := node["x-secret"].(bool); secret { + result.password = true + } + result.description, _ = node["description"].(string) + if ref, _ := node["$ref"].(string); ref != "" { + target, err := resolveLocalSchemaRef(root, ref) + if err != nil { + return schemaFieldInfo{}, err + } + if refs[ref] { + return schemaFieldInfo{}, fmt.Errorf("cyclic local reference %q", ref) + } + refs[ref] = true + resolved, err := collectFieldMetadata(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return schemaFieldInfo{}, err + } + result.password = result.password || resolved.password + result.description = firstDescription(result.description, resolved.description) + } + for _, part := range appendSchemaCompositions(node) { + metadata, err := collectFieldMetadata(part, root, depth+1, refs) + if err != nil { + return schemaFieldInfo{}, err + } + result.password = result.password || metadata.password + result.description = firstDescription(result.description, metadata.description) + } + return result, nil +} + +func appendSchemaCompositions(node map[string]any) []any { + parts := make([]any, 0) + for _, keyword := range []string{"allOf", "anyOf", "oneOf"} { + if values, ok := node[keyword].([]any); ok { + parts = append(parts, values...) + } + } + return parts +} + +func resolveLocalSchemaRef(root map[string]any, ref string) (any, error) { + if !strings.HasPrefix(ref, "#/") { + return nil, fmt.Errorf("external schema reference %q is not supported", ref) + } + var current any = root + for _, token := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + object, ok := stringMap(current) + if !ok { + return nil, fmt.Errorf("local reference %q does not resolve to an object", ref) + } + current, ok = object[strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")] + if !ok { + return nil, fmt.Errorf("local reference %q was not found", ref) + } + } + return current, nil +} + +func firstDescription(left, right string) string { + if left != "" { + return left + } + return right } func BuildInstallConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { @@ -114,10 +345,11 @@ func BuildConfigureConfig(cmd *cobra.Command, version *connectivityclient.Connec } func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, base *connectivityclient.ConnectorInstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { - fields, err := SchemaFields(version) + contract, err := schemaContractFor(version) if err != nil { return nil, err } + fields := contract.fields config := cloneConfig(base) unknown := make(map[string]struct{}) @@ -126,7 +358,7 @@ func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersio if err != nil { return nil, fmt.Errorf("reading config %q: %w", inputs.ConfigFile, err) } - if err := applyConfigDocument(config, fields, contents, unknown); err != nil { + if err := applyConfigDocument(config, contract, contents, unknown); err != nil { return nil, fmt.Errorf("parsing config %q: %w", inputs.ConfigFile, err) } } @@ -136,7 +368,7 @@ func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersio if err != nil { return nil, fmt.Errorf("reading env file %q: %w", path, err) } - if err := applyDotenv(config, fields, contents, unknown); err != nil { + if err := applyDotenv(config, contract, contents, unknown); err != nil { return nil, fmt.Errorf("parsing env file %s:%w", path, err) } } @@ -146,7 +378,7 @@ func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersio if err != nil { return nil, err } - field, exists := fields[key] + field, exists := assignmentField(contract, key) if !exists { unknown[key] = struct{}{} continue @@ -251,7 +483,7 @@ func parseAssignment(raw string) (string, string, error) { return key, parts[1], nil } -func applyDotenv(config *connectivityclient.ConnectorInstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { +func applyDotenv(config *connectivityclient.ConnectorInstanceConfig, contract schemaContract, contents string, unknown map[string]struct{}) error { scanner := bufio.NewScanner(strings.NewReader(contents)) line := 0 for scanner.Scan() { @@ -270,8 +502,8 @@ func applyDotenv(config *connectivityclient.ConnectorInstanceConfig, fields map[ return fmt.Errorf("%d: expected KEY=value", line) } value := unquoteEnvValue(raw[equals+1:]) - field, exists := fields[key] - if !exists || field.Kind != ConfigEnv { + field, exists := configField(contract, key, ConfigEnv) + if !exists { unknown[key] = struct{}{} continue } @@ -351,7 +583,7 @@ func applyDoubleQuoteEscapes(value string) string { return decoded.String() } -func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fields map[string]SchemaField, contents string, unknown map[string]struct{}) error { +func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, contract schemaContract, contents string, unknown map[string]struct{}) error { var root map[string]any if err := yaml.Unmarshal([]byte(contents), &root); err != nil { return err @@ -364,8 +596,8 @@ func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fie _, hasFiles := root["files"] if !hasEnv && !hasFiles { for key, raw := range root { - field, exists := fields[key] - if !exists || field.Kind != ConfigEnv { + field, exists := configField(contract, key, ConfigEnv) + if !exists { unknown[key] = struct{}{} continue } @@ -388,8 +620,8 @@ func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fie return err } for key, value := range document.Env { - field, exists := fields[key] - if !exists || field.Kind != ConfigEnv { + _, exists := configField(contract, key, ConfigEnv) + if !exists { unknown[key] = struct{}{} continue } @@ -407,8 +639,8 @@ func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fie if strings.TrimSpace(file.Path) == "" { return fmt.Errorf("structured configuration file path is required") } - field, exists := fields[file.Path] - if !exists || field.Kind != ConfigFile { + _, exists := configField(contract, file.Path, ConfigFile) + if !exists { unknown[file.Path] = struct{}{} continue } @@ -427,6 +659,27 @@ func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, fie return nil } +func configField(contract schemaContract, key string, kind ConfigKind) (SchemaField, bool) { + if field, exists := contract.fields[key]; exists { + return field, field.Kind == kind + } + if contract.open[kind] { + return SchemaField{Key: key, Kind: kind}, true + } + return SchemaField{}, false +} + +func assignmentField(contract schemaContract, key string) (SchemaField, bool) { + if field, exists := contract.fields[key]; exists { + return field, true + } + kind := ConfigEnv + if strings.HasPrefix(key, "/") { + kind = ConfigFile + } + return configField(contract, key, kind) +} + func (ref *documentRef) clientRef() *connectivityclient.KeyRef { if ref == nil { return nil diff --git a/cmd/connectivity/connectorinstances/config_test.go b/cmd/connectivity/connectorinstances/config_test.go index f42449e5..f840d3dc 100644 --- a/cmd/connectivity/connectorinstances/config_test.go +++ b/cmd/connectivity/connectorinstances/config_test.go @@ -87,6 +87,47 @@ func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { }, fields["LEGACY_TOKEN"]) } +func TestSchemaFieldsCollectsLocalReferencesAndCompositionsWithoutOverstatingRequiredKeys(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "$defs": map[string]any{ + "token": map[string]any{"type": "string", "format": "password", "description": "API token"}, + }, + "allOf": []any{ + map[string]any{"properties": map[string]any{"TOKEN": map[string]any{"$ref": "#/$defs/token"}}, "required": []any{"TOKEN"}}, + map[string]any{"anyOf": []any{ + map[string]any{"properties": map[string]any{"CLIENT_ID": map[string]any{"type": "string"}}, "required": []any{"CLIENT_ID"}}, + map[string]any{"properties": map[string]any{"CLIENT_SECRET": map[string]any{"type": "string"}}, "required": []any{"CLIENT_SECRET"}}, + }}, + }, + }, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.True(t, fields["TOKEN"].Required) + require.True(t, fields["TOKEN"].Password) + require.Equal(t, "API token", fields["TOKEN"].Description) + require.False(t, fields["CLIENT_ID"].Required, "either alternative can satisfy the schema") + require.False(t, fields["CLIENT_SECRET"].Required, "either alternative can satisfy the schema") +} + +func TestBuildInstallConfigDefersOpenAndPatternSchemaKeysToTheAPI(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "type": "object", + "properties": map[string]any{"KNOWN": map[string]any{"type": "string"}}, + "patternProperties": map[string]any{"^DYNAMIC_": map[string]any{"type": "string"}}, + }, + }} + + config, err := BuildInstallConfig(&cobra.Command{}, version, InputOptions{SetValues: []string{"DYNAMIC_TOKEN=value"}}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "value", *config.Env["DYNAMIC_TOKEN"].Value) +} + func TestBuildInstallConfigAllowsConnectorVersionWithoutSchema(t *testing.T) { version := &connectivityclient.ConnectorVersion{Version: "2.0.0", Image: "example/connector:2.0.0"} diff --git a/cmd/connectivity/connectorinstances/configure.go b/cmd/connectivity/connectorinstances/configure.go index 8515a1c9..39b6a91d 100644 --- a/cmd/connectivity/connectorinstances/configure.go +++ b/cmd/connectivity/connectorinstances/configure.go @@ -43,6 +43,7 @@ func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFi fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), fctl.WithStringFlag(versionFlag, "", "Connector version"), + fctl.WithStringFlag(channelFlag, "", "Maturity channel to track (stable, rc, beta, alpha) when no version is pinned"), fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), @@ -53,6 +54,9 @@ func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFi if err := command.RegisterFlagCompletionFunc(versionFlag, completeVersions(factory, resolveConfigureConnector)); err != nil { panic(err) } + if err := command.RegisterFlagCompletionFunc(channelFlag, connectivityinternal.CompleteChannels(factory)); err != nil { + panic(err) + } if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveConfigureConnectorVersion, paths)); err != nil { panic(err) } @@ -78,13 +82,32 @@ func resolveConfigureConnectorVersion(ctx context.Context, client connectivitycl if err != nil || instance == nil { return nil, err } - pinned := fctl.GetString(cmd, versionFlag) - if pinned == "" { - pinned = instanceVersionPin(instance) + pinned, channel := configureVersionSelector(cmd, instance) + if channel != "" { + return resolveChannelVersion(ctx, client, instance.Spec.Connector, channel, instanceVersionPin(instance)) } return resolveConnectorVersion(ctx, client, instance.Spec.Connector, pinned) } +func configureVersionSelector(cmd *cobra.Command, instance *connectivityclient.ConnectorInstance) (string, string) { + if cmd.Flags().Changed(versionFlag) { + return fctl.GetString(cmd, versionFlag), "" + } + if cmd.Flags().Changed(channelFlag) { + return "", fctl.GetString(cmd, channelFlag) + } + if instance != nil && instance.Spec.Version != nil && *instance.Spec.Version != "" { + return *instance.Spec.Version, "" + } + if instance != nil && instance.Spec.Channel != nil && *instance.Spec.Channel != "" { + return "", *instance.Spec.Channel + } + if applied := instanceVersionPin(instance); applied != "" { + return applied, "" + } + return "stable", "" +} + func (c *ConfigureController) GetStore() *ConfigureStore { return c.store } @@ -110,11 +133,13 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende specPatch := map[string]any{} configChanged := cmd.Flags().Changed(configFlag) || cmd.Flags().Changed(envFileFlag) || cmd.Flags().Changed(setFlag) if configChanged { - pinned := instanceVersionPin(instance) - if cmd.Flags().Changed(versionFlag) { - pinned = fctl.GetString(cmd, versionFlag) + pinned, channel := configureVersionSelector(cmd, instance) + var version *connectivityclient.ConnectorVersion + if channel != "" { + version, err = resolveChannelVersion(cmd.Context(), client, instance.Spec.Connector, channel, instanceVersionPin(instance)) + } else { + version, err = resolveConnectorVersion(cmd.Context(), client, instance.Spec.Connector, pinned) } - version, err := resolveConnectorVersion(cmd.Context(), client, instance.Spec.Connector, pinned) if err != nil { return nil, err } @@ -139,6 +164,14 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende if cmd.Flags().Changed(versionFlag) { specPatch["version"] = fctl.GetString(cmd, versionFlag) } + if cmd.Flags().Changed(channelFlag) { + specPatch["channel"] = fctl.GetString(cmd, channelFlag) + if !cmd.Flags().Changed(versionFlag) { + // A pre-existing pin would otherwise continue to win over the new + // channel. JSON null deletes it under the API's merge-patch contract. + specPatch["version"] = nil + } + } if cmd.Flags().Changed(ledgerFlag) { specPatch["ledger"] = fctl.GetString(cmd, ledgerFlag) } diff --git a/cmd/connectivity/connectorinstances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go index 0c94f046..cb25abab 100644 --- a/cmd/connectivity/connectorinstances/configure_test.go +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -147,6 +147,58 @@ func TestConfigureFallsBackToTheAppliedVersionWhenUnpinned(t *testing.T) { require.Equal(t, "1.9.9", gotVersion) } +func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.0.0") + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedVersion = stringPtr("v1.0.0") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(_ context.Context, _ string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + switch options.Cursor { + case "": + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "v1.1.0", Image: "example:v1.1.0"}, + {Version: "v1.2.0-beta.1", Image: "example:v1.2.0-beta.1"}, + }, HasMore: true, Next: "second"}, nil + case "second": + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "v1.3.0", Image: "example:v1.3.0"}, + {Version: "v2.0.0", Image: "example:v2.0.0"}, + }}, nil + default: + t.Fatalf("unexpected version cursor %q", options.Cursor) + return nil, nil + } + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=stable", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "v1.3.0", gotVersion, "the server selects the highest stable candidate in the running major") + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "stable", spec["channel"]) + require.Contains(t, spec, "version", "switching to a channel removes an existing pin") + require.Nil(t, spec["version"]) +} + func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *testing.T) { current := instanceFixture("stripe-eu") current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} diff --git a/cmd/connectivity/connectorinstances/install.go b/cmd/connectivity/connectorinstances/install.go index 2da6d6ae..7ae8ab99 100644 --- a/cmd/connectivity/connectorinstances/install.go +++ b/cmd/connectivity/connectorinstances/install.go @@ -97,12 +97,15 @@ func resolveInstallConnectorVersion(ctx context.Context, client connectivityclie // installVersionSelector picks the {version} slot value governing the config // schema: the explicit pin, else the tracked channel alias (resolved with -// exactly the rules installation uses), else empty for `latest`. +// exactly the rules installation uses), else `stable` for the API default. func installVersionSelector(cmd *cobra.Command) string { if pinned := fctl.GetString(cmd, versionFlag); pinned != "" { return pinned } - return fctl.GetString(cmd, channelFlag) + if channel := fctl.GetString(cmd, channelFlag); channel != "" { + return channel + } + return "stable" } func (c *InstallController) GetStore() *InstallStore { diff --git a/cmd/connectivity/connectorinstances/install_test.go b/cmd/connectivity/connectorinstances/install_test.go index 6a6903e3..20574eb8 100644 --- a/cmd/connectivity/connectorinstances/install_test.go +++ b/cmd/connectivity/connectorinstances/install_test.go @@ -78,7 +78,7 @@ func TestInstallBuildsConnectorInstanceFromVersionSchemaAndOnlySetsChangedScalar require.Equal(t, `Connector instance "stripe-eu" installed with connector "stripe" for ledger "main".`, strings.TrimSpace(output)) } -func TestInstallWithoutVersionFlagUsesServerResolvedLatestForTheSchema(t *testing.T) { +func TestInstallWithoutSelectorUsesStableHeadForTheSchema(t *testing.T) { var gotVersion string var gotBody connectivityclient.ConnectorInstanceCreate client := mutationClientMock{ @@ -100,7 +100,9 @@ func TestInstallWithoutVersionFlagUsesServerResolvedLatestForTheSchema(t *testin ) require.NoError(t, err) - require.Equal(t, connectivityclient.VersionAliasLatest, gotVersion) + // A newer prerelease must not supply the schema for a selector-less + // install: the API persists the stable head in that case. + require.Equal(t, "stable", gotVersion) require.Nil(t, gotBody.Spec.Version, "an unpinned install must let the server resolve the version") } @@ -132,10 +134,10 @@ func TestInstallWithChannelFlagTracksTheChannelAndUsesItsHeadForTheSchema(t *tes require.Nil(t, gotBody.Spec.Version) } -func TestInstallReportsConnectorsWithoutAPublishedVersion(t *testing.T) { +func TestInstallReportsPrereleaseOnlyCataloguesAsMissingStableVersion(t *testing.T) { client := mutationClientMock{connectorVersions: connectorVersions{ getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { - return nil, &connectivityclient.APIError{StatusCode: 404, Code: "not_found", Message: "no Validated version"} + return nil, &connectivityclient.APIError{StatusCode: 404, Code: "channel_empty", Message: "only v2.0.0-beta.1 is published"} }, }} diff --git a/cmd/connectivity/connectorinstances/list.go b/cmd/connectivity/connectorinstances/list.go index d4f13447..9d74f3cc 100644 --- a/cmd/connectivity/connectorinstances/list.go +++ b/cmd/connectivity/connectorinstances/list.go @@ -120,6 +120,8 @@ func (c *ListController) Render(cmd *cobra.Command, _ []string) error { stringValue(instance.Metadata.Name), instance.Spec.Connector, stringValue(instance.Spec.Version), + stringValue(instance.Spec.Channel), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedVersion }), instance.Spec.Ledger, instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase }), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State }), @@ -129,7 +131,7 @@ func (c *ListController) Render(cmd *cobra.Command, _ []string) error { } }) rows = fctl.Prepend(rows, []string{ - "Name", "Connector", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "Name", "Connector", "Version", "Channel", "Resolved Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", }) if err := pterm.DefaultTable. WithHasHeader(). diff --git a/cmd/connectivity/connectorinstances/list_test.go b/cmd/connectivity/connectorinstances/list_test.go index 9b859aca..75189140 100644 --- a/cmd/connectivity/connectorinstances/list_test.go +++ b/cmd/connectivity/connectorinstances/list_test.go @@ -26,10 +26,14 @@ func (m listInstanceClientMock) ListConnectorInstances(ctx context.Context, opti func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *testing.T) { var gotOptions connectivityclient.ListOptions + channelInstance := instanceFixture("stripe-eu") + channelInstance.Spec.Version = nil + channelInstance.Spec.Channel = stringPtr("stable") + channelInstance.Status.ResolvedVersion = stringPtr("v1.3.0") client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { gotOptions = options return &connectivityclient.ConnectorInstanceList{ - Items: []connectivityclient.ConnectorInstance{instanceFixture("stripe-eu")}, + Items: []connectivityclient.ConnectorInstance{channelInstance}, PageSize: 7, HasMore: true, Next: "next-page", @@ -46,8 +50,8 @@ func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColum } require.Equal(t, wantOptions, gotOptions) for _, expected := range []string{ - "Name", "Connector", "Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", - "stripe-eu", "stripe", "2.0.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", + "Name", "Connector", "Version", "Channel", "Resolved Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "stripe-eu", "stripe", "stable", "v1.3.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", "HasMore", "true", "PageSize", "7", "Next", "next-page", } { require.Contains(t, output, expected) diff --git a/cmd/connectivity/connectorinstances/test_helpers_test.go b/cmd/connectivity/connectorinstances/test_helpers_test.go index da43c779..8f183579 100644 --- a/cmd/connectivity/connectorinstances/test_helpers_test.go +++ b/cmd/connectivity/connectorinstances/test_helpers_test.go @@ -132,14 +132,16 @@ func versionWithFileSchema() *connectivityclient.ConnectorVersion { ConfigSchema: map[string]any{ "type": "object", "env": map[string]any{ - "type": "object", + "type": "object", + "additionalProperties": false, "properties": map[string]any{ "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, }, "required": []any{}, }, "files": map[string]any{ - "type": "object", + "type": "object", + "additionalProperties": false, "properties": map[string]any{ "/etc/a": map[string]any{"type": "string", "description": "Primary config"}, "/etc/b": map[string]any{"type": "string", "format": "password"}, @@ -174,7 +176,8 @@ func fullConfigSchema(requiredEnv, requiredFiles []any) map[string]any { return map[string]any{ "type": "object", "env": map[string]any{ - "type": "object", + "type": "object", + "additionalProperties": false, "properties": map[string]any{ "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, "TOKEN": map[string]any{"type": "string", "format": "password", "description": "Authentication token"}, @@ -183,7 +186,8 @@ func fullConfigSchema(requiredEnv, requiredFiles []any) map[string]any { "required": requiredEnv, }, "files": map[string]any{ - "type": "object", + "type": "object", + "additionalProperties": false, "properties": map[string]any{ "/etc/plugin/config.yaml": map[string]any{"type": "string", "description": "Connector configuration"}, "/etc/plugin/ca.pem": map[string]any{"type": "string", "format": "password"}, diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go index 39e6ab61..93b21018 100644 --- a/cmd/connectivity/connectorinstances/version.go +++ b/cmd/connectivity/connectorinstances/version.go @@ -5,23 +5,33 @@ import ( "errors" "fmt" "net/http" + "strings" + xmodsemver "golang.org/x/mod/semver" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) +const ( + versionResolvePageSize = int32(100) + versionResolveMaxPages = 100 +) + // resolveConnectorVersion returns the ConnectorVersion whose configSchema governs // the configuration being assembled: the requested pin (or channel alias) when -// there is one, the server-resolved `latest` alias otherwise. +// there is one, the server-resolved stable head otherwise. This mirrors an +// unselected install, whose API write persists the stable selector. func resolveConnectorVersion(ctx context.Context, client connectivityclient.Client, connector, pinned string) (*connectivityclient.ConnectorVersion, error) { if connector == "" { return nil, fmt.Errorf("connector name is required") } if pinned == "" { - pinned = connectivityclient.VersionAliasLatest + pinned = "stable" } version, err := client.GetConnectorVersion(ctx, connector, pinned) var apiErr *connectivityclient.APIError - if pinned == connectivityclient.VersionAliasLatest && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + if (pinned == "stable" || pinned == connectivityclient.VersionAliasLatest) && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("connectivity connector %q has no published version: %w", connector, err) } if err != nil { @@ -48,3 +58,97 @@ func instanceVersionPin(instance *connectivityclient.ConnectorInstance) string { } return "" } + +// resolveChannelVersion mirrors the operator resolver for the schema selected +// during a channel-tracked configure. It reads every version page because the +// selected channel head is not necessarily present on the first page. +func resolveChannelVersion(ctx context.Context, client connectivityclient.Client, connector, channel, running string) (*connectivityclient.ConnectorVersion, error) { + versions, err := connectivityinternal.CollectPages(versionResolvePageSize, versionResolveMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(ctx, connector, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("list connectivity connector %q versions: empty response", connector) + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, err + } + candidates := make([]string, 0, len(versions)) + for _, version := range versions { + candidates = append(candidates, version.Version) + } + selected, err := selectChannelVersion(candidates, channel, running) + if err != nil { + return nil, fmt.Errorf("resolve connectivity connector %q channel %q: %w", connector, channel, err) + } + return resolveConnectorVersion(ctx, client, connector, selected) +} + +func selectChannelVersion(versions []string, channel, running string) (string, error) { + allowed, ok := channelPrereleases[channel] + if !ok { + return "", fmt.Errorf("unsupported channel %q", channel) + } + if running != "" && !isStrictSemver(running) { + return "", fmt.Errorf("parsing running version %q: invalid semantic version", running) + } + chosen := "" + for _, version := range versions { + if !isStrictSemver(version) { + return "", fmt.Errorf("parsing candidate version %q: invalid semantic version", version) + } + if !channelAdmits(version, allowed) || (running != "" && (xmodsemver.Major(normalizeSemver(version)) != xmodsemver.Major(normalizeSemver(running)) || compareSemver(version, running) < 0)) { + continue + } + if chosen == "" || compareSemver(version, chosen) > 0 { + chosen = version + } + } + if chosen == "" { + return "", fmt.Errorf("no candidate version satisfies channel %q", channel) + } + return chosen, nil +} + +var channelPrereleases = map[string]map[string]struct{}{ + "stable": {}, + "rc": {"rc": {}}, + "beta": {"rc": {}, "beta": {}}, + "alpha": {"rc": {}, "beta": {}, "alpha": {}}, +} + +func channelAdmits(version string, allowed map[string]struct{}) bool { + prerelease := strings.TrimPrefix(xmodsemver.Prerelease(normalizeSemver(version)), "-") + if prerelease == "" { + return true + } + identifier, _, _ := strings.Cut(prerelease, ".") + _, ok := allowed[identifier] + return ok +} + +func isStrictSemver(version string) bool { + core := strings.TrimPrefix(version, "v") + if suffix := strings.IndexAny(core, "-+"); suffix >= 0 { + core = core[:suffix] + } + return len(strings.Split(core, ".")) == 3 && xmodsemver.IsValid(normalizeSemver(version)) +} + +func normalizeSemver(version string) string { + if strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} + +func compareSemver(left, right string) int { + if result := xmodsemver.Compare(normalizeSemver(left), normalizeSemver(right)); result != 0 { + return result + } + return strings.Compare(left, right) +} diff --git a/cmd/connectivity/connectors/completion.go b/cmd/connectivity/connectors/completion.go index 4edd2cee..205961d3 100644 --- a/cmd/connectivity/connectors/completion.go +++ b/cmd/connectivity/connectors/completion.go @@ -20,7 +20,7 @@ func CompleteConnectorNames(factory connectivityinternal.ClientFactory) cobra.Co } defer cancel() - connectors, err := connectivityinternal.CollectPages(connectivityinternal.CompletionPageSize, connectorCompletionMaxPages, + connectors, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, connectorCompletionMaxPages, func(options connectivityclient.ListOptions) ([]connectivityclient.Connector, bool, string, error) { page, err := client.ListConnectors(completionCommand.Context(), options) if err != nil || page == nil { diff --git a/cmd/connectivity/internal/filtercompletion.go b/cmd/connectivity/internal/filtercompletion.go index 767c2882..0436daf2 100644 --- a/cmd/connectivity/internal/filtercompletion.go +++ b/cmd/connectivity/internal/filtercompletion.go @@ -15,6 +15,7 @@ import ( const ( CompletionTimeout = 2 * time.Second CompletionPageSize = int32(100) + CompletionMaxPages = 5 ) // CompletionClient builds a non-interactive, deadline-bounded client for @@ -174,12 +175,22 @@ func facetFilterValues(ctx context.Context, client connectivityclient.Client) [] } func connectorFilterValues(ctx context.Context, client connectivityclient.Client) []filterValue { - connectors, err := client.ListConnectors(ctx, connectivityclient.ListOptions{PageSize: CompletionPageSize}) - if err != nil || connectors == nil { + connectors, err := CollectPagesBounded(CompletionPageSize, CompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.Connector, bool, string, error) { + page, err := client.ListConnectors(ctx, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("empty connector completion response") + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { return nil } - values := make([]filterValue, 0, len(connectors.Items)) - for _, connector := range connectors.Items { + values := make([]filterValue, 0, len(connectors)) + for _, connector := range connectors { if connector.Metadata.Name == nil || *connector.Metadata.Name == "" { continue } diff --git a/cmd/connectivity/internal/filtercompletion_test.go b/cmd/connectivity/internal/filtercompletion_test.go index 4821cbdc..86054004 100644 --- a/cmd/connectivity/internal/filtercompletion_test.go +++ b/cmd/connectivity/internal/filtercompletion_test.go @@ -174,6 +174,32 @@ func TestCompleteFilterExpressionsCompletesConnectorNamesForInstanceConnectorKey } } +func TestCompleteFilterExpressionsDrainsBoundedConnectorPages(t *testing.T) { + first, second := "adyen", "stripe" + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + switch options.Cursor { + case "": + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{{Metadata: connectivityclient.ObjectMeta{Name: &first}}}, HasMore: true, Next: "second"}, nil + case "second": + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{{Metadata: connectivityclient.ObjectMeta{Name: &second}}}}, nil + default: + t.Fatalf("unexpected connector cursor %q", options.Cursor) + return nil, nil + } + }, + } + + candidates, _ := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances)(&cobra.Command{}, nil, "connector=") + + if want := []string{"connector=adyen", "connector=stripe"}; !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + func TestCompleteChannelsServesTheCapabilityEnum(t *testing.T) { client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { return capabilitiesFixture(), nil diff --git a/cmd/connectivity/internal/paging.go b/cmd/connectivity/internal/paging.go index eececfe7..217498f1 100644 --- a/cmd/connectivity/internal/paging.go +++ b/cmd/connectivity/internal/paging.go @@ -1,25 +1,56 @@ package internal import ( + "fmt" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" ) // CollectPages drains a cursor-paginated list endpoint, following -// `cursor.next` until the server reports the last page or maxPages is -// reached (a bound against pathological continuations). +// `cursor.next` until the server reports the last page. A user-facing read +// must never render an unmarked prefix, so malformed and truncated cursors are +// reported as errors. func CollectPages[T any](pageSize int32, maxPages int, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + return collectPages(pageSize, maxPages, false, fetch) +} + +// CollectPagesBounded is for best-effort shell completion. It returns the +// useful prefix at the intentional page bound, while retaining validation of +// malformed cursors before that bound. +func CollectPagesBounded[T any](pageSize int32, maxPages int, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + return collectPages(pageSize, maxPages, true, fetch) +} + +func collectPages[T any](pageSize int32, maxPages int, allowTruncation bool, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + if maxPages <= 0 { + return nil, fmt.Errorf("pagination maximum pages must be positive") + } options := connectivityclient.ListOptions{PageSize: pageSize} items := make([]T, 0) + seen := make(map[string]struct{}) for page := 0; page < maxPages; page++ { pageItems, hasMore, next, err := fetch(options) if err != nil { return nil, err } items = append(items, pageItems...) - if !hasMore || next == "" { - break + if !hasMore { + return items, nil + } + if next == "" { + return nil, fmt.Errorf("pagination cursor hasMore is true without a next cursor") + } + if _, repeated := seen[next]; repeated { + return nil, fmt.Errorf("pagination cursor repeated %q", next) + } + seen[next] = struct{}{} + if page+1 == maxPages { + if allowTruncation { + return items, nil + } + return nil, fmt.Errorf("pagination exceeded maximum of %d pages", maxPages) } options.Cursor = next } - return items, nil + return items, nil // unreachable while maxPages is positive } diff --git a/cmd/connectivity/internal/paging_test.go b/cmd/connectivity/internal/paging_test.go new file mode 100644 index 00000000..c8b1b110 --- /dev/null +++ b/cmd/connectivity/internal/paging_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestCollectPagesRejectsDishonestCursorEnvelopes(t *testing.T) { + tests := map[string]struct { + maxPages int + fetch func(connectivityclient.ListOptions) ([]string, bool, string, error) + want string + }{ + "has more without next cursor": { + maxPages: 2, + fetch: func(connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{"one"}, true, "", nil + }, + want: "hasMore", + }, + "repeated cursor": { + maxPages: 3, + fetch: func(options connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{options.Cursor}, true, "again", nil + }, + want: "repeated", + }, + "bounded before final page": { + maxPages: 1, + fetch: func(connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{"one"}, true, "next", nil + }, + want: "maximum", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + _, err := CollectPages(100, tt.maxPages, tt.fetch) + + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), tt.want), "error = %v", err) + }) + } +} + +func TestCollectPagesBoundedReturnsUsefulCompletionPrefix(t *testing.T) { + items, err := CollectPagesBounded(100, 1, func(options connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{options.Cursor + "candidate"}, true, "later", nil + }) + + require.NoError(t, err) + require.Equal(t, []string{"candidate"}, items) +} diff --git a/cmd/connectivity/internal/query.go b/cmd/connectivity/internal/query.go index 200d7dcb..08427124 100644 --- a/cmd/connectivity/internal/query.go +++ b/cmd/connectivity/internal/query.go @@ -3,7 +3,6 @@ package internal import ( "encoding/json" "fmt" - "strings" "github.com/spf13/cobra" @@ -100,9 +99,14 @@ func filterClause(filter string) (any, error) { } func splitFilter(filter string) (key, value, operator string) { - for _, candidate := range []string{"!=", "=", "~"} { - if index := strings.Index(filter, candidate); index >= 0 { - return filter[:index], filter[index+len(candidate):], candidate + for index := 0; index < len(filter); index++ { + switch filter[index] { + case '~', '=': + return filter[:index], filter[index+1:], string(filter[index]) + case '!': + if index+1 < len(filter) && filter[index+1] == '=' { + return filter[:index], filter[index+2:], "!=" + } } } return "", "", "" diff --git a/cmd/connectivity/internal/query_test.go b/cmd/connectivity/internal/query_test.go index 4f75dc02..4ad07b6d 100644 --- a/cmd/connectivity/internal/query_test.go +++ b/cmd/connectivity/internal/query_test.go @@ -43,6 +43,13 @@ func TestBuildListQueryBuildsLikeLeaf(t *testing.T) { require.JSONEq(t, `{"$like":{"name":"stripe%"}}`, query) } +func TestBuildListQueryChoosesTheEarliestOperatorWhenValuesContainDelimiters(t *testing.T) { + query, err := BuildListQuery("", []string{"name~stripe=us"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$like":{"name":"stripe=us"}}`, query) +} + func TestBuildListQueryPassesRawQueryThrough(t *testing.T) { raw := `{"$or":[{"$match":{"catalog":"ee"}},{"$exists":{"catalog":false}}]}` diff --git a/docs/connectivity.md b/docs/connectivity.md new file mode 100644 index 00000000..67fd0bca --- /dev/null +++ b/docs/connectivity.md @@ -0,0 +1,66 @@ +# Connectivity commands + +`fctl connectivity` manages the published Connector catalogue and the +ConnectorInstances installed in the selected stack. + +## Command tree + +```text +fctl connectivity connectors list [--filter KEY=VALUE] [--query JSON] +fctl connectivity connectors show +fctl connectivity connectors facets [--filter KEY=VALUE] [--query JSON] + +fctl connectivity connectorinstances list [--connector NAME] [--filter KEY=VALUE] +fctl connectivity connectorinstances show +fctl connectivity connectorinstances install --ledger +fctl connectivity connectorinstances configure +fctl connectivity connectorinstances uninstall +``` + +Short aliases are available for the common list and catalogue commands: +`connectors ls`, `connectors l`, `connectors facets` (`facet`, `f`), and +`connectorinstances ls` (`l`). `install` also accepts `create` and `in`; +`configure` accepts `config`, `update`, and `c`. + +## Filtering and pagination + +List and facets commands accept repeatable `--filter` expressions: + +```bash +fctl connectivity connectors list --filter catalog=ee --filter tags~provider:% +fctl connectivity connectorinstances list --filter channel=beta +``` + +`KEY=VALUE` is an exact match, `KEY!=VALUE` excludes a present matching key, +and `KEY~PATTERN` uses SQL `%` and `_` wildcards. Use `--query ''` for +the full API query dialect; it cannot be combined with `--filter`. The CLI +also accepts `--page-size` and the opaque `--cursor` from a previous response. +Shell completion obtains the currently supported keys, values, and channels +from the API. + +## Versions and channels + +An install without `--version` or `--channel` resolves its configuration schema +from the **stable** channel, matching the API's persisted default. `--version` +pins an exact release. `--channel stable|rc|beta|alpha` tracks that channel; +when both are present the explicit version wins. + +During `configure`, `--channel` removes a prior pin unless `--version` is also +provided. The CLI resolves the channel across every catalogue page using the +same rules as Connectivity: it never downgrades and stays within the currently +resolved major version. `connectors show` and schema-driven configuration use +the API's `latest` alias only where that is the documented catalogue view. + +## Configuration inputs + +`install` and `configure` accept three configuration forms, in precedence +order: `--config` (YAML or JSON), repeatable `--env-file`, then repeatable +`--set KEY=VALUE`. Values may be inline, `@path`/`@-`, +`secret://name/key`, or `configmap://name/key`. Structured config supports +separate `env` and `files` sections. + +The CLI derives known keys and definitely-required fields from the Connector +Version JSON Schema, including bounded local `$ref`, `allOf`, `anyOf`, and +`oneOf` composition. Open or pattern-based keys, and constraints that cannot +be proven from CLI input, are sent to Connectivity for authoritative +validation. From 2d319b223faf2de7389a3ed976a892b46ab57f70 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:52:52 +0200 Subject: [PATCH 17/21] fix(connectivity): mirror selector normalization --- cmd/connectivity/connectorinstances/config.go | 136 ++++++++++++----- .../connectorinstances/config_test.go | 48 ++++++ .../connectorinstances/configure.go | 36 +++-- .../connectorinstances/configure_test.go | 144 ++++++++++++++++++ .../connectorinstances/version.go | 20 ++- 5 files changed, 324 insertions(+), 60 deletions(-) diff --git a/cmd/connectivity/connectorinstances/config.go b/cmd/connectivity/connectorinstances/config.go index 10704022..77bfc39f 100644 --- a/cmd/connectivity/connectorinstances/config.go +++ b/cmd/connectivity/connectorinstances/config.go @@ -3,6 +3,7 @@ package connectorinstances import ( "bufio" "fmt" + "net/url" "sort" "strings" @@ -76,7 +77,7 @@ func schemaContractFor(version *connectivityclient.ConnectorVersion) (schemaCont {name: "env", kind: ConfigEnv, raw: version.ConfigSchema["env"]}, {name: "files", kind: ConfigFile, raw: version.ConfigSchema["files"]}, } - if sections[0].raw == nil { + if sections[0].raw == nil && sections[1].raw == nil { if _, hasLegacyProperties := version.ConfigSchema["properties"]; hasLegacyProperties { sections[0].raw = version.ConfigSchema } @@ -146,21 +147,19 @@ func collectSectionSchema(raw any, root map[string]any, depth int, refs map[stri result.fields[key] = schemaFieldInfo{required: true} } } - if ref, _ := node["$ref"].(string); ref != "" { - target, err := resolveLocalSchemaRef(root, ref) - if err != nil { - return sectionContract{}, err - } + for _, ref := range schemaLocalReferences(node) { if refs[ref] { - return sectionContract{}, fmt.Errorf("cyclic local reference %q", ref) + continue // The API accepts recursive schemas; metadata is best effort. } - refs[ref] = true - referenced, err := collectSectionSchema(target, root, depth+1, refs) - delete(refs, ref) - if err != nil { - return sectionContract{}, err + if target, resolved := resolveLocalSchemaRef(root, ref); resolved { + refs[ref] = true + referenced, err := collectSectionSchema(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return sectionContract{}, err + } + result = mergeAllSections(result, referenced) } - result = mergeAllSections(result, referenced) } for _, keyword := range []string{"allOf"} { parts, err := schemaArray(node[keyword]) @@ -261,6 +260,9 @@ func mergeAlternativeSections(alternatives []sectionContract) sectionContract { } func collectFieldMetadata(raw any, root map[string]any, depth int, refs map[string]bool) (schemaFieldInfo, error) { + if depth > maxSchemaCollectionDepth { + return schemaFieldInfo{}, fmt.Errorf("schema nesting exceeds %d", maxSchemaCollectionDepth) + } node, ok := stringMap(raw) if !ok { return schemaFieldInfo{}, nil // Boolean schemas are delegated to the API. @@ -273,22 +275,20 @@ func collectFieldMetadata(raw any, root map[string]any, depth int, refs map[stri result.password = true } result.description, _ = node["description"].(string) - if ref, _ := node["$ref"].(string); ref != "" { - target, err := resolveLocalSchemaRef(root, ref) - if err != nil { - return schemaFieldInfo{}, err - } + for _, ref := range schemaLocalReferences(node) { if refs[ref] { - return schemaFieldInfo{}, fmt.Errorf("cyclic local reference %q", ref) + continue // Recursive reference: leave the remaining validation to the API. } - refs[ref] = true - resolved, err := collectFieldMetadata(target, root, depth+1, refs) - delete(refs, ref) - if err != nil { - return schemaFieldInfo{}, err + if target, found := resolveLocalSchemaRef(root, ref); found { + refs[ref] = true + resolved, err := collectFieldMetadata(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return schemaFieldInfo{}, err + } + result.password = result.password || resolved.password + result.description = firstDescription(result.description, resolved.description) } - result.password = result.password || resolved.password - result.description = firstDescription(result.description, resolved.description) } for _, part := range appendSchemaCompositions(node) { metadata, err := collectFieldMetadata(part, root, depth+1, refs) @@ -311,22 +311,82 @@ func appendSchemaCompositions(node map[string]any) []any { return parts } -func resolveLocalSchemaRef(root map[string]any, ref string) (any, error) { - if !strings.HasPrefix(ref, "#/") { - return nil, fmt.Errorf("external schema reference %q is not supported", ref) +func schemaLocalReferences(node map[string]any) []string { + refs := make([]string, 0, 3) + for _, keyword := range []string{"$ref", "$dynamicRef", "$recursiveRef"} { + if ref, ok := node[keyword].(string); ok && strings.HasPrefix(ref, "#") { + refs = append(refs, ref) + } } - var current any = root - for _, token := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { - object, ok := stringMap(current) - if !ok { - return nil, fmt.Errorf("local reference %q does not resolve to an object", ref) + return refs +} + +// resolveLocalSchemaRef follows JSON Pointer and named local anchors. It has +// the same deliberately tolerant contract as the API's schema metadata walk: +// unresolvable or external references are deferred to server-side validation. +func resolveLocalSchemaRef(root map[string]any, ref string) (any, bool) { + if !strings.HasPrefix(ref, "#") { + return nil, false + } + fragment, err := url.PathUnescape(strings.TrimPrefix(ref, "#")) + if err != nil { + return nil, false + } + if fragment == "" { + return root, true + } + if strings.HasPrefix(fragment, "/") { + var current any = root + for _, token := range strings.Split(strings.TrimPrefix(fragment, "/"), "/") { + object, ok := stringMap(current) + if !ok { + return nil, false + } + current, ok = object[strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")] + if !ok { + return nil, false + } } - current, ok = object[strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")] - if !ok { - return nil, fmt.Errorf("local reference %q was not found", ref) + return current, true + } + return findSchemaAnchor(root, fragment) +} + +func findSchemaAnchor(root map[string]any, anchor string) (any, bool) { + remaining := 100000 + var find func(any, int) (any, bool) + find = func(node any, depth int) (any, bool) { + if depth > maxSchemaCollectionDepth || remaining == 0 { + return nil, false + } + remaining-- + switch node := node.(type) { + case map[string]any: + for _, keyword := range []string{"$anchor", "$dynamicAnchor"} { + if declared, _ := node[keyword].(string); declared == anchor { + return node, true + } + } + keys := make([]string, 0, len(node)) + for key := range node { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if target, found := find(node[key], depth+1); found { + return target, true + } + } + case []any: + for _, child := range node { + if target, found := find(child, depth+1); found { + return target, true + } + } } + return nil, false } - return current, nil + return find(root, 0) } func firstDescription(left, right string) string { diff --git a/cmd/connectivity/connectorinstances/config_test.go b/cmd/connectivity/connectorinstances/config_test.go index f840d3dc..fe97ffb3 100644 --- a/cmd/connectivity/connectorinstances/config_test.go +++ b/cmd/connectivity/connectorinstances/config_test.go @@ -87,6 +87,19 @@ func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { }, fields["LEGACY_TOKEN"]) } +func TestSchemaFieldsDoesNotTreatMixedSectionAndLegacyShapesAsLegacy(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{"properties": map[string]any{"SECTION": map[string]any{}}}, + "properties": map[string]any{"LEGACY": map[string]any{}}, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.Contains(t, fields, "SECTION") + require.NotContains(t, fields, "LEGACY") +} + func TestSchemaFieldsCollectsLocalReferencesAndCompositionsWithoutOverstatingRequiredKeys(t *testing.T) { version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ "env": map[string]any{ @@ -113,6 +126,41 @@ func TestSchemaFieldsCollectsLocalReferencesAndCompositionsWithoutOverstatingReq require.False(t, fields["CLIENT_SECRET"].Required, "either alternative can satisfy the schema") } +func TestSchemaFieldsFollowsAnchoredDynamicAndRecursiveReferences(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "$defs": map[string]any{ + "secret": map[string]any{"$dynamicAnchor": "secret", "format": "password", "description": "Anchored secret"}, + }, + "properties": map[string]any{ + "TOKEN": map[string]any{"$dynamicRef": "#secret"}, + "LOOP": map[string]any{"$recursiveRef": "#"}, + }, + }, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.True(t, fields["TOKEN"].Password) + require.Equal(t, "Anchored secret", fields["TOKEN"].Description) + require.Contains(t, fields, "LOOP", "recursive schemas are accepted and delegated to API validation") +} + +func TestSchemaFieldsBoundsMetadataTraversalDepth(t *testing.T) { + field := any(map[string]any{"format": "password"}) + for index := 0; index < maxSchemaCollectionDepth+1; index++ { + field = map[string]any{"allOf": []any{field}} + } + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{"properties": map[string]any{"TOKEN": field}}, + }} + + _, err := SchemaFields(version) + + require.ErrorContains(t, err, "schema nesting exceeds") +} + func TestBuildInstallConfigDefersOpenAndPatternSchemaKeysToTheAPI(t *testing.T) { version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ "env": map[string]any{ diff --git a/cmd/connectivity/connectorinstances/configure.go b/cmd/connectivity/connectorinstances/configure.go index 39b6a91d..d788d806 100644 --- a/cmd/connectivity/connectorinstances/configure.go +++ b/cmd/connectivity/connectorinstances/configure.go @@ -84,25 +84,38 @@ func resolveConfigureConnectorVersion(ctx context.Context, client connectivitycl } pinned, channel := configureVersionSelector(cmd, instance) if channel != "" { - return resolveChannelVersion(ctx, client, instance.Spec.Connector, channel, instanceVersionPin(instance)) + return resolveChannelVersion(ctx, client, instance.Spec.Connector, channel, appliedChannelFloor(instance)) } return resolveConnectorVersion(ctx, client, instance.Spec.Connector, pinned) } func configureVersionSelector(cmd *cobra.Command, instance *connectivityclient.ConnectorInstance) (string, string) { + pinned, channel := "", "" + if instance != nil && instance.Spec.Version != nil && *instance.Spec.Version != "" { + pinned = *instance.Spec.Version + } + if instance != nil && instance.Spec.Channel != nil && *instance.Spec.Channel != "" { + channel = *instance.Spec.Channel + } + // Match the API's post-merge-patch normalization. Empty selector values are + // removed, rather than selecting stable. A non-empty channel is the only + // channel change that clears an existing pin. if cmd.Flags().Changed(versionFlag) { - return fctl.GetString(cmd, versionFlag), "" + pinned = fctl.GetString(cmd, versionFlag) } if cmd.Flags().Changed(channelFlag) { - return "", fctl.GetString(cmd, channelFlag) + channel = fctl.GetString(cmd, channelFlag) + if channel != "" && !cmd.Flags().Changed(versionFlag) { + pinned = "" + } } - if instance != nil && instance.Spec.Version != nil && *instance.Spec.Version != "" { - return *instance.Spec.Version, "" + if pinned != "" { + return pinned, "" } - if instance != nil && instance.Spec.Channel != nil && *instance.Spec.Channel != "" { - return "", *instance.Spec.Channel + if channel != "" { + return "", channel } - if applied := instanceVersionPin(instance); applied != "" { + if applied := appliedChannelFloor(instance); applied != "" { return applied, "" } return "stable", "" @@ -136,7 +149,7 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende pinned, channel := configureVersionSelector(cmd, instance) var version *connectivityclient.ConnectorVersion if channel != "" { - version, err = resolveChannelVersion(cmd.Context(), client, instance.Spec.Connector, channel, instanceVersionPin(instance)) + version, err = resolveChannelVersion(cmd.Context(), client, instance.Spec.Connector, channel, appliedChannelFloor(instance)) } else { version, err = resolveConnectorVersion(cmd.Context(), client, instance.Spec.Connector, pinned) } @@ -165,8 +178,9 @@ func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Rende specPatch["version"] = fctl.GetString(cmd, versionFlag) } if cmd.Flags().Changed(channelFlag) { - specPatch["channel"] = fctl.GetString(cmd, channelFlag) - if !cmd.Flags().Changed(versionFlag) { + channel := fctl.GetString(cmd, channelFlag) + specPatch["channel"] = channel + if channel != "" && !cmd.Flags().Changed(versionFlag) { // A pre-existing pin would otherwise continue to win over the new // channel. JSON null deletes it under the API's merge-patch contract. specPatch["version"] = nil diff --git a/cmd/connectivity/connectorinstances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go index cb25abab..edf1896a 100644 --- a/cmd/connectivity/connectorinstances/configure_test.go +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -152,6 +152,7 @@ func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) current.Spec.Version = stringPtr("v1.0.0") current.Spec.Channel = stringPtr("stable") current.Status.ResolvedVersion = stringPtr("v1.0.0") + current.Status.ResolvedConnectorRef = stringPtr("stripe") current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} var gotVersion string var gotPatch connectivityclient.ConnectorInstancePatch @@ -199,6 +200,149 @@ func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) require.Nil(t, spec["version"]) } +func TestConfigureChannelUsesAppliedVersionOnlyForTheResolvedConnector(t *testing.T) { + for _, tt := range []struct { + name string + resolvedRef string + wantErr string + wantVersion string + wantPatched bool + }{ + {name: "matching resolved connector prevents downgrade", resolvedRef: "stripe", wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "different resolved connector does not constrain channel", resolvedRef: "other", wantVersion: "v1.1.0", wantPatched: true}, + } { + t.Run(tt.name, func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.0.0") + current.Status.ResolvedVersion = stringPtr("v1.2.0") + current.Status.ResolvedConnectorRef = stringPtr(tt.resolvedRef) + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + patched := false + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{{Version: "v1.1.0"}}}, nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patched = true + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=stable", "--set=/etc/a=kept", "--confirm") + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantVersion, gotVersion) + require.Equal(t, tt.wantPatched, patched) + }) + } +} + +func TestConfigureEmptySelectorsUseTheNormalizedPostPatchSelectorForConfig(t *testing.T) { + t.Run("empty channel keeps an existing version pin", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.4.2") + current.Spec.Channel = stringPtr("stable") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "v1.4.2", gotVersion) + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "", spec["channel"]) + require.NotContains(t, spec, "version") + }) + + t.Run("empty version retains an existing channel", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedConnectorRef = stringPtr("stripe") + current.Status.ResolvedVersion = stringPtr("v1.0.0") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{{Version: "v1.1.0"}}}, nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--version=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "v1.1.0", gotVersion) + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "", spec["version"]) + require.NotContains(t, spec, "channel") + }) + + t.Run("empty channel does not reuse an applied version for another connector", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedConnectorRef = stringPtr("other") + current.Status.ResolvedVersion = stringPtr("v9.0.0") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "stable", gotVersion) + }) +} + func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *testing.T) { current := instanceFixture("stripe-eu") current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go index 93b21018..b4111da2 100644 --- a/cmd/connectivity/connectorinstances/version.go +++ b/cmd/connectivity/connectorinstances/version.go @@ -43,20 +43,18 @@ func resolveConnectorVersion(ctx context.Context, client connectivityclient.Clie return version, nil } -// instanceVersionPin reports the version an existing connector instance is -// configured against: its explicit pin when set, the version the operator -// actually applied otherwise. An empty result means "resolve the newest". -func instanceVersionPin(instance *connectivityclient.ConnectorInstance) string { - if instance == nil { +// appliedChannelFloor returns the version currently applied to this exact +// connector. The API only uses this status value as a channel resolver floor +// after confirming its resolved connector identity; desired spec.version is +// deliberately not a floor when switching from a pin to a channel. +func appliedChannelFloor(instance *connectivityclient.ConnectorInstance) string { + if instance == nil || instance.Status == nil || instance.Status.ResolvedVersion == nil || instance.Status.ResolvedConnectorRef == nil { return "" } - if instance.Spec.Version != nil && *instance.Spec.Version != "" { - return *instance.Spec.Version - } - if instance.Status != nil && instance.Status.ResolvedVersion != nil { - return *instance.Status.ResolvedVersion + if *instance.Status.ResolvedConnectorRef != instance.Spec.Connector { + return "" } - return "" + return *instance.Status.ResolvedVersion } // resolveChannelVersion mirrors the operator resolver for the schema selected From d4433cf1ede85ba7da707a922e87731771f6af80 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:57:13 +0200 Subject: [PATCH 18/21] fix(connectivity): support legacy resolved identity --- .../connectorinstances/config_test.go | 23 ++++++----- .../connectorinstances/configure_test.go | 38 +++++++++++++++++-- .../connectorinstances/version.go | 7 +++- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/cmd/connectivity/connectorinstances/config_test.go b/cmd/connectivity/connectorinstances/config_test.go index fe97ffb3..f22ed60b 100644 --- a/cmd/connectivity/connectorinstances/config_test.go +++ b/cmd/connectivity/connectorinstances/config_test.go @@ -88,16 +88,21 @@ func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { } func TestSchemaFieldsDoesNotTreatMixedSectionAndLegacyShapesAsLegacy(t *testing.T) { - version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ - "env": map[string]any{"properties": map[string]any{"SECTION": map[string]any{}}}, - "properties": map[string]any{"LEGACY": map[string]any{}}, - }} - - fields, err := SchemaFields(version) + for _, schema := range []map[string]any{ + { + "env": map[string]any{"properties": map[string]any{"SECTION": map[string]any{}}}, + "properties": map[string]any{"LEGACY": map[string]any{}}, + }, + { + "files": map[string]any{"properties": map[string]any{"/etc/section": map[string]any{}}}, + "properties": map[string]any{"LEGACY": map[string]any{}}, + }, + } { + fields, err := SchemaFields(&connectivityclient.ConnectorVersion{ConfigSchema: schema}) - require.NoError(t, err) - require.Contains(t, fields, "SECTION") - require.NotContains(t, fields, "LEGACY") + require.NoError(t, err) + require.NotContains(t, fields, "LEGACY") + } } func TestSchemaFieldsCollectsLocalReferencesAndCompositionsWithoutOverstatingRequiredKeys(t *testing.T) { diff --git a/cmd/connectivity/connectorinstances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go index edf1896a..7e205cf1 100644 --- a/cmd/connectivity/connectorinstances/configure_test.go +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -147,6 +147,34 @@ func TestConfigureFallsBackToTheAppliedVersionWhenUnpinned(t *testing.T) { require.Equal(t, "1.9.9", gotVersion) } +func TestConfigureFallsBackToLegacyAppliedVersionWhenNoSelectorRemains(t *testing.T) { + for _, resolvedRef := range []*string{nil, stringPtr("")} { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = nil + current.Status.ResolvedConnectorRef = resolvedRef + current.Status.ResolvedVersion = stringPtr("1.9.9") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "1.9.9", gotVersion) + } +} + func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) { current := instanceFixture("stripe-eu") current.Spec.Version = stringPtr("v1.0.0") @@ -203,19 +231,21 @@ func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) func TestConfigureChannelUsesAppliedVersionOnlyForTheResolvedConnector(t *testing.T) { for _, tt := range []struct { name string - resolvedRef string + resolvedRef *string wantErr string wantVersion string wantPatched bool }{ - {name: "matching resolved connector prevents downgrade", resolvedRef: "stripe", wantErr: "no candidate version satisfies channel", wantPatched: false}, - {name: "different resolved connector does not constrain channel", resolvedRef: "other", wantVersion: "v1.1.0", wantPatched: true}, + {name: "matching resolved connector prevents downgrade", resolvedRef: stringPtr("stripe"), wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "empty resolved connector retains legacy floor", resolvedRef: stringPtr(""), wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "absent resolved connector retains legacy floor", resolvedRef: nil, wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "different resolved connector does not constrain channel", resolvedRef: stringPtr("other"), wantVersion: "v1.1.0", wantPatched: true}, } { t.Run(tt.name, func(t *testing.T) { current := instanceFixture("stripe-eu") current.Spec.Version = stringPtr("v1.0.0") current.Status.ResolvedVersion = stringPtr("v1.2.0") - current.Status.ResolvedConnectorRef = stringPtr(tt.resolvedRef) + current.Status.ResolvedConnectorRef = tt.resolvedRef current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} patched := false var gotVersion string diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go index b4111da2..290d71cb 100644 --- a/cmd/connectivity/connectorinstances/version.go +++ b/cmd/connectivity/connectorinstances/version.go @@ -48,10 +48,13 @@ func resolveConnectorVersion(ctx context.Context, client connectivityclient.Clie // after confirming its resolved connector identity; desired spec.version is // deliberately not a floor when switching from a pin to a channel. func appliedChannelFloor(instance *connectivityclient.ConnectorInstance) string { - if instance == nil || instance.Status == nil || instance.Status.ResolvedVersion == nil || instance.Status.ResolvedConnectorRef == nil { + if instance == nil || instance.Status == nil || instance.Status.ResolvedVersion == nil { return "" } - if *instance.Status.ResolvedConnectorRef != instance.Spec.Connector { + // Older API responses did not stamp resolvedConnectorRef. The server treats + // that empty identity as the instance's previous connector, while a nonempty + // different identity must never constrain this connector's channel. + if instance.Status.ResolvedConnectorRef != nil && *instance.Status.ResolvedConnectorRef != "" && *instance.Status.ResolvedConnectorRef != instance.Spec.Connector { return "" } return *instance.Status.ResolvedVersion From aa5f9664ed16b82798fe6a2c87957bfc27c2c843 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:02:26 +0200 Subject: [PATCH 19/21] feat(connectivity): suspend connector instances (#178) --- cmd/connectivity/connectorinstances/root.go | 2 + cmd/connectivity/connectorinstances/show.go | 34 +++++ .../connectorinstances/show_test.go | 21 +++ .../connectorinstances/suspend.go | 94 ++++++++++++ .../connectorinstances/suspend_test.go | 136 ++++++++++++++++++ docs/connectivity.md | 20 +++ internal/connectivityclient/client_test.go | 64 +++++++++ .../connectivityclient/integration_test.go | 38 +++++ internal/connectivityclient/models.go | 31 ++-- 9 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 cmd/connectivity/connectorinstances/suspend.go create mode 100644 cmd/connectivity/connectorinstances/suspend_test.go diff --git a/cmd/connectivity/connectorinstances/root.go b/cmd/connectivity/connectorinstances/root.go index 3a80b862..72277219 100644 --- a/cmd/connectivity/connectorinstances/root.go +++ b/cmd/connectivity/connectorinstances/root.go @@ -17,6 +17,8 @@ func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, p NewShowCommand(factory), NewInstallCommand(factory, read, paths), NewConfigureCommand(factory, read, paths), + NewSuspendCommand(factory), + NewUnsuspendCommand(factory), NewUninstallCommand(factory), ), ) diff --git a/cmd/connectivity/connectorinstances/show.go b/cmd/connectivity/connectorinstances/show.go index e2f4a4ff..f3af1860 100644 --- a/cmd/connectivity/connectorinstances/show.go +++ b/cmd/connectivity/connectorinstances/show.go @@ -88,6 +88,8 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { {pterm.LightCyan("Connectivity Reference"), stringValue(instance.Spec.ConnectivityRef)}, {pterm.LightCyan("Ledger"), instance.Spec.Ledger}, {pterm.LightCyan("Poll Interval"), stringValue(instance.Spec.PollInterval)}, + {pterm.LightCyan("Suspend"), boolValue(instance.Spec.Suspend)}, + {pterm.LightCyan("Replicas"), int32Value(instance.Spec.Replicas)}, }); err != nil { return err } @@ -99,6 +101,7 @@ func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { {pterm.LightCyan("Connector Address"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ConnectorAddress })}, {pterm.LightCyan("Phase"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase })}, {pterm.LightCyan("State"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State })}, + {pterm.LightCyan("Suspended by"), instanceSuspensionSources(instance.Status)}, }); err != nil { return err } @@ -184,3 +187,34 @@ func mapValue(value map[string]string) string { } return strings.Join(entries, ", ") } + +func boolValue(value *bool) string { + if value == nil { + return "" + } + return strconv.FormatBool(*value) +} + +func int32Value(value *int32) string { + if value == nil { + return "" + } + return strconv.FormatInt(int64(*value), 10) +} + +func instanceSuspensionSources(status *connectivityclient.ConnectorInstanceStatus) string { + if status == nil { + return "" + } + // Connectivity orders instance fields first and Policies by priority/name. + // Preserve that API order because Policy priority is not part of this model. + entries := make([]string, 0, len(status.SuspendedBy)) + for _, source := range status.SuspendedBy { + entry := strings.Join(nonEmptyStrings(source.Kind, source.Name), "/") + if source.Field != "" { + entry += " (" + source.Field + ")" + } + entries = append(entries, entry) + } + return strings.Join(entries, ", ") +} diff --git a/cmd/connectivity/connectorinstances/show_test.go b/cmd/connectivity/connectorinstances/show_test.go index 7fd4c8d2..53c82c9f 100644 --- a/cmd/connectivity/connectorinstances/show_test.go +++ b/cmd/connectivity/connectorinstances/show_test.go @@ -120,6 +120,27 @@ func TestShowConnectorInstanceHandlesMissingStatusAndConfig(t *testing.T) { require.Contains(t, output, "No configuration entries.") } +func TestShowConnectorInstanceRendersDesiredSuspensionReplicasAndOrderedProvenance(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.Suspend = fctl.Ptr(true) + instance.Spec.Replicas = fctl.Ptr(int32(0)) + instance.Status.SuspendedBy = []connectivityclient.SuspensionSource{ + {Kind: "ConnectorInstance", Name: "stripe-eu", Field: "spec.suspend"}, + {Kind: "ConnectorInstance", Name: "stripe-eu", Field: "spec.replicas"}, + {Kind: "Policy", Name: "maintenance", Field: "spec.mutate.suspend"}, + } + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "Suspend") + require.Contains(t, output, "true") + require.Contains(t, output, "Replicas") + require.Contains(t, output, "0") + require.Contains(t, output, "Suspended by") + require.Contains(t, output, "ConnectorInstance/stripe-eu (spec.suspend), ConnectorInstance/stripe-eu (spec.replicas), Policy/maintenance (spec.mutate.suspend)") +} + func TestShowConnectorInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { tests := map[string]struct { factory func(*cobra.Command) (connectivityclient.Client, error) diff --git a/cmd/connectivity/connectorinstances/suspend.go b/cmd/connectivity/connectorinstances/suspend.go new file mode 100644 index 00000000..08dc576e --- /dev/null +++ b/cmd/connectivity/connectorinstances/suspend.go @@ -0,0 +1,94 @@ +package connectorinstances + +import ( + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type SuspensionStore struct { + Name string `json:"name"` + Suspend bool `json:"suspend"` +} + +type SuspensionController struct { + factory connectivityinternal.ClientFactory + approve approvalFunc + store *SuspensionStore + suspend bool +} + +var _ fctl.Controller[*SuspensionStore] = (*SuspensionController)(nil) + +func NewSuspensionController(factory connectivityinternal.ClientFactory, suspend bool) *SuspensionController { + return &SuspensionController{ + factory: factory, + approve: fctl.CheckStackApprobation, + store: &SuspensionStore{}, + suspend: suspend, + } +} + +func NewSuspendCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return newSuspensionCommand(factory, "suspend", "Suspend a Connectivity connector instance", true) +} + +func NewUnsuspendCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return newSuspensionCommand(factory, "unsuspend", "Resume a Connectivity connector instance", false) +} + +func newSuspensionCommand(factory connectivityinternal.ClientFactory, name, description string, suspend bool) *cobra.Command { + controller := NewSuspensionController(factory, suspend) + return fctl.NewCommand( + name+" ", + fctl.WithShortDescription(description), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), + fctl.WithConfirmFlag(), + fctl.WithController[*SuspensionStore](controller), + ) +} + +func (c *SuspensionController) GetStore() *SuspensionStore { + return c.store +} + +func (c *SuspensionController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + name := args[0] + action := "suspend" + if !c.suspend { + action = "resume" + } + if !c.approve(cmd, "You are about to %s Connectivity connector instance %q", action, name) { + return nil, fctl.ErrMissingApproval + } + _, err = client.PatchConnectorInstance(cmd.Context(), name, connectivityclient.ConnectorInstancePatch{ + "spec": map[string]any{"suspend": c.suspend}, + }) + if err != nil { + return nil, err + } + c.store.Name = name + c.store.Suspend = c.suspend + return c, nil +} + +func (c *SuspensionController) Render(cmd *cobra.Command, _ []string) error { + request := "suspension" + if !c.store.Suspend { + request = "resumption" + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q %s requested.\n", c.store.Name, request) + return err +} diff --git a/cmd/connectivity/connectorinstances/suspend_test.go b/cmd/connectivity/connectorinstances/suspend_test.go new file mode 100644 index 00000000..b3882753 --- /dev/null +++ b/cmd/connectivity/connectorinstances/suspend_test.go @@ -0,0 +1,136 @@ +package connectorinstances + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type suspensionClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) + patch func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) +} + +func (m suspensionClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m suspensionClientMock) PatchConnectorInstance(ctx context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return m.patch(ctx, name, patch) +} + +func TestSuspensionCommandsPatchOnlyDesiredBooleanAndTreatAlreadyConvergedResponseAsSuccess(t *testing.T) { + tests := []struct { + name string + command func(connectivityinternal.ClientFactory) *cobra.Command + suspend bool + wantOutput string + }{ + {name: "suspend", command: NewSuspendCommand, suspend: true, wantOutput: `Connector instance "stripe-eu" suspension requested.`}, + {name: "unsuspend", command: NewUnsuspendCommand, suspend: false, wantOutput: `Connector instance "stripe-eu" resumption requested.`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + client := suspensionClientMock{patch: func(_ context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + calls++ + require.Equal(t, "stripe-eu", name) + require.Equal(t, connectivityclient.ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, patch) + instance := instanceFixture(name) + instance.Spec.Suspend = fctl.Ptr(test.suspend) + return &instance, nil + }} + + output, err := executeCommand(test.command(factoryReturning(client)), "stripe-eu", "--confirm") + + require.NoError(t, err) + require.Equal(t, 1, calls) + require.Equal(t, test.wantOutput, strings.TrimSpace(output)) + }) + } +} + +func TestSuspensionCommandConfirmationRejectionMakesNoPatch(t *testing.T) { + patchCalls := 0 + client := suspensionClientMock{patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patchCalls++ + return nil, errors.New("PatchConnectorInstance must not be called") + }} + controller := NewSuspensionController(factoryReturning(client), true) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + + _, err := controller.Run(&cobra.Command{}, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.Zero(t, patchCalls) +} + +func TestSuspensionCommandsPreserveAPIError(t *testing.T) { + apiError := &connectivityclient.APIError{StatusCode: 409, Code: "CONFLICT", Message: "stale"} + client := suspensionClientMock{patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return nil, apiError + }} + + for _, command := range []func(connectivityinternal.ClientFactory) *cobra.Command{NewSuspendCommand, NewUnsuspendCommand} { + _, err := executeCommand(command(factoryReturning(client)), "stripe-eu", "--confirm") + require.ErrorIs(t, err, apiError) + } +} + +func TestSuspensionCommandsRejectInvalidArgumentsBeforeUsingClient(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + + for _, command := range []func(connectivityinternal.ClientFactory) *cobra.Command{NewSuspendCommand, NewUnsuspendCommand} { + _, err := executeCommand(command(factory), "stripe-eu", "extra", "--confirm") + require.Error(t, err) + } + require.False(t, usedFactory) +} + +func TestSuspensionCommandsRegisterConfirmationCompletionAndRootChildren(t *testing.T) { + instance := instanceFixture("stripe-eu") + client := suspensionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}}, nil + }} + + for _, test := range []struct { + name string + command func(connectivityinternal.ClientFactory) *cobra.Command + }{ + {name: "suspend", command: NewSuspendCommand}, + {name: "unsuspend", command: NewUnsuspendCommand}, + } { + command := test.command(factoryReturning(client)) + require.Equal(t, test.name+" ", command.Use) + require.NotNil(t, command.Flags().Lookup("confirm")) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + candidates, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe ยท main"}, candidates) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + } + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + for _, name := range []string{"suspend", "unsuspend"} { + child, _, err := root.Find([]string{name}) + require.NoError(t, err) + require.Equal(t, name, child.Name()) + } +} diff --git a/docs/connectivity.md b/docs/connectivity.md index 67fd0bca..32a73c29 100644 --- a/docs/connectivity.md +++ b/docs/connectivity.md @@ -14,6 +14,8 @@ fctl connectivity connectorinstances list [--connector NAME] [--filter KEY=VALUE fctl connectivity connectorinstances show fctl connectivity connectorinstances install --ledger fctl connectivity connectorinstances configure +fctl connectivity connectorinstances suspend --confirm +fctl connectivity connectorinstances unsuspend --confirm fctl connectivity connectorinstances uninstall ``` @@ -22,6 +24,24 @@ Short aliases are available for the common list and catalogue commands: `connectorinstances ls` (`l`). `install` also accepts `create` and `in`; `configure` accepts `config`, `update`, and `c`. +## Suspension and resumption + +`connectorinstances suspend` and `connectorinstances unsuspend` request the +desired ingestion state through the Connectivity API. Both commands require +confirmation, either interactively or with `--confirm`: + +```bash +fctl connectivity connectorinstances suspend stripe-prod --confirm +fctl connectivity connectorinstances unsuspend stripe-prod --confirm +``` + +A successful command means the request was accepted; reconciliation may still +be in progress. Use `connectorinstances show ` to compare the desired +`Suspend` and `Replicas` values with the observed phase, state, and +`Suspended by` provenance. Suspension can also remain effective because the +desired replica count is zero or a matching Policy contributes a suspension +source. + ## Filtering and pagination List and facets commands accept repeatable `--filter` expressions: diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go index 45a2820a..bd400493 100644 --- a/internal/connectivityclient/client_test.go +++ b/internal/connectivityclient/client_test.go @@ -361,6 +361,70 @@ func TestPatchConnectorInstanceSendsAPIShapedConfig(t *testing.T) { }`, string(seenBody)) } +func TestPatchConnectorInstanceSendsExactSuspensionBoolean(t *testing.T) { + tests := []struct { + name string + suspend bool + want string + }{ + {name: "suspend", suspend: true, want: `{"spec":{"suspend":true}}`}, + {name: "explicit resume", suspend: false, want: `{"spec":{"suspend":false}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var seenMethod, seenPath, seenContentType string + var seenBody []byte + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenMethod = req.Method + seenPath = req.URL.EscapedPath() + seenContentType = req.Header.Get("Content-Type") + var err error + seenBody, err = io.ReadAll(req.Body) + require.NoError(t, err) + return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"main"}}`), nil + })} + + _, err := New("https://stack.example/base", httpClient).PatchConnectorInstance( + context.Background(), + "worker/one", + ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, + ) + + require.NoError(t, err) + require.Equal(t, http.MethodPatch, seenMethod) + require.Equal(t, "/base/api/connectivity/connectorinstances/worker%2Fone", seenPath) + require.Equal(t, "application/merge-patch+json", seenContentType) + require.Equal(t, test.want, string(seenBody)) + }) + } +} + +func TestGetConnectorInstanceDecodesDesiredSuspensionAndObservedProvenance(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "metadata":{"name":"worker"}, + "spec":{"connector":"stripe","ledger":"main","suspend":false,"replicas":0}, + "status":{"suspendedBy":[ + {"kind":"ConnectorInstance","name":"worker","field":"spec.replicas"}, + {"kind":"Policy","name":"maintenance","field":"spec.mutate.suspend"} + ]} + }`), nil + })} + + instance, err := New("https://stack.example", httpClient).GetConnectorInstance(context.Background(), "worker") + + require.NoError(t, err) + require.NotNil(t, instance.Spec.Suspend) + require.False(t, *instance.Spec.Suspend) + require.NotNil(t, instance.Spec.Replicas) + require.Equal(t, int32(0), *instance.Spec.Replicas) + require.Equal(t, []SuspensionSource{ + {Kind: "ConnectorInstance", Name: "worker", Field: "spec.replicas"}, + {Kind: "Policy", Name: "maintenance", Field: "spec.mutate.suspend"}, + }, instance.Status.SuspendedBy) +} + func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { tests := []struct { name string diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go index 2a6e788b..cc59e1c9 100644 --- a/internal/connectivityclient/integration_test.go +++ b/internal/connectivityclient/integration_test.go @@ -145,3 +145,41 @@ func TestGivenConnectivityAPI_WhenConfigIsPatched_ThenAPIShapeIsSent(t *testing. // Then the API receives spec.config verbatim and preserves the full files array. require.NoError(t, err) } + +func TestGivenConnectivityAPI_WhenSuspensionIsPatched_ThenExactBooleanBodyIsSent(t *testing.T) { + tests := []struct { + name string + suspend bool + want string + }{ + {name: "suspension", suspend: true, want: `{"spec":{"suspend":true}}`}, + {name: "resumption", suspend: false, want: `{"spec":{"suspend":false}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Given a Connectivity server accepting ConnectorInstance merge patches. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + require.Equal(t, http.MethodPatch, req.Method) + require.Equal(t, "/api/connectivity/connectorinstances/worker", req.URL.EscapedPath()) + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, test.want, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`) + })) + defer server.Close() + + // When the client requests suspension or resumption. + _, err := New(server.URL, server.Client()).PatchConnectorInstance( + context.Background(), + "worker", + ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, + ) + + // Then the exact boolean, including false, reaches the HTTP route. + require.NoError(t, err) + }) + } +} diff --git a/internal/connectivityclient/models.go b/internal/connectivityclient/models.go index af53cd7f..e864ac62 100644 --- a/internal/connectivityclient/models.go +++ b/internal/connectivityclient/models.go @@ -134,21 +134,30 @@ type ConnectorInstanceSpec struct { Ledger string `json:"ledger"` StartSequence *int64 `json:"startSequence,omitempty"` PollInterval *string `json:"pollInterval,omitempty"` + Suspend *bool `json:"suspend,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` Config *ConnectorInstanceConfig `json:"config,omitempty"` } +type SuspensionSource struct { + Kind string `json:"kind"` + Name string `json:"name"` + Field string `json:"field,omitempty"` +} + type ConnectorInstanceStatus struct { - Phase *string `json:"phase,omitempty"` - State *string `json:"state,omitempty"` - ConnectorAddress *string `json:"connectorAddress,omitempty"` - ResolvedImage *string `json:"resolvedImage,omitempty"` - ResolvedConnectorRef *string `json:"resolvedConnectorRef,omitempty"` - ResolvedVersion *string `json:"resolvedVersion,omitempty"` - ResolvedDigest *string `json:"resolvedDigest,omitempty"` - CurrentSequence *int64 `json:"currentSequence,omitempty"` - SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` - LastError *string `json:"lastError,omitempty"` - Message *string `json:"message,omitempty"` + Phase *string `json:"phase,omitempty"` + State *string `json:"state,omitempty"` + ConnectorAddress *string `json:"connectorAddress,omitempty"` + ResolvedImage *string `json:"resolvedImage,omitempty"` + ResolvedConnectorRef *string `json:"resolvedConnectorRef,omitempty"` + ResolvedVersion *string `json:"resolvedVersion,omitempty"` + ResolvedDigest *string `json:"resolvedDigest,omitempty"` + CurrentSequence *int64 `json:"currentSequence,omitempty"` + SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` + LastError *string `json:"lastError,omitempty"` + Message *string `json:"message,omitempty"` + SuspendedBy []SuspensionSource `json:"suspendedBy,omitempty"` } type ConnectorInstance struct { From 31e3cc49a09eecde2fe5367d03f19830ae811c0c Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:00:14 +0200 Subject: [PATCH 20/21] fix(connectivity): drop the description column from `connectors list` Descriptions are full sentences and pterm sizes a column to its widest cell, so one connector wrapped every row and the table stopped lining up -- the `Tags` column, which is what the list is useful for, ended up on its own line under each entry. `connectors show` prints the description, which is where a sentence belongs. --- cmd/connectivity/connectors/list.go | 6 ++++-- cmd/connectivity/connectors/list_test.go | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cmd/connectivity/connectors/list.go b/cmd/connectivity/connectors/list.go index bd886dee..0417afd3 100644 --- a/cmd/connectivity/connectors/list.go +++ b/cmd/connectivity/connectors/list.go @@ -108,12 +108,14 @@ func (c *ListController) Render(cmd *cobra.Command, _ []string) error { return []string{ stringValue(connector.Metadata.Name), stringValue(connector.Spec.DisplayName), - stringValue(connector.Spec.Description), strings.Join(connector.Spec.Tags, ", "), connectorPhase(connector.Status), } }) - rows = fctl.Prepend(rows, []string{"Name", "Display Name", "Description", "Tags", "Phase"}) + // No description column: descriptions are full sentences, and pterm sizes + // columns to their content, so one connector wrapped every row and the + // table stopped lining up. `connectors show` prints the description. + rows = fctl.Prepend(rows, []string{"Name", "Display Name", "Tags", "Phase"}) if err := pterm.DefaultTable. WithHasHeader(). WithWriter(cmd.OutOrStdout()). diff --git a/cmd/connectivity/connectors/list_test.go b/cmd/connectivity/connectors/list_test.go index 1aa51f84..51792f66 100644 --- a/cmd/connectivity/connectors/list_test.go +++ b/cmd/connectivity/connectors/list_test.go @@ -37,14 +37,21 @@ func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation( t.Fatalf("ListConnectors options = %#v, want %#v", gotOptions, wantOptions) } for _, expected := range []string{ - "Name", "Display Name", "Description", "Tags", "Phase", - "stripe", "Connector display name", "Connector description", "payments, webhooks", "Ready", + "Name", "Display Name", "Tags", "Phase", + "stripe", "Connector display name", "payments, webhooks", "Ready", "HasMore", "true", "PageSize", "7", "Next", "next-page", } { if !strings.Contains(output, expected) { t.Errorf("plain output missing %q:\n%s", expected, output) } } + // The description is deliberately absent: it is a full sentence, and pterm + // widens a column to its content, so carrying it here wrapped every row. + for _, absent := range []string{"Description", "Connector description"} { + if strings.Contains(output, absent) { + t.Errorf("plain output should not carry %q:\n%s", absent, output) + } + } } func TestListConnectorsBuildsQueryFromFilterFlags(t *testing.T) { From 4471074cd074bffa40ebf1767df273374ae85030 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:10:03 +0200 Subject: [PATCH 21/21] fix(connectivity): stop rejecting instances that pin an image `connectorinstances list` failed with decode connectivity response: cursor.data[4]: connector instance spec.connector is required on a response the server had answered 200. A ConnectorInstance may pin spec.image on the CR instead of referencing a Connector -- the two are mutually exclusive -- so the API returns it with an empty connector, and one such row failed the decode of the entire page. validateConnectorInstance guards decoding, not creation: both call sites are response paths, so requiring a field here only rejects legitimate data. Keep metadata.name, which identifies the row and Kubernetes always sets, and drop connector and ledger -- enforcing a write-side invariant while reading is the same mistake in both cases. The two table cases asserting the old behaviour go with it. --- internal/connectivityclient/client.go | 17 ++++++--- internal/connectivityclient/client_test.go | 43 +++++++++++++--------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go index 147a9cfa..6dd614d2 100644 --- a/internal/connectivityclient/client.go +++ b/internal/connectivityclient/client.go @@ -325,16 +325,21 @@ func validateConnectorVersionSummary(summary *ConnectorVersionSummary) error { return validateConnectorVersion(summary.Version, summary.Image) } +// validateConnectorInstance guards decoding, not creation: both call sites are +// response paths. Only the name is required, because it is what identifies the +// row and Kubernetes always sets it. +// +// spec.connector deliberately is not. An instance may pin spec.image on the CR +// instead of referencing a Connector -- the two are mutually exclusive -- and +// the API returns it with an empty connector. Requiring it here rejected such +// an instance, and since one bad item fails the page, a single image-pinned +// connector made `connectorinstances list` unusable against a response the +// server had answered successfully. spec.ledger goes with it: enforcing a +// write-side invariant while reading is the same mistake. func validateConnectorInstance(instance *ConnectorInstance) error { if instance == nil || instance.Metadata.Name == nil || strings.TrimSpace(*instance.Metadata.Name) == "" { return fmt.Errorf("connector instance metadata.name is required") } - if strings.TrimSpace(instance.Spec.Connector) == "" { - return fmt.Errorf("connector instance spec.connector is required") - } - if strings.TrimSpace(instance.Spec.Ledger) == "" { - return fmt.Errorf("connector instance spec.ledger is required") - } return nil } diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go index bd400493..3ce7e663 100644 --- a/internal/connectivityclient/client_test.go +++ b/internal/connectivityclient/client_test.go @@ -400,6 +400,28 @@ func TestPatchConnectorInstanceSendsExactSuspensionBoolean(t *testing.T) { } } +// An instance pinned to spec.image on the CR references no Connector, so the +// API returns it with an empty spec.connector. One such row used to fail the +// decode of the whole page -- `connectorinstances list` reported +// "cursor.data[N]: connector instance spec.connector is required" on a +// response the server had answered 200. +func TestListConnectorInstancesDecodesAnInstanceWithNoConnector(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"cursor":{"pageSize":15,"hasMore":false,"data":[ + {"metadata":{"name":"with-connector"},"spec":{"connector":"stripe","ledger":"main"}}, + {"metadata":{"name":"image-pinned"},"spec":{"ledger":"ops"}} + ]}}`), nil + })} + + list, err := New("https://stack.example", httpClient).ListConnectorInstances(context.Background(), ListOptions{}) + + require.NoError(t, err) + require.Len(t, list.Items, 2) + require.Equal(t, "stripe", list.Items[0].Spec.Connector) + require.Empty(t, list.Items[1].Spec.Connector) + require.Equal(t, "ops", list.Items[1].Spec.Ledger) +} + func TestGetConnectorInstanceDecodesDesiredSuspensionAndObservedProvenance(t *testing.T) { httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { return jsonResponse(http.StatusOK, `{ @@ -599,24 +621,9 @@ func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { return err }, }, - { - name: "connector instance missing connector", - status: http.StatusCreated, - body: `{"metadata":{"name":"worker"},"spec":{"ledger":"main"}}`, - call: func(client Client) error { - _, err := client.CreateConnectorInstance(context.Background(), ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "main"}}) - return err - }, - }, - { - name: "connector instance list item missing ledger", - status: http.StatusOK, - body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{"name":"worker"},"spec":{"connector":"stripe"}}]}}`, - call: func(client Client) error { - _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) - return err - }, - }, + // A missing spec.connector or spec.ledger is deliberately absent from + // this table: neither is structurally invalid on a response. See + // TestListConnectorInstancesDecodesAnInstanceWithNoConnector. { name: "facet distribution missing facets", status: http.StatusOK,