From 605d413fbd0347a018554590a7b4ff1aa69ed633 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:00:51 +0200 Subject: [PATCH 1/6] chore: snapshot pre-existing catalogue work for isolated integration --- cmd/external_distribution_test.go | 48 ++++++++- cmd/plugins_bootstrap.go | 37 ++++++- cmd/plugins_discovery_test.go | 101 ++++++++++++++++++ cmd/plugins_sync.go | 7 +- docs/plugin-distribution.md | 30 ++++-- go.mod | 6 +- go.sum | 18 +++- internal/pluginmanager/catalogue.go | 59 +++++++--- internal/pluginmanager/catalogue_yaml_test.go | 82 ++++++++++++++ internal/pluginmanager/types.go | 5 +- plugins/ledger/plugin.go | 4 +- 11 files changed, 357 insertions(+), 40 deletions(-) create mode 100644 cmd/plugins_discovery_test.go create mode 100644 internal/pluginmanager/catalogue_yaml_test.go diff --git a/cmd/external_distribution_test.go b/cmd/external_distribution_test.go index b3741cc6..606b6c3b 100644 --- a/cmd/external_distribution_test.go +++ b/cmd/external_distribution_test.go @@ -1,7 +1,6 @@ package cmd_test import ( - "encoding/json" "fmt" "io" "net/http" @@ -13,11 +12,37 @@ import ( "sync/atomic" "testing" + "sigs.k8s.io/yaml" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" "github.com/formancehq/fctl/v4/internal/pluginmanager" ) +// Route only the official catalogue URL to a test-owned YAML file. All OCI and +// service traffic still uses real HTTP; no production registry is contacted. +type officialCatalogueTransport struct { + base http.RoundTripper + path string + reads atomic.Int32 + t *testing.T +} + +func (f *officialCatalogueTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.String() != pluginmanager.DefaultCatalogue { + return f.base.RoundTrip(request) + } + f.reads.Add(1) + if request.Header.Get("Authorization") != "" { + f.t.Error("public catalogue received service credentials") + } + data, err := os.ReadFile(f.path) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(data)))}, nil +} + // This serves the OCI upload/download protocol over real HTTP. Published layers // are actual Go executables, which fctl downloads and starts after resolving the // deployed version. Manager unit tests separately cover registry failure modes. @@ -95,10 +120,10 @@ func TestExternalLedgerDistributionCLI(t *testing.T) { } v300 := publishExternalLedger(t, publisher, registry.URL, "3.0.0") v301 := publishExternalLedger(t, publisher, registry.URL, "3.0.1") - catalogue := filepath.Join(t.TempDir(), "catalogue.json") + catalogue := filepath.Join(t.TempDir(), "registry.yaml") writeCatalogue := func(releases ...pluginmanager.Release) { t.Helper() - data, err := json.Marshal(pluginmanager.Catalogue{SchemaVersion: 1, Releases: releases}) + data, err := yaml.Marshal(pluginmanager.Catalogue{SchemaVersion: 1, Releases: releases}) if err != nil { t.Fatal(err) } @@ -107,7 +132,10 @@ func TestExternalLedgerDistributionCLI(t *testing.T) { } } writeCatalogue(v300, v301) - t.Setenv("FCTL_PLUGIN_CATALOGUE", catalogue) + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + transport := &officialCatalogueTransport{base: http.DefaultTransport, path: catalogue, t: t} + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = transport.base }) var version atomic.Value version.Store("3.0.0") ledger := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -136,6 +164,9 @@ func TestExternalLedgerDistributionCLI(t *testing.T) { } } assertList() + if transport.reads.Load() != 1 { + t.Fatalf("default discovery must fetch the YAML catalogue once: %d", transport.reads.Load()) + } initialReads := reads.Load() if initialReads != 3 { t.Fatalf("expected OCI manifest/config/executable, got %d reads", initialReads) @@ -161,14 +192,21 @@ func TestExternalLedgerDistributionCLI(t *testing.T) { registry.Close() assertList() // Prepared commands need no catalogue/registry downloads. version.Store("3.0.2") + // A new target whose service has no native release keeps the embedded provider. + unprepared := append([]string{}, list...) + unprepared[1] = t.TempDir() + if out, trace, err := executeExternalCLI(t, unprepared); err != nil || !strings.Contains(out, "books") || trace != "" { + t.Fatalf("unpublished service version must keep embedded Ledger: %s %s %v", out, trace, err) + } out, _, err = executeExternalCLI(t, list) if err == nil || !strings.Contains(err.Error(), "exact service version") || out != "" { t.Fatalf("missing exact release must fail: %s %v", out, err) } ledger.Close() + catalogueReads := transport.reads.Load() args = append(args, "ledger", "--help") out, trace, err := executeExternalCLI(t, args) - if err != nil || trace != "" || !strings.Contains(out, "External Ledger pilot") { + if err != nil || trace != "" || !strings.Contains(out, "External Ledger pilot") || transport.reads.Load() != catalogueReads { t.Fatalf("offline downloaded manifest help: %s %s %v", out, trace, err) } } diff --git a/cmd/plugins_bootstrap.go b/cmd/plugins_bootstrap.go index d4114369..d9942d34 100644 --- a/cmd/plugins_bootstrap.go +++ b/cmd/plugins_bootstrap.go @@ -76,26 +76,57 @@ func (p ledgerPreparation) resolve(ctx context.Context, plan pluginBootstrap) (p catalogue = lock.Catalogue } if catalogue != "" && !plan.help && len(plan.commands) > 0 && plan.commands[0] == "ledger" { - return p.matchVersion(ctx, catalogue) + return p.matchVersion(ctx, catalogue, nil) + } + if catalogue == "" && errors.Is(loadErr, pluginmanager.ErrNotInstalled) && !plan.help && + len(plan.commands) > 0 && plan.commands[0] == "ledger" { + return p.discover(ctx) } return lock, loadErr } + +func (p ledgerPreparation) discover(ctx context.Context) (pluginmanager.Lock, error) { + catalogue, err := p.manager.Discover(ctx, pluginmanager.DefaultCatalogue) + if errors.Is(err, pluginmanager.ErrCatalogueUnavailable) && ctx.Err() == nil { + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled + } + if err != nil { + return pluginmanager.Lock{}, err + } + for _, release := range catalogue.Releases { + if release.Service == "ledger" && release.Platform == pluginmanager.CurrentPlatform() { + return p.matchVersion(ctx, pluginmanager.DefaultCatalogue, &catalogue) + } + } + // Keep the embedded provider until a native release exists for this platform. + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled +} + func (p ledgerPreparation) cached() (pluginmanager.Lock, error) { return cachedLedgerLock(p.settings, p.root, p.manager) } -func (p ledgerPreparation) matchVersion(ctx context.Context, catalogue string) (pluginmanager.Lock, error) { +func (p ledgerPreparation) matchVersion(ctx context.Context, catalogue string, discovered *pluginmanager.Catalogue) (pluginmanager.Lock, error) { target, version, err := pluginSyncTarget(ctx, p.settings, p.root, "") if err != nil { return pluginmanager.Lock{}, err } lock, err := p.manager.Load(target, "ledger") + notInstalled := errors.Is(err, pluginmanager.ErrNotInstalled) if err == nil && lock.ServiceVersion == version { return lock, nil } if err != nil && !errors.Is(err, pluginmanager.ErrNotInstalled) { return lock, err } - release, err := p.manager.Resolve(ctx, catalogue, "ledger", version, 0) + var release pluginmanager.Release + if discovered != nil { + release, err = discovered.Resolve(pluginmanager.CurrentPlatform(), "ledger", version, 0) + } else { + release, err = p.manager.Resolve(ctx, catalogue, "ledger", version, 0) + } + if errors.Is(err, pluginmanager.ErrNoRelease) && discovered != nil && notInstalled { + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled + } if err != nil { return pluginmanager.Lock{}, err } diff --git a/cmd/plugins_discovery_test.go b/cmd/plugins_discovery_test.go new file mode 100644 index 00000000..4dc05f98 --- /dev/null +++ b/cmd/plugins_discovery_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func discoveryPreparation(t *testing.T, transport http.RoundTripper) ledgerPreparation { + t.Helper() + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + settings.Options.AuthMode = "none" + settings.Options.LedgerURL = "http://127.0.0.1:1" + manager, err := pluginmanager.New(t.TempDir(), &http.Client{Transport: transport}) + if err != nil { + t.Fatal(err) + } + return ledgerPreparation{root: root, settings: settings, manager: manager} +} + +func TestOfficialDiscoveryKeepsEmbeddedUntilPublished(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + for _, test := range []struct { + name, data string + status int + embedded bool + }{ + {"empty", "schemaVersion: 1\nreleases: []\n", http.StatusOK, true}, + {"unavailable", "", http.StatusServiceUnavailable, true}, + {"invalid", "schemaVersion: 1\nreleases: invalid\n", http.StatusOK, false}, + } { + t.Run(test.name, func(t *testing.T) { + requests := 0 + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + requests++ + if request.URL.String() != pluginmanager.DefaultCatalogue { + t.Fatalf("unexpected registry: %s", request.URL) + } + return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.data)), Header: make(http.Header)}, nil + })) + _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"ledger", "list"}}) + if errors.Is(err, pluginmanager.ErrNotInstalled) != test.embedded || err == nil || requests != 1 { + t.Fatalf("embedded=%v, requests=%d, error=%v", test.embedded, requests, err) + } + }) + } +} + +func TestOfficialDiscoverySkipsOfflineMetadataAndOtherModules(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("metadata attempted discovery: %s", request.URL) + return nil, context.Canceled + })) + for _, plan := range []pluginBootstrap{ + {commands: []string{"ledger"}, help: true}, + {commands: []string{"__complete", "ledger"}}, + {commands: []string{"auth", "info"}}, + {}, + } { + if _, err := prep.resolve(t.Context(), plan); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("offline bootstrap: %v", err) + } + } +} + +func TestPluginCatalogueUsesOfficialDefaultAndExplicitOverrides(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep := discoveryPreparation(t, discoveryTransport(func(*http.Request) (*http.Response, error) { + t.Fatal("source selection attempted a download") + return nil, context.Canceled + })) + for _, test := range []struct{ explicit, environment, want string }{ + {"", "", pluginmanager.DefaultCatalogue}, + {"", "https://example.com/environment.yaml", "https://example.com/environment.yaml"}, + {"explicit.yaml", "https://example.com/environment.yaml", "explicit.yaml"}, + } { + t.Setenv("FCTL_PLUGIN_CATALOGUE", test.environment) + got, err := pluginCatalogue(prep.root, prep.settings, prep.manager, test.explicit) + if err != nil || got != test.want { + t.Fatalf("catalogue: %q, %v", got, err) + } + } +} + +type discoveryTransport func(*http.Request) (*http.Response, error) + +func (f discoveryTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/cmd/plugins_sync.go b/cmd/plugins_sync.go index 2dc1e98d..67a02655 100644 --- a/cmd/plugins_sync.go +++ b/cmd/plugins_sync.go @@ -1,7 +1,6 @@ package cmd import ( - "fmt" "os" "github.com/spf13/cobra" @@ -16,9 +15,9 @@ func newPluginsSyncCommand(settings *connection.Settings) *cobra.Command { sync := &cobra.Command{ Use: "sync", Short: "Download the Ledger plugin for the exact deployed service version", Args: cobra.NoArgs, Annotations: map[string]string{"fctl.target": "stack"}, - Example: " fctl plugins sync --catalogue catalogue.json --organization ORG --stack STACK\n fctl plugins sync --catalogue https://example.com/plugins.json --service-version 3.0.0 --profile local", + Example: " fctl plugins sync --organization ORG --stack STACK\n fctl plugins sync --catalogue registry.yaml --service-version 3.0.0 --profile local", } - sync.Flags().StringVar(&catalogue, "catalogue", "", "Trusted plugin catalogue file or HTTPS URL (FCTL_PLUGIN_CATALOGUE)") + sync.Flags().StringVar(&catalogue, "catalogue", "", "YAML or JSON catalogue file or HTTPS URL; defaults to the official registry (FCTL_PLUGIN_CATALOGUE)") sync.Flags().StringVar(&serviceVersion, "service-version", "", "Exact service version; defaults to Ledger /_info discovery") sync.Flags().IntVar(&revision, "revision", 0, "Plugin revision; defaults to the highest for this exact service version") sync.RunE = func(cmd *cobra.Command, _ []string) error { @@ -68,5 +67,5 @@ func pluginCatalogue(cmd *cobra.Command, settings *connection.Settings, manager if err == nil && lock.Catalogue != "" { return lock.Catalogue, nil } - return "", fmt.Errorf("choose a trusted --catalogue file or URL, or set FCTL_PLUGIN_CATALOGUE") + return pluginmanager.DefaultCatalogue, nil } diff --git a/docs/plugin-distribution.md b/docs/plugin-distribution.md index cb8da17c..194ad40f 100644 --- a/docs/plugin-distribution.md +++ b/docs/plugin-distribution.md @@ -58,20 +58,34 @@ before advertising its mapping. The catalogue has `schemaVersion: 1` and a | `sha256` | SHA-256 of the raw executable layer | | `manifest` | Complete SDK command and form manifest, including protocol version | -The pilot accepts a trusted catalogue file or HTTPS URL. HTTP is allowed only -for loopback test registries and catalogues. An official public catalogue is -not enabled by default during this pilot. +The pilot accepts a trusted YAML or JSON catalogue file or HTTPS URL. HTTP is +allowed only for loopback test registries and catalogues. The default catalogue +is the public `registry.yaml` in `formancehq/fctl-plugin-registry`: + +```text +https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml +``` ```sh -fctl plugins sync --catalogue ./catalogue.json --profile local +fctl plugins sync --profile local +fctl plugins sync --catalogue ./registry.yaml --profile local fctl ledger list --profile local ``` -Set `FCTL_PLUGIN_CATALOGUE` to enable automatic preparation for new targets. +New Ledger targets discover native releases from the official catalogue. If +it has no matching release, they retain the embedded provider. A temporary +catalogue transport failure also retains the embedded provider for unprepared +targets; invalid metadata remains an error. An empty catalogue does not query +the service version or trigger target selection. + +Set `FCTL_PLUGIN_CATALOGUE` to override the catalogue used for automatic +preparation. Explicit `plugins sync` prefers `--catalogue`, then the environment +variable, then the target's saved catalogue, then the official URL. The core reads `/_info`, selects the exact version and current platform, checks protocol compatibility, downloads the digest-addressed artifact and verifies the manifest, config and executable checksums. A missing exact release fails -with an actionable error. The stack supplies its service version; it never +for explicit sync, custom catalogues and already prepared external targets. +The stack supplies its service version; it never supplies an executable URL. Checksums verify bytes against the trusted catalogue. They do not independently @@ -114,5 +128,5 @@ debug redaction, exact large JSON integers, partial bulk errors, cancellation, endpoint restrictions, corrupt downloads and concurrent cache installation. The SDK and product module can also be built and tested independently. Production -OCI publication and enabling the default official catalogue are separate -release steps; snapshot packaging does not perform either action. +OCI publication and updating the official catalogue are separate release +steps; snapshot packaging does not perform either action. diff --git a/go.mod b/go.mod index 0a9c26e5..5f80bcfc 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,14 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/coreos/go-oidc/v3 v3.17.0 github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba + github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.12.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.47.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -25,9 +27,12 @@ require ( github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/oklog/run v1.1.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect @@ -52,7 +57,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 // indirect github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261009101655-a5cfb893d64d - github.com/formancehq/ledger/fctl-plugin v0.0.0-20261009103441-885e135ef98a github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect diff --git a/go.sum b/go.sum index e5c9b643..20d731a3 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,8 +63,8 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba h1:UlEHshELSyVGhLQamC6zSXLzOvKW/8zAGndNIRa9THY= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba/go.mod h1:7w74DBUsVrg9FuGsogUMsmBCjGGloEKUEyvZGsNuUeg= -github.com/formancehq/ledger/fctl-plugin v0.0.0-20261009103441-885e135ef98a h1:tM7/oaNpAtBZcpln46qI2/XlX2tceJETxmOI3Bfe4WQ= -github.com/formancehq/ledger/fctl-plugin v0.0.0-20261009103441-885e135ef98a/go.mod h1:UUsAeDYhnlZVa8TrxEgJYPvir48rlozeCWdcjqcuamI= +github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d h1:A+Hgy+0j0Gy6ORjlkQl5k0M27fHH9MAjI5jZMmTvJYM= +github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d/go.mod h1:z5UABESLheBEcYTNGSc/am7ek7GsJ42ZwnaV5CoxW48= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= @@ -82,6 +83,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -103,6 +108,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -114,6 +121,9 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= @@ -142,5 +152,9 @@ google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2Q google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/pluginmanager/catalogue.go b/internal/pluginmanager/catalogue.go index 62ae943b..108b0e9a 100644 --- a/internal/pluginmanager/catalogue.go +++ b/internal/pluginmanager/catalogue.go @@ -2,7 +2,6 @@ package pluginmanager import ( "context" - "encoding/json" "fmt" "io" "net" @@ -13,6 +12,8 @@ import ( "regexp" "strings" + "sigs.k8s.io/yaml" + "github.com/formancehq/fctl/pkg/pluginsdk" ) @@ -23,36 +24,68 @@ var ( repositoryPattern = regexp.MustCompile(`^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*$`) ) +// DefaultCatalogue is the official public plugin catalogue. +const DefaultCatalogue = "https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml" + func (m *Manager) resolve(ctx context.Context, source, service, version string, revision int) (Release, error) { if !servicePattern.MatchString(service) || !validVersion(version) || revision < 0 { return Release{}, fmt.Errorf("invalid plugin service, service version or revision") } - data, err := m.catalogueBytes(ctx, source) + catalogue, err := m.Discover(ctx, source) if err != nil { return Release{}, err } + return catalogue.Resolve(m.platform, service, version, revision) +} + +// Discover reads a YAML or JSON catalogue without downloading or starting plugins. +func (m *Manager) Discover(ctx context.Context, source string) (Catalogue, error) { + data, err := m.catalogueBytes(ctx, source) + if err != nil { + return Catalogue{}, err + } var catalogue Catalogue - if err := json.Unmarshal(data, &catalogue); err != nil { - return Release{}, fmt.Errorf("decode plugin catalogue: %w", err) + if err := yaml.UnmarshalStrict(data, &catalogue); err != nil { + return Catalogue{}, fmt.Errorf("decode plugin catalogue: %w", err) } - if catalogue.SchemaVersion != SchemaVersion { - return Release{}, fmt.Errorf("unsupported catalogue schema %d", catalogue.SchemaVersion) + if err := validateCatalogue(catalogue); err != nil { + return Catalogue{}, err } - return selectRelease(catalogue, m.platform, service, version, revision) + return catalogue, nil } -func selectRelease(catalogue Catalogue, platform Platform, service, version string, revision int) (Release, error) { - var selected Release +// Resolve selects an exact service version and platform from an inspected catalogue. +func (c Catalogue) Resolve(platform Platform, service, version string, revision int) (Release, error) { + if !servicePattern.MatchString(service) || !validVersion(version) || revision < 0 { + return Release{}, fmt.Errorf("invalid plugin service, service version or revision") + } + return selectRelease(c, platform, service, version, revision) +} + +func validateCatalogue(catalogue Catalogue) error { + if catalogue.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported catalogue schema %d", catalogue.SchemaVersion) + } seen := make(map[string]bool) for _, release := range catalogue.Releases { if err := validateRelease(release, false); err != nil { - return Release{}, fmt.Errorf("invalid catalogue release: %w", err) + return fmt.Errorf("invalid catalogue release: %w", err) } key := fmt.Sprintf("%s/%s/%d/%s", release.Service, release.ServiceVersion, release.Revision, release.Platform) if seen[key] { - return Release{}, fmt.Errorf("duplicate catalogue release %s", key) + return fmt.Errorf("duplicate catalogue release %s", key) } seen[key] = true + } + return nil +} + +func selectRelease(catalogue Catalogue, platform Platform, service, version string, revision int) (Release, error) { + if err := validateCatalogue(catalogue); err != nil { + return Release{}, err + } + var selected Release + for _, release := range catalogue.Releases { if release.Service == service && release.ServiceVersion == version && release.Platform == platform && (revision == 0 || release.Revision == revision) && release.Revision > selected.Revision { selected = release @@ -86,11 +119,11 @@ func (m *Manager) catalogueBytes(ctx context.Context, source string) (_ []byte, } response, err := m.client.Do(request) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", ErrCatalogueUnavailable, err) } defer func() { err = joinClose(err, response.Body) }() if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("plugin catalogue returned HTTP %d", response.StatusCode) + return nil, fmt.Errorf("%w: HTTP %d", ErrCatalogueUnavailable, response.StatusCode) } return readBounded(response.Body, maxCatalogueBytes) } diff --git a/internal/pluginmanager/catalogue_yaml_test.go b/internal/pluginmanager/catalogue_yaml_test.go new file mode 100644 index 00000000..9970b97b --- /dev/null +++ b/internal/pluginmanager/catalogue_yaml_test.go @@ -0,0 +1,82 @@ +package pluginmanager + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestCatalogueYAMLAndJSONPreserveManifest(t *testing.T) { + m := manager(t) + release := Release{ + Service: "ledger", ServiceVersion: "3.0.0-beta.10", Revision: 2, + Platform: CurrentPlatform(), SHA256: strings.Repeat("a", 64), + Artifact: Artifact{Registry: "https://ghcr.io", Repository: "formancehq/ledger-fctl-plugin", Digest: "sha256:" + strings.Repeat("b", 64)}, + Manifest: manifest("3.0.0-beta.10"), + } + release.Manifest.Root.Flags = []pluginsdk.FlagSpec{{Name: "ledger", Type: "string", Default: "90071992547409930001", Persistent: true}} + release.Manifest.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{ + Title: "Ledger", Kind: "select", Flag: "ledger", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "list"}, ValueField: "name", MatchFields: map[string][]string{"status": {"yes", "no"}}}, + }} + catalogue := Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{release}} + data, err := yaml.Marshal(catalogue) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "registry.yaml") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + for _, source := range []string{path, catalogueFile(t, release)} { + got, err := m.Resolve(t.Context(), source, "ledger", release.ServiceVersion, 0) + if err != nil || !reflect.DeepEqual(got, release) { + t.Fatalf("manifest changed after catalogue decoding: %+v, %v", got, err) + } + if _, err := m.Resolve(t.Context(), source, "ledger", "3.0.0-beta.11", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("selected a different service version: %v", err) + } + } +} + +func TestCatalogueYAMLDiscoveryValidation(t *testing.T) { + for _, test := range []struct { + name, data string + valid bool + }{ + {"empty", "# Official registry\nschemaVersion: 1\nreleases: []\n", true}, + {"unknown-field", "schemaVersion: 1\nreleases: []\nrelases: []\n", false}, + {"duplicate-field", "schemaVersion: 1\nschemaVersion: 2\nreleases: []\n", false}, + {"schema", "schemaVersion: 2\nreleases: []\n", false}, + {"invalid-release", "schemaVersion: 1\nreleases:\n - service: ledger\n revision: 1\n", false}, + } { + t.Run(test.name, func(t *testing.T) { + m := manager(t) + m.client.Transport = catalogueTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != DefaultCatalogue || request.Header.Get("Authorization") != "" { + t.Fatalf("unexpected discovery request: %s", request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.data)), Header: make(http.Header)}, nil + }) + catalogue, err := m.Discover(t.Context(), DefaultCatalogue) + if (err == nil) != test.valid { + t.Fatalf("discovery: %+v %v", catalogue, err) + } + }) + } +} + +type catalogueTransport func(*http.Request) (*http.Response, error) + +func (f catalogueTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/internal/pluginmanager/types.go b/internal/pluginmanager/types.go index 5d9e57e9..81508de3 100644 --- a/internal/pluginmanager/types.go +++ b/internal/pluginmanager/types.go @@ -40,8 +40,9 @@ const ( ) var ( - ErrNotInstalled = errors.New("plugin is not installed for this target") - ErrNoRelease = errors.New("no plugin release for the exact service version and platform") + ErrNotInstalled = errors.New("plugin is not installed for this target") + ErrNoRelease = errors.New("no plugin release for the exact service version and platform") + ErrCatalogueUnavailable = errors.New("plugin catalogue is unavailable") ) type Platform struct { diff --git a/plugins/ledger/plugin.go b/plugins/ledger/plugin.go index fc0e6420..501038ba 100644 --- a/plugins/ledger/plugin.go +++ b/plugins/ledger/plugin.go @@ -1,13 +1,13 @@ // Package ledger embeds the product-owned Ledger fctl plugin. // Service commands, payloads, forms and contract tests live in the independent -// github.com/formancehq/ledger/fctl-plugin module. +// github.com/formancehq/ledger/misc/fctl-plugin module. package ledger import ( "net/http" "github.com/formancehq/fctl/pkg/pluginsdk" - ledgerplugin "github.com/formancehq/ledger/fctl-plugin" + ledgerplugin "github.com/formancehq/ledger/misc/fctl-plugin" ) // New adapts the product factory to the host's embedded registration boundary. From f8280e94f72130025e9fe557a6433552139169aa Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:15:01 +0200 Subject: [PATCH 2/6] refactor(auth): consume the product-owned fctl plugin --- go.mod | 3 +- plugins/auth/auth.go | 149 ----------- plugins/auth/auth_test.go | 423 ------------------------------- plugins/auth/interaction.go | 74 ------ plugins/auth/interaction_test.go | 222 ---------------- plugins/auth/manifest.go | 50 ---- plugins/auth/payload.go | 106 -------- plugins/auth/plugin.go | 14 + 8 files changed, 16 insertions(+), 1025 deletions(-) delete mode 100644 plugins/auth/auth.go delete mode 100644 plugins/auth/auth_test.go delete mode 100644 plugins/auth/interaction.go delete mode 100644 plugins/auth/interaction_test.go delete mode 100644 plugins/auth/manifest.go delete mode 100644 plugins/auth/payload.go create mode 100644 plugins/auth/plugin.go diff --git a/go.mod b/go.mod index 5f80bcfc..053498a5 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 github.com/coreos/go-oidc/v3 v3.17.0 - github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba + github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009151254-70cef9efe2e2 github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.12.1 @@ -23,6 +23,7 @@ require ( require ( github.com/fatih/color v1.13.0 // indirect + github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.8.0 // indirect diff --git a/plugins/auth/auth.go b/plugins/auth/auth.go deleted file mode 100644 index 78e0be39..00000000 --- a/plugins/auth/auth.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package auth implements the Auth plugin using the public generated Auth SDK. -package auth - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - sdk "github.com/formancehq/auth/pkg/client" - "github.com/formancehq/auth/pkg/client/models/components" - "github.com/formancehq/auth/pkg/client/models/operations" - "github.com/formancehq/fctl/pkg/pluginsdk" - httpadapter "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" -) - -type plugin struct{ client *http.Client } -type response interface { - GetHTTPMeta() components.HTTPMetadata -} - -// New creates an Auth plugin with the caller's authenticated HTTP client. -func New(httpClient *http.Client) pluginsdk.Plugin { return &plugin{client: httpClient} } - -func (*plugin) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { - if err := ctx.Err(); err != nil { - return pluginsdk.Manifest{}, err - } - return manifest(), nil -} - -func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { - req, err := pluginsdk.NormalizeRequest(manifest(), req) - if err != nil { - return pluginsdk.ExecuteResponse{}, err - } - if err = validateIDs(req.Args); err != nil { - return pluginsdk.ExecuteResponse{}, err - } - if p.client == nil { - return pluginsdk.ExecuteResponse{}, fmt.Errorf("auth plugin requires an HTTP client") - } - conn, err := httpadapter.New(req.Endpoint, p.client) - if err != nil { - return pluginsdk.ExecuteResponse{}, err - } - s := sdk.New(sdk.WithServerURL(conn.Endpoint()), sdk.WithClient(conn.HTTPClient())) - res, err := dispatch(ctx, s.Auth.V1, req) - data, err := result(res, err) - if err == nil && strings.Join(req.CommandPath, " ") == "auth clients secrets list" { - data, err = secretList(data) - } - if err != nil { - return pluginsdk.ExecuteResponse{}, err - } - return pluginsdk.ExecuteResponse{Data: data}, nil -} -func validateIDs(args []string) error { - for _, id := range args { - if strings.TrimSpace(id) == "" || id == "." || id == ".." { - return fmt.Errorf("identifier must be non-empty and cannot be a dot or double dot") - } - } - return nil -} -func dispatch(ctx context.Context, s *sdk.V1, req pluginsdk.ExecuteRequest) (response, error) { - switch strings.Join(req.CommandPath, " ") { - case "auth info": - return s.GetServerInfo(ctx) - case "auth discovery": - return s.GetOIDCWellKnowns(ctx) - case "auth clients list": - return s.ListClients(ctx) - case "auth clients show", "auth clients secrets list": - return s.ReadClient(ctx, operations.ReadClientRequest{ClientID: url.PathEscape(req.Args[0])}) - case "auth clients create": - return createClient(ctx, s, req.Body) - case "auth clients update": - return updateClient(ctx, s, req.Args[0], req.Body) - case "auth clients delete": - return s.DeleteClient(ctx, operations.DeleteClientRequest{ClientID: url.PathEscape(req.Args[0])}) - case "auth clients secrets create": - return createSecret(ctx, s, req.Args[0], req.Body) - case "auth clients secrets delete": - return s.DeleteSecret(ctx, operations.DeleteSecretRequest{ClientID: url.PathEscape(req.Args[0]), SecretID: url.PathEscape(req.Args[1])}) - case "auth users list": - return s.ListUsers(ctx) - case "auth users show": - return s.ReadUser(ctx, operations.ReadUserRequest{UserID: url.PathEscape(req.Args[0])}) - default: - return nil, fmt.Errorf("unsupported auth command %q", req.CommandPath) - } -} -func createClient(ctx context.Context, s *sdk.V1, data json.RawMessage) (response, error) { - var req components.CreateClientRequest - if err := decodeObject(data, &req); err != nil { - return nil, err - } - if strings.TrimSpace(req.Name) == "" { - return nil, fmt.Errorf("--data requires a non-empty name") - } - return s.CreateClient(ctx, &req) -} -func createSecret(ctx context.Context, s *sdk.V1, id string, data json.RawMessage) (response, error) { - var req components.CreateSecretRequest - if err := decodeObject(data, &req); err != nil { - return nil, err - } - if strings.TrimSpace(req.Name) == "" { - return nil, fmt.Errorf("--data requires a non-empty name") - } - return s.CreateSecret(ctx, operations.CreateSecretRequest{ClientID: url.PathEscape(id), CreateSecretRequest: &req}) -} -func updateClient(ctx context.Context, s *sdk.V1, id string, data json.RawMessage) (response, error) { - var patch components.UpdateClientRequest - if err := decodeObject(data, &patch); err != nil { - return nil, err - } - current, err := s.ReadClient(ctx, operations.ReadClientRequest{ClientID: url.PathEscape(id)}) - if err != nil { - return current, err - } - body, err := responseBody(current) - if err != nil { - return nil, err - } - merged, err := mergeOptions(body, data) - if err != nil { - return nil, err - } - var req components.UpdateClientRequest - if err = decodeObject(merged, &req); err != nil { - return nil, err - } - if strings.TrimSpace(req.Name) == "" { - return nil, fmt.Errorf("--data requires a non-empty name") - } - return s.UpdateClient(ctx, operations.UpdateClientRequest{ClientID: url.PathEscape(id), UpdateClientRequest: &req}) -} - -// Auth errors leave stdout empty; the host reports the error separately. -func result(res response, err error) (json.RawMessage, error) { - if err != nil { - return nil, err - } - return responseBody(res) -} diff --git a/plugins/auth/auth_test.go b/plugins/auth/auth_test.go deleted file mode 100644 index 3c1e3334..00000000 --- a/plugins/auth/auth_test.go +++ /dev/null @@ -1,423 +0,0 @@ -package auth - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/formancehq/auth/pkg/client/models/components" - "github.com/formancehq/fctl/pkg/pluginsdk" -) - -type roundTrip func(*http.Request) (*http.Response, error) - -func (f roundTrip) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -func execute(t *testing.T, req pluginsdk.ExecuteRequest, transport roundTrip) (pluginsdk.ExecuteResponse, error) { - t.Helper() - client := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { - if r.Context() != t.Context() { - t.Fatal("lost request context") - } - r.Header.Set("Authorization", "Bearer injected") - return transport(r) - })} - req.Endpoint = "https://example.test/api/auth/" - return New(client).Execute(t.Context(), req) -} -func request(path string, args []string, body string, confirm bool) pluginsdk.ExecuteRequest { - req := pluginsdk.ExecuteRequest{CommandPath: strings.Fields("auth " + path), Args: args, Flags: map[string]string{}, ChangedFlags: map[string]bool{}} - if body != "" { - req.Body = json.RawMessage(body) - req.Flags["data"] = "-" - req.ChangedFlags["data"] = true - } - if confirm { - req.Flags["confirm"] = "true" - req.ChangedFlags["confirm"] = true - } - return req -} -func reply(code int, body string) *http.Response { - return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} -} - -type commandTest struct { - path string - args []string - body string - confirm bool - method, url, response, want string -} - -func TestCommands(t *testing.T) { - cases := []commandTest{ - {"info", nil, "", false, "GET", "/_info", `{"version":"2.5.0","precision":9007199254740993}`, "9007199254740993"}, - {"discovery", nil, "", false, "GET", "/.well-known/openid-configuration", `{"issuer":"https://example.test","token_endpoint":"https://example.test/oauth/token"}`, "token_endpoint"}, - {"clients list", nil, "", false, "GET", "/clients", `{"data":[]}`, `"data"`}, - {"clients show", []string{"c1"}, "", false, "GET", "/clients/c1", `{"data":{"id":"c1","name":"test","extra":9007199254740993}}`, "9007199254740993"}, - {"clients create", nil, `{"name":"new","public":false,"scopes":["auth:read"],"metadata":{"env":"test"}}`, false, "POST", "/clients", `{"data":{"id":"c1","name":"new"}}`, `"new"`}, - {"clients delete", []string{"c1"}, "", true, "DELETE", "/clients/c1", "", "null"}, - {"clients secrets create", []string{"c1"}, `{"name":"secret","metadata":{"env":"test"}}`, false, "POST", "/clients/c1/secrets", `{"data":{"id":"s1","name":"secret","clear":"one-time"}}`, "one-time"}, - {"clients secrets delete", []string{"c1", "s1"}, "", true, "DELETE", "/clients/c1/secrets/s1", "", "null"}, - {"clients secrets list", []string{"c1"}, "", false, "GET", "/clients/c1", `{"data":{"id":"c1","name":"test","secrets":[{"id":"s1","name":"secret","lastDigits":"1234","extra":9007199254740993}]}}`, "9007199254740993"}, - {"users list", nil, "", false, "GET", "/users", `{"data":[]}`, `"data"`}, - {"users show", []string{"u1"}, "", false, "GET", "/users/u1", `{"data":{"id":"u1","subject":"sub","email":"user@example.test"}}`, "user@example.test"}, - } - for _, test := range cases { - t.Run(test.path, func(t *testing.T) { testCommand(t, test) }) - } -} -func testCommand(t *testing.T, test commandTest) { - t.Helper() - calls := 0 - res, err := execute(t, request(test.path, test.args, test.body, test.confirm), func(req *http.Request) (*http.Response, error) { calls++; return commandReply(t, test, req), nil }) - if err != nil { - t.Fatal(err) - } - if calls != 1 { - t.Fatalf("calls=%d", calls) - } - if !json.Valid(res.Data) || !strings.Contains(string(res.Data), test.want) { - t.Fatalf("data=%s", res.Data) - } - if test.path == "clients secrets list" && strings.Contains(string(res.Data), `"data"`) { - t.Fatal("expected a secret array") - } -} -func commandReply(t *testing.T, test commandTest, req *http.Request) *http.Response { - t.Helper() - if req.Method != test.method || req.URL.Path != "/api/auth"+test.url { - t.Fatalf("request=%s %s", req.Method, req.URL) - } - if req.Header.Get("Authorization") != "Bearer injected" { - t.Fatal("lost injected authentication") - } - if test.body != "" { - assertJSON(t, readRequest(t, req), []byte(test.body)) - if req.Header.Get("Content-Type") != "application/json" { - t.Fatal("missing content type") - } - } - code := 200 - if test.path == "clients create" { - code = 201 - } - if test.method == "DELETE" { - code = 204 - } - return reply(code, test.response) -} -func readRequest(t *testing.T, req *http.Request) []byte { - t.Helper() - body, err := io.ReadAll(req.Body) - if err != nil { - t.Fatal(err) - } - return body -} -func assertJSON(t *testing.T, got, want []byte) { - t.Helper() - a := canonicalJSON(t, got) - b := canonicalJSON(t, want) - if !bytes.Equal(a, b) { - t.Fatalf("JSON=%s; want %s", a, b) - } -} -func canonicalJSON(t *testing.T, raw []byte) []byte { - t.Helper() - var value any - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - if err := decoder.Decode(&value); err != nil { - t.Fatal(err) - } - data, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return data -} -func TestUpdatePreservesAndClearsOptions(t *testing.T) { - current := `{"data":{"id":"c1","name":"original","description":"old","public":true,"trusted":true,"redirectUris":["https://example.test/cb"],"postLogoutRedirectUris":["https://example.test/bye"],"scopes":["auth:read"],"metadata":{"env":"test"},"secrets":[]}}` - for _, patch := range []string{`{"description":"changed"}`, `{"public":false,"trusted":false,"redirectUris":[],"postLogoutRedirectUris":[],"scopes":[],"metadata":{}}`} { - t.Run(patch, func(t *testing.T) { - calls := 0 - res, err := execute(t, request("clients update", []string{"c1"}, patch, true), func(req *http.Request) (*http.Response, error) { - calls++ - return updateReply(t, req, calls, current, patch), nil - }) - if err != nil { - t.Fatal(err) - } - if calls != 2 || !strings.Contains(string(res.Data), "original") { - t.Fatalf("calls=%d data=%s", calls, res.Data) - } - }) - } -} -func updateReply(t *testing.T, req *http.Request, calls int, current, patch string) *http.Response { - t.Helper() - if req.URL.Path != "/api/auth/clients/c1" { - t.Fatal(req.URL) - } - if calls == 1 { - if req.Method != "GET" { - t.Fatal(req.Method) - } - return reply(200, current) - } - if req.Method != "PUT" { - t.Fatal(req.Method) - } - checkUpdatedOptions(t, readRequest(t, req), current, patch) - return reply(200, current) -} -func checkUpdatedOptions(t *testing.T, body []byte, current, patch string) { - t.Helper() - var got map[string]json.RawMessage - decode(t, body, &got) - if _, ok := got["id"]; ok { - t.Fatal("sent read-only id") - } - if _, ok := got["secrets"]; ok { - t.Fatal("sent secrets") - } - if string(got["name"]) != `"original"` { - t.Fatal("lost name") - } - var envelope struct { - Data map[string]json.RawMessage `json:"data"` - } - decode(t, []byte(current), &envelope) - var changes map[string]json.RawMessage - decode(t, []byte(patch), &changes) - for key, value := range envelope.Data { - if key == "id" || key == "secrets" { - continue - } - if v, ok := changes[key]; ok { - value = v - } - checkOption(t, key, got[key], value) - } -} -func decode(t *testing.T, data []byte, target any) { - t.Helper() - if err := json.Unmarshal(data, target); err != nil { - t.Fatal(err) - } -} -func checkOption(t *testing.T, key string, got, want json.RawMessage) { - t.Helper() - if len(got) == 0 { // Generated omitempty fields decode as empty options on the server. - if string(want) != "[]" && string(want) != "{}" { - t.Fatalf("lost %s", key) - } - return - } - assertJSON(t, got, want) -} - -// The host may authenticate before Execute; this fixture checks service HTTP only. -func TestValidationBeforeServiceRequest(t *testing.T) { - cases := []pluginsdk.ExecuteRequest{ - request("clients delete", []string{"c1"}, "", false), request("clients update", []string{"c1"}, `{"name":"x"}`, false), request("clients secrets delete", []string{"c1", "s1"}, "", false), - request("clients create", nil, `{"unknown":true,"name":"x"}`, false), request("clients create", nil, `null`, false), request("clients create", nil, `{"name":""}`, false), request("clients create", nil, `{"name":null}`, false), - request("clients update", []string{"c1"}, `{"scopes":1}`, true), request("clients secrets create", []string{"c1"}, `{}`, false), request("clients create", nil, "", false), - request("clients show", []string{".."}, "", false), request("users show", []string{""}, "", false), request("users show", []string{"."}, "", false), request("missing", nil, "", false), request("clients", nil, "", false), request("users show", nil, "", false), - } - for _, req := range cases { - t.Run(strings.Join(req.CommandPath, " ")+string(req.Body), func(t *testing.T) { - res, err := execute(t, req, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network request"); return nil, nil }) - if err == nil || len(res.Data) > 0 { - t.Fatalf("data=%s err=%v", res.Data, err) - } - }) - } -} -func TestFlagValidation(t *testing.T) { - for _, flags := range []map[string]string{{"confirm": "invalid"}, {"unknown": "true"}} { - req := request("clients delete", []string{"c1"}, "", true) - req.Flags = flags - _, err := execute(t, req, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network request"); return nil, nil }) - if err == nil { - t.Fatal("expected invalid flag error") - } - } -} -func TestErrorsLeaveDataEmpty(t *testing.T) { - for _, status := range []int{401, 404, 500} { - t.Run(http.StatusText(status), func(t *testing.T) { - body := `{"errorCode":"FAIL","errorMessage":"denied","partial":9007199254740993}` - res, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return reply(status, body), nil }) - if err == nil { - t.Fatal("expected error") - } - if len(res.Data) != 0 { - t.Fatalf("error data=%s", res.Data) - } - }) - } - offline := errors.New("offline") - res, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return nil, offline }) - if !errors.Is(err, offline) || res.Data != nil { - t.Fatalf("data=%s err=%v", res.Data, err) - } -} -func TestInvalidResponse(t *testing.T) { - for _, code := range []int{200, 400} { - _, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return reply(code, "broken"), nil }) - if err == nil { - t.Fatal("expected JSON error") - } - } -} -func TestEncodedIdentifier(t *testing.T) { - _, err := execute(t, request("clients show", []string{"a/b ?#%"}, "", false), func(req *http.Request) (*http.Response, error) { - if req.URL.EscapedPath() != "/api/auth/clients/a%2Fb%20%3F%23%25" || req.URL.RawQuery != "" { - t.Fatal(req.URL) - } - return reply(200, `{"data":{"id":"c1","name":"test"}}`), nil - }) - if err != nil { - t.Fatal(err) - } -} -func TestUpdateReadFailureDoesNotWrite(t *testing.T) { - for _, body := range []string{`{"errorCode":"NOT_FOUND","errorMessage":"missing"}`, `{}`} { - calls := 0 - _, err := execute(t, request("clients update", []string{"c1"}, `{"description":"changed"}`, true), func(req *http.Request) (*http.Response, error) { - calls++ - if req.Method != "GET" { - t.Fatal("write after failed read") - } - code := 404 - if body == `{}` { - code = 200 - } - return reply(code, body), nil - }) - if err == nil || calls != 1 { - t.Fatalf("calls=%d err=%v", calls, err) - } - } -} -func TestEmptySecrets(t *testing.T) { - res, err := execute(t, request("clients secrets list", []string{"c1"}, "", false), func(*http.Request) (*http.Response, error) { - return reply(200, `{"data":{"id":"c1","name":"test"}}`), nil - }) - if err != nil { - t.Fatal(err) - } - assertJSON(t, res.Data, []byte(`[]`)) -} -func TestManifest(t *testing.T) { - m, err := New(nil).GetManifest(t.Context()) - if err != nil { - t.Fatal(err) - } - if m.Name != "auth" || m.Service != "auth" || m.Version == "" || m.ProtocolVersion != pluginsdk.ProtocolVersion { - t.Fatalf("manifest=%+v", m) - } - update, err := pluginsdk.FindCommand(m, []string{"auth", "clients", "update"}) - if err != nil { - t.Fatal(err) - } - if !update.Confirm || !strings.Contains(update.Long, "preserved") { - t.Fatal("update contract missing") - } - create, err := pluginsdk.FindCommand(m, []string{"auth", "clients", "create"}) - if err != nil { - t.Fatal(err) - } - if len(create.Flags) != 1 || !create.Flags[0].Required || !create.Flags[0].Body || create.Flags[0].Type != "string" { - t.Fatal("body contract missing") - } - // Each call returns an independent manifest, safe for concurrent host adapters. - m.Root.Subcommands[0].Use = "changed" - fresh, err := New(nil).GetManifest(t.Context()) - if err != nil { - t.Fatal(err) - } - if fresh.Root.Subcommands[0].Use != "info" { - t.Fatal("shared manifest state") - } -} -func TestConnectionValidation(t *testing.T) { - _, err := New(nil).Execute(t.Context(), request("info", nil, "", false)) - if err == nil { - t.Fatal("expected missing client error") - } - req := request("info", nil, "", false) - req.Endpoint = "https://user:secret@example.test" - _, err = New(&http.Client{}).Execute(t.Context(), req) - if err == nil { - t.Fatal("expected endpoint error") - } -} - -func TestManifestCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - cancel() - m, err := New(nil).GetManifest(ctx) - if !errors.Is(err, context.Canceled) || m.Name != "" { - t.Fatalf("manifest=%+v err=%v", m, err) - } -} -func TestProvidedBodyWithoutDataFlag(t *testing.T) { - req := request("clients create", nil, `{"name":"direct"}`, false) - req.Flags = nil - req.ChangedFlags = nil - res, err := execute(t, req, func(r *http.Request) (*http.Response, error) { - if r.Method != "POST" || r.URL.Path != "/api/auth/clients" { - t.Fatalf("request=%s %s", r.Method, r.URL) - } - assertJSON(t, readRequest(t, r), []byte(`{"name":"direct"}`)) - return reply(201, `{"data":{"id":"c1","name":"direct"}}`), nil - }) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(res.Data), "direct") { - t.Fatal(string(res.Data)) - } -} - -type responseFixture struct{ metadata components.HTTPMetadata } - -func (r responseFixture) GetHTTPMeta() components.HTTPMetadata { return r.metadata } - -type unreadBody struct{ touched bool } - -func (b *unreadBody) Read([]byte) (int, error) { - b.touched = true - return 0, errors.New("response must not be read") -} -func (b *unreadBody) Close() error { - b.touched = true - return errors.New("response must not be processed") -} -func TestResultErrorDoesNotProcessResponse(t *testing.T) { - body := &unreadBody{} - failure := errors.New("operation failed") - res := responseFixture{metadata: components.HTTPMetadata{Response: &http.Response{StatusCode: 400, Body: body}}} - data, err := result(res, failure) - if len(data) != 0 || !errors.Is(err, failure) || body.touched { - t.Fatalf("data=%s err=%v response touched=%v", data, err, body.touched) - } -} - -func TestResultMissingResponse(t *testing.T) { - var absent *responseFixture - for _, res := range []response{nil, absent} { - data, err := result(res, nil) - if len(data) != 0 || err == nil { - t.Fatalf("missing response: data=%s err=%v", data, err) - } - } -} diff --git a/plugins/auth/interaction.go b/plugins/auth/interaction.go deleted file mode 100644 index fc676a76..00000000 --- a/plugins/auth/interaction.go +++ /dev/null @@ -1,74 +0,0 @@ -package auth - -import ( - "strings" - - "github.com/formancehq/fctl/pkg/pluginsdk" -) - -// Inputs belong to leaves. The host resolves stack context and runs these -// ordinary read commands through the plugin's injected client. -func addInputs(command *pluginsdk.CommandSpec, parent []string) { - path := append(append([]string{}, parent...), pluginsdk.CommandName(*command)) - if command.Runnable { - command.Inputs = commandInputs(strings.Join(path, " ")) - } - for i := range command.Subcommands { - addInputs(&command.Subcommands[i], path) - } -} - -func commandInputs(path string) []pluginsdk.InputSpec { - switch path { - case "auth clients create": - return clientCreateInputs() - case "auth clients show", "auth clients delete", "auth clients secrets list": - return []pluginsdk.InputSpec{clientInput()} - case "auth clients update": - // Do not add default booleans, arrays or objects to an update patch. - // Existing options are merged by updateClient. Explicit --data patches - // also let callers update any option without requiring a new name. - return []pluginsdk.InputSpec{ - clientInput(), - {Title: "Client name", Description: "New name for the client", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, - {Title: "Description", Description: "Leave blank to preserve the current description", Kind: "input", BodyPointer: "/description", ValueType: "string"}, - } - case "auth clients secrets create": - return []pluginsdk.InputSpec{ - clientInput(), - {Title: "Secret name", Description: "Label for the new secret; its clear value is generated by Auth", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, - {Title: "Metadata", Description: `Optional JSON object of string values, e.g. {"purpose":"ci"}`, Kind: "text", BodyPointer: "/metadata", ValueType: "json"}, - } - case "auth clients secrets delete": - return []pluginsdk.InputSpec{ - clientInput(), - {Title: "Client secret", Kind: "select", Argument: new(1), ValueType: "string", Required: true, - Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "clients", "secrets", "list"}, Args: []string{"$arg0"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "This client has no secrets to delete"}}, - } - case "auth users show": - return []pluginsdk.InputSpec{ - {Title: "User", Kind: "select", Argument: new(0), ValueType: "string", Required: true, - Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "users", "list"}, ValueField: "id", LabelFields: []string{"email", "subject", "id"}, EmptyMessage: "No Auth users are available"}}, - } - default: - return nil - } -} - -func clientInput() pluginsdk.InputSpec { - return pluginsdk.InputSpec{Title: "OAuth2 client", Kind: "select", Argument: new(0), ValueType: "string", Required: true, - Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "clients", "list"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "No OAuth2 clients are available; create a client first"}} -} - -func clientCreateInputs() []pluginsdk.InputSpec { - return []pluginsdk.InputSpec{ - {Title: "Client name", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, - {Title: "Description", Kind: "input", BodyPointer: "/description", ValueType: "string"}, - {Title: "Public client", Description: "Public clients cannot keep a client secret", Kind: "confirm", BodyPointer: "/public", ValueType: "bool", Default: "false"}, - {Title: "Trusted client", Description: "Trusted clients bypass user consent", Kind: "confirm", BodyPointer: "/trusted", ValueType: "bool", Default: "false"}, - {Title: "Scopes", Description: `JSON array of scope strings, e.g. ["ledger:read"]`, Kind: "text", BodyPointer: "/scopes", ValueType: "json"}, - {Title: "Redirect URIs", Description: `JSON array of URI strings, e.g. ["https://example.test/callback"]`, Kind: "text", BodyPointer: "/redirectUris", ValueType: "json"}, - {Title: "Post-logout redirect URIs", Description: "Optional JSON array of URI strings", Kind: "text", BodyPointer: "/postLogoutRedirectUris", ValueType: "json"}, - {Title: "Metadata", Description: `Optional JSON object of string values, e.g. {"purpose":"automation"}`, Kind: "text", BodyPointer: "/metadata", ValueType: "json"}, - } -} diff --git a/plugins/auth/interaction_test.go b/plugins/auth/interaction_test.go deleted file mode 100644 index 4c5b8171..00000000 --- a/plugins/auth/interaction_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package auth - -import ( - "encoding/json" - "net/http" - "reflect" - "slices" - "strings" - "testing" - - "github.com/formancehq/fctl/pkg/pluginsdk" -) - -func TestInteractionManifestContract(t *testing.T) { - t.Parallel() - m := manifest() - if m.Root.Target != "stack" { - t.Fatal("Auth must target stack context") - } - data, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - var decoded pluginsdk.Manifest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(m, decoded) { - t.Fatal("input metadata does not survive serialization") - } - walkInputs(t, m, m.Root, nil) -} - -func walkInputs(t *testing.T, m pluginsdk.Manifest, command pluginsdk.CommandSpec, parent []string) { - t.Helper() - path := append(append([]string{}, parent...), pluginsdk.CommandName(command)) - if !command.Runnable && len(command.Inputs) != 0 { - t.Fatalf("%s has inherited inputs", path) - } - for _, input := range command.Inputs { - checkInputBinding(t, command, input) - if input.Source != nil { - source, err := pluginsdk.FindCommand(m, input.Source.CommandPath) - if err != nil || !source.Runnable || source.Confirm { - t.Fatalf("%s has invalid source: %#v, %v", path, input.Source, err) - } - if len(input.Source.Args) != source.Args.Min || input.Source.ValueField == "" || input.Source.EmptyMessage == "" { - t.Fatalf("incomplete source: %#v", input.Source) - } - } - } - for _, child := range command.Subcommands { - walkInputs(t, m, child, path) - } -} - -func checkInputBinding(t *testing.T, command pluginsdk.CommandSpec, input pluginsdk.InputSpec) { - t.Helper() - bindings := 0 - for _, bound := range []bool{input.Flag != "", input.Argument != nil, input.BodyPointer != "", input.Context != ""} { - if bound { - bindings++ - } - } - if bindings != 1 || input.Title == "" || !slices.Contains([]string{"input", "text", "select", "confirm"}, input.Kind) || !slices.Contains([]string{"", "string", "json", "bool", "number"}, input.ValueType) { - t.Fatalf("invalid input: %#v", input) - } - if input.Argument != nil && (*input.Argument < 0 || *input.Argument >= command.Args.Max) { - t.Fatalf("argument binding out of range: %#v", input) - } - if input.BodyPointer != "" { - checkBodyPointer(t, input.BodyPointer) - } -} - -func checkBodyPointer(t *testing.T, pointer string) { - t.Helper() - if !strings.HasPrefix(pointer, "/") { - t.Fatalf("invalid JSON pointer %q", pointer) - } - for i := 0; i < len(pointer); i++ { - if pointer[i] == '~' { - i++ - if i >= len(pointer) || (pointer[i] != '0' && pointer[i] != '1') { - t.Fatalf("invalid JSON pointer escape %q", pointer) - } - } - } -} - -func TestAuthCreateBodyInputsMatchSDK(t *testing.T) { - t.Parallel() - command, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth clients create")) - if err != nil { - t.Fatal(err) - } - values := map[string]string{ - "/name": `"automation"`, "/description": `"Service client"`, "/public": `false`, "/trusted": `false`, - "/scopes": `["ledger:read"]`, "/redirectUris": `["https://example.test/callback"]`, - "/postLogoutRedirectUris": `["https://example.test/logout"]`, "/metadata": `{"purpose":"tests"}`, - } - body := make(map[string]json.RawMessage) - for _, input := range command.Inputs { - value, ok := values[input.BodyPointer] - if !ok { - t.Fatalf("unknown SDK field %q", input.BodyPointer) - } - body[strings.TrimPrefix(input.BodyPointer, "/")] = json.RawMessage(value) - } - if len(body) != len(values) || !command.Inputs[0].Required { - t.Fatal("incomplete client creation form") - } - data, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - _, err = execute(t, request("clients create", nil, string(data), false), func(req *http.Request) (*http.Response, error) { - assertJSON(t, readRequest(t, req), data) - return reply(http.StatusCreated, `{"data":{"id":"c1","name":"automation"}}`), nil - }) - if err != nil { - t.Fatal(err) - } -} - -func TestAuthUpdateInputsPreserveOmittedOptions(t *testing.T) { - t.Parallel() - command, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth clients update")) - if err != nil { - t.Fatal(err) - } - for _, input := range command.Inputs { - if input.BodyPointer == "" { - continue - } - if !slices.Contains([]string{"/name", "/description"}, input.BodyPointer) || input.Default != "" || input.Kind == "confirm" { - t.Fatalf("update injects an omitted option: %#v", input) - } - if input.BodyPointer == "/description" && input.Required { - t.Fatal("description must be optional") - } - } - current := `{"data":{"name":"old","description":"preserved","public":true,"trusted":true,"scopes":["ledger:read"],"metadata":{"purpose":"original"}}}` - patch := `{"name":"renamed"}` - merged, err := mergeOptions(json.RawMessage(current), json.RawMessage(patch)) - if err != nil { - t.Fatal(err) - } - assertJSON(t, merged, []byte(`{"name":"renamed","description":"preserved","public":true,"trusted":true,"scopes":["ledger:read"],"metadata":{"purpose":"original"}}`)) -} - -func TestAuthChoiceSourcesUseInjectedReadClient(t *testing.T) { - t.Parallel() - cases := []struct { - command, field, source, response, value string - argument int - }{ - {"clients show", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, - {"clients delete", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, - {"clients update", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, - {"clients secrets list", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, - {"clients secrets create", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, - {"clients secrets delete", "id", "clients secrets list", `{"data":{"id":"c1","name":"Automation","secrets":[{"id":"s1","name":"CI"}]}}`, "s1", 1}, - {"users show", "id", "users list", `{"data":[{"id":"u1","subject":"sub","email":"user@example.test"}]}`, "u1", 0}, - } - for _, tc := range cases { - t.Run(tc.command, func(t *testing.T) { - checkAuthChoiceSource(t, tc.command, tc.argument, tc.source, tc.response, tc.field, tc.value) - }) - } -} - -func checkAuthChoiceSource(t *testing.T, command string, argument int, sourcePath, response, field, value string) { - t.Helper() - spec, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth "+command)) - if err != nil { - t.Fatal(err) - } - index := slices.IndexFunc(spec.Inputs, func(input pluginsdk.InputSpec) bool { return input.Argument != nil && *input.Argument == argument }) - if index < 0 || spec.Inputs[index].Source == nil { - t.Fatal("missing selector") - } - source := spec.Inputs[index].Source - if strings.Join(source.CommandPath, " ") != "auth "+sourcePath || source.ValueField != field { - t.Fatalf("source = %#v", source) - } - args := slices.Clone(source.Args) - for i, arg := range args { - if arg != "$arg0" { - t.Fatalf("unexpected substitution %q", arg) - } - args[i] = "c1" - } - calls := 0 - result, err := execute(t, pluginsdk.ExecuteRequest{CommandPath: source.CommandPath, Args: args}, func(req *http.Request) (*http.Response, error) { - calls++ - if req.Method != http.MethodGet { - t.Fatalf("choice source attempted %s", req.Method) - } - return reply(http.StatusOK, response), nil - }) - if err != nil || calls != 1 { - t.Fatalf("source execution: calls=%d, error=%v", calls, err) - } - rows := authChoiceRows(t, result.Data) - if len(rows) != 1 || rows[0][field] != value { - t.Fatalf("selector cannot read %s: %s", field, result.Data) - } -} - -func authChoiceRows(t *testing.T, data json.RawMessage) []map[string]string { - t.Helper() - var envelope struct { - Data []map[string]string `json:"data"` - } - if strings.HasPrefix(string(data), "[") { - decode(t, data, &envelope.Data) - } else { - decode(t, data, &envelope) - } - return envelope.Data -} diff --git a/plugins/auth/manifest.go b/plugins/auth/manifest.go deleted file mode 100644 index 4a4b6818..00000000 --- a/plugins/auth/manifest.go +++ /dev/null @@ -1,50 +0,0 @@ -package auth - -import "github.com/formancehq/fctl/pkg/pluginsdk" - -func manifest() pluginsdk.Manifest { - m := pluginsdk.Manifest{ - Name: "auth", Version: "0.1.0", Service: "auth", ProtocolVersion: pluginsdk.ProtocolVersion, - Root: pluginsdk.CommandSpec{Use: "auth", Target: "stack", Short: "Manage Auth clients, secrets and users", Subcommands: []pluginsdk.CommandSpec{ - leaf("info", "Show Auth server information", 0, false), - leaf("discovery", "Show OpenID Connect discovery configuration", 0, false), - {Use: "clients", Short: "Manage OAuth2 clients", Subcommands: []pluginsdk.CommandSpec{ - leaf("list", "List OAuth2 clients", 0, false), - leaf("show CLIENT", "Show a client and its secret metadata", 1, false), - bodyCommand("create", "Create an OAuth2 client", 0, false, false), - bodyCommand("update CLIENT", "Update client options, preserving omitted fields", 1, true, false), - leaf("delete CLIENT", "Delete an OAuth2 client", 1, true), - {Use: "secrets", Short: "Manage client secrets (clear values are returned only at creation)", Subcommands: []pluginsdk.CommandSpec{ - leaf("list CLIENT", "List secret metadata for a client", 1, false), - bodyCommand("create CLIENT", "Create a secret; save the returned clear value", 1, false, true), - leaf("delete CLIENT SECRET", "Delete a client secret", 2, true), - }}, - }}, - {Use: "users", Short: "Read Auth users", Subcommands: []pluginsdk.CommandSpec{ - leaf("list", "List users", 0, false), leaf("show USER", "Show a user", 1, false), - }}, - }}, - } - addInputs(&m.Root, nil) - return m -} -func leaf(use, short string, args int, confirm bool) pluginsdk.CommandSpec { - spec := pluginsdk.CommandSpec{Use: use, Short: short, Args: pluginsdk.ArgsSpec{Min: args, Max: args}, Runnable: true, Confirm: confirm} - if confirm { - spec.Flags = []pluginsdk.FlagSpec{{Name: "confirm", Type: "bool", Default: "false", Usage: "Confirm this destructive operation"}} - } - return spec -} -func bodyCommand(use, short string, args int, confirm, secret bool) pluginsdk.CommandSpec { - spec := leaf(use, short, args, confirm) - spec.Flags = append(spec.Flags, pluginsdk.FlagSpec{Name: "data", Type: "string", Usage: "JSON object; inline, @file, or - for stdin", Required: true, Body: true}) - spec.Long = short + ".\nOptions: name, description, public, trusted, redirectUris, postLogoutRedirectUris, scopes, metadata." - if secret { - spec.Long = short + ".\nJSON object with required name and optional metadata." - } else if confirm { - spec.Long += "\nOmitted options are preserved. Explicit false, empty arrays and empty objects clear options. Requires --confirm." - } else { - spec.Long += "\nThe name field is required. Create a secret separately with clients secrets create." - } - return spec -} diff --git a/plugins/auth/payload.go b/plugins/auth/payload.go deleted file mode 100644 index 7cab0436..00000000 --- a/plugins/auth/payload.go +++ /dev/null @@ -1,106 +0,0 @@ -package auth - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "maps" - "net/http" - "reflect" -) - -func decodeObject(data json.RawMessage, target any) error { - if len(bytes.TrimSpace(data)) == 0 || bytes.TrimSpace(data)[0] != '{' { - return fmt.Errorf("--data must be a JSON object") - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return fmt.Errorf("invalid --data: %w", err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(data, &fields); err != nil { - return err - } - for key, value := range fields { - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return fmt.Errorf("--data field %s cannot be null; use false, an empty string, array or object", key) - } - } - return nil -} -func responseBody(res response) (json.RawMessage, error) { - if res == nil || (reflect.ValueOf(res).Kind() == reflect.Pointer && reflect.ValueOf(res).IsNil()) { - return nil, fmt.Errorf("auth SDK returned no response") - } - httpRes := res.GetHTTPMeta().Response - if httpRes == nil { - return nil, fmt.Errorf("auth SDK returned no HTTP response") - } - if httpRes.Body == nil { - return json.RawMessage("null"), nil - } - body, err := io.ReadAll(io.LimitReader(httpRes.Body, 32<<20+1)) - closeErr := httpRes.Body.Close() - if err != nil { - return nil, fmt.Errorf("read Auth response: %w", err) - } - if closeErr != nil { - return nil, fmt.Errorf("close Auth response: %w", closeErr) - } - if len(body) > 32<<20 { - return nil, fmt.Errorf("auth response exceeds 32 MiB") - } - if httpRes.StatusCode < http.StatusOK || httpRes.StatusCode >= http.StatusMultipleChoices { - return nil, fmt.Errorf("auth HTTP %d", httpRes.StatusCode) - } - if len(bytes.TrimSpace(body)) == 0 { - return json.RawMessage("null"), nil - } - if !json.Valid(body) { - return nil, fmt.Errorf("auth returned invalid JSON") - } - return body, nil -} -func mergeOptions(current, patch json.RawMessage) (json.RawMessage, error) { - var envelope struct { - Data map[string]json.RawMessage `json:"data"` - } - if err := json.Unmarshal(current, &envelope); err != nil { - return nil, err - } - if envelope.Data == nil { - return nil, fmt.Errorf("auth response is missing client data") - } - fields := make(map[string]json.RawMessage) - for _, key := range []string{"name", "description", "public", "trusted", "redirectUris", "postLogoutRedirectUris", "scopes", "metadata"} { - if value, ok := envelope.Data[key]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - fields[key] = value - } - } - var changes map[string]json.RawMessage - if err := json.Unmarshal(patch, &changes); err != nil { - return nil, err - } - maps.Copy(fields, changes) - return json.Marshal(fields) -} - -func secretList(body json.RawMessage) (json.RawMessage, error) { - var envelope struct { - Data *struct { - Secrets json.RawMessage `json:"secrets"` - } `json:"data"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, err - } - if envelope.Data == nil { - return nil, fmt.Errorf("auth response is missing client data") - } - if len(envelope.Data.Secrets) == 0 || bytes.Equal(envelope.Data.Secrets, []byte("null")) { - return json.RawMessage("[]"), nil - } - return envelope.Data.Secrets, nil -} diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go new file mode 100644 index 00000000..0f36dd90 --- /dev/null +++ b/plugins/auth/plugin.go @@ -0,0 +1,14 @@ +// Package auth embeds the product-owned Auth fctl plugin. +// Service commands, payloads, forms and contract tests live in the independent +// github.com/formancehq/auth/misc/fctl-plugin module. +package auth + +import ( + "net/http" + + authplugin "github.com/formancehq/auth/misc/fctl-plugin" + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +// New adapts the product factory to the host's embedded registration boundary. +func New(client *http.Client) pluginsdk.Plugin { return authplugin.New(client) } From 73a3217d7a18369e35b15d1e5c133c2ea0ead6dc Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:15:01 +0200 Subject: [PATCH 3/6] feat(plugins): load Auth and Ledger through shared service boundaries --- cmd/plugins.go | 39 +++- cmd/plugins_bootstrap.go | 65 ++++-- cmd/plugins_target.go | 64 +++++- cmd/root.go | 13 +- cmd/service_loader_test.go | 298 +++++++++++++++++++++++++ docs/plugins.md | 26 ++- internal/plugin/external.go | 8 +- internal/plugin/service_loader_test.go | 57 +++++ 8 files changed, 519 insertions(+), 51 deletions(-) create mode 100644 cmd/service_loader_test.go create mode 100644 internal/plugin/service_loader_test.go diff --git a/cmd/plugins.go b/cmd/plugins.go index a122de5b..f639a77e 100644 --- a/cmd/plugins.go +++ b/cmd/plugins.go @@ -41,17 +41,24 @@ func writePluginResult(cmd *cobra.Command, result any) error { } func pluginSyncTarget(ctx context.Context, settings *connection.Settings, cmd *cobra.Command, version string) (pluginmanager.Target, string, error) { + return pluginServiceSyncTarget(ctx, settings, cmd, "ledger", version) +} + +func pluginServiceSyncTarget(ctx context.Context, settings *connection.Settings, cmd *cobra.Command, service, version string) (pluginmanager.Target, string, error) { + if _, err := supportedPluginService(service); err != nil { + return pluginmanager.Target{}, "", err + } if version == "" { - client, err := settings.Client(ctx, cmd, "ledger") + client, err := settings.Client(ctx, cmd, service) if err != nil { return pluginmanager.Target{}, "", err } - version, err = ledgerServiceVersion(ctx, client) + version, err = pluginServiceVersion(ctx, client, service) if err != nil { return pluginmanager.Target{}, "", err } } - target, err := ledgerTarget(settings, cmd) + target, err := pluginServiceTarget(settings, cmd, service) if err != nil { return target, "", err } @@ -91,10 +98,30 @@ func summarizePlugin(lock pluginmanager.Lock) pluginSummary { Platform: lock.Platform.String(), Source: source, SHA256: lock.SHA256} } -// Keep plugin identities explicit during the Ledger-only pilot. +// External providers may replace only the root and connection boundary of their service. func ledgerManifestIdentity(manifest pluginsdk.Manifest) error { - if manifest.Name != "ledger" || manifest.Service != "ledger" || pluginsdk.CommandName(manifest.Root) != "ledger" { - return fmt.Errorf("this pilot accepts Ledger plugins with the ledger command root") + return serviceManifestIdentity("ledger", manifest) +} + +func serviceManifestIdentity(service string, manifest pluginsdk.Manifest) error { + descriptor, err := supportedPluginService(service) + if err != nil { + return err + } + if manifest.Name != descriptor.name || manifest.Service != descriptor.name || pluginsdk.CommandName(manifest.Root) != descriptor.name { + return fmt.Errorf("%s plugins must use the %s service and command root", descriptor.title, descriptor.name) + } + return validateServiceOverrides(service, manifest.Root) +} + +func validateServiceOverrides(service string, command pluginsdk.CommandSpec) error { + if command.Service != "" && command.Service != service { + return fmt.Errorf("%s plugin command %q cannot override service with %q", service, command.Use, command.Service) + } + for _, child := range command.Subcommands { + if err := validateServiceOverrides(service, child); err != nil { + return err + } } return nil } diff --git a/cmd/plugins_bootstrap.go b/cmd/plugins_bootstrap.go index d9942d34..035fa638 100644 --- a/cmd/plugins_bootstrap.go +++ b/cmd/plugins_bootstrap.go @@ -19,6 +19,14 @@ import ( ) func prepareLedgerPlugin(ctx context.Context, root *cobra.Command, settings *connection.Settings, args []string) (plugin.Factory, error) { + return prepareServicePlugin(ctx, root, settings, args, "ledger") +} + +func prepareServicePlugin(ctx context.Context, root *cobra.Command, settings *connection.Settings, args []string, service string) (plugin.Factory, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return nil, err + } if args == nil { return nil, nil } @@ -30,15 +38,15 @@ func prepareLedgerPlugin(ctx context.Context, root *cobra.Command, settings *con if err != nil { return nil, err } - prep := ledgerPreparation{root: root, settings: settings, manager: manager} + prep := servicePreparation{root: root, settings: settings, manager: manager, service: descriptor} lock, err := prep.resolve(ctx, plan) if errors.Is(err, pluginmanager.ErrNotInstalled) { return nil, nil } if err != nil { - return nil, fmt.Errorf("prepare Ledger plugin: %w", err) + return nil, fmt.Errorf("prepare %s plugin: %w", descriptor.title, err) } - if err := validateLedgerManifest(ctx, root, lock.Manifest); err != nil { + if err := validateServiceManifest(ctx, root, service, lock.Manifest); err != nil { return nil, err } return plugin.ExternalFactoryWithVerifier(lock.Manifest, func(ctx context.Context) (string, error) { @@ -63,29 +71,40 @@ func parsePluginBootstrap(root *cobra.Command, args []string) (pluginBootstrap, return plan, err } -type ledgerPreparation struct { +// The zero service descriptor retains the Ledger pilot helpers' behavior. +type ledgerPreparation = servicePreparation + +type servicePreparation struct { + service serviceDescriptor root *cobra.Command settings *connection.Settings manager *pluginmanager.Manager } -func (p ledgerPreparation) resolve(ctx context.Context, plan pluginBootstrap) (pluginmanager.Lock, error) { +func (p servicePreparation) descriptor() serviceDescriptor { + if p.service.name == "" { + return serviceDescriptor{name: "ledger", title: "Ledger"} + } + return p.service +} + +func (p servicePreparation) resolve(ctx context.Context, plan pluginBootstrap) (pluginmanager.Lock, error) { lock, loadErr := p.cached() catalogue := os.Getenv("FCTL_PLUGIN_CATALOGUE") if catalogue == "" && loadErr == nil { catalogue = lock.Catalogue } - if catalogue != "" && !plan.help && len(plan.commands) > 0 && plan.commands[0] == "ledger" { + if catalogue != "" && !plan.help && len(plan.commands) > 0 && plan.commands[0] == p.descriptor().name { return p.matchVersion(ctx, catalogue, nil) } if catalogue == "" && errors.Is(loadErr, pluginmanager.ErrNotInstalled) && !plan.help && - len(plan.commands) > 0 && plan.commands[0] == "ledger" { + len(plan.commands) > 0 && plan.commands[0] == p.descriptor().name { return p.discover(ctx) } return lock, loadErr } -func (p ledgerPreparation) discover(ctx context.Context) (pluginmanager.Lock, error) { +func (p servicePreparation) discover(ctx context.Context) (pluginmanager.Lock, error) { catalogue, err := p.manager.Discover(ctx, pluginmanager.DefaultCatalogue) if errors.Is(err, pluginmanager.ErrCatalogueUnavailable) && ctx.Err() == nil { return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled @@ -94,7 +113,7 @@ func (p ledgerPreparation) discover(ctx context.Context) (pluginmanager.Lock, er return pluginmanager.Lock{}, err } for _, release := range catalogue.Releases { - if release.Service == "ledger" && release.Platform == pluginmanager.CurrentPlatform() { + if release.Service == p.descriptor().name && release.Platform == pluginmanager.CurrentPlatform() { return p.matchVersion(ctx, pluginmanager.DefaultCatalogue, &catalogue) } } @@ -102,15 +121,15 @@ func (p ledgerPreparation) discover(ctx context.Context) (pluginmanager.Lock, er return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled } -func (p ledgerPreparation) cached() (pluginmanager.Lock, error) { - return cachedLedgerLock(p.settings, p.root, p.manager) +func (p servicePreparation) cached() (pluginmanager.Lock, error) { + return cachedServiceLock(p.settings, p.root, p.manager, p.descriptor().name) } -func (p ledgerPreparation) matchVersion(ctx context.Context, catalogue string, discovered *pluginmanager.Catalogue) (pluginmanager.Lock, error) { - target, version, err := pluginSyncTarget(ctx, p.settings, p.root, "") +func (p servicePreparation) matchVersion(ctx context.Context, catalogue string, discovered *pluginmanager.Catalogue) (pluginmanager.Lock, error) { + target, version, err := pluginServiceSyncTarget(ctx, p.settings, p.root, p.descriptor().name, "") if err != nil { return pluginmanager.Lock{}, err } - lock, err := p.manager.Load(target, "ledger") + lock, err := p.manager.Load(target, p.descriptor().name) notInstalled := errors.Is(err, pluginmanager.ErrNotInstalled) if err == nil && lock.ServiceVersion == version { return lock, nil @@ -120,9 +139,9 @@ func (p ledgerPreparation) matchVersion(ctx context.Context, catalogue string, d } var release pluginmanager.Release if discovered != nil { - release, err = discovered.Resolve(pluginmanager.CurrentPlatform(), "ledger", version, 0) + release, err = discovered.Resolve(pluginmanager.CurrentPlatform(), p.descriptor().name, version, 0) } else { - release, err = p.manager.Resolve(ctx, catalogue, "ledger", version, 0) + release, err = p.manager.Resolve(ctx, catalogue, p.descriptor().name, version, 0) } if errors.Is(err, pluginmanager.ErrNoRelease) && discovered != nil && notInstalled { return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled @@ -130,26 +149,30 @@ func (p ledgerPreparation) matchVersion(ctx context.Context, catalogue string, d if err != nil { return pluginmanager.Lock{}, err } - if err := validateLedgerManifest(ctx, p.root, release.Manifest); err != nil { + if err := validateServiceManifest(ctx, p.root, p.descriptor().name, release.Manifest); err != nil { return pluginmanager.Lock{}, err } lock, err = p.manager.InstallResolved(ctx, catalogue, target, release) if err != nil { return lock, err } - _, err = fmt.Fprintf(p.root.ErrOrStderr(), "Prepared Ledger plugin %s (revision %d).\n", lock.ServiceVersion, lock.Revision) + _, err = fmt.Fprintf(p.root.ErrOrStderr(), "Prepared %s plugin %s (revision %d).\n", p.descriptor().title, lock.ServiceVersion, lock.Revision) return lock, err } -//nolint:contextcheck // Validation builds callbacks without executing them. func validateLedgerManifest(ctx context.Context, root *cobra.Command, manifest pluginsdk.Manifest) error { - if err := ledgerManifestIdentity(manifest); err != nil { + return validateServiceManifest(ctx, root, "ledger", manifest) +} + +//nolint:contextcheck // Validation builds callbacks without executing them. +func validateServiceManifest(ctx context.Context, root *cobra.Command, service string, manifest pluginsdk.Manifest) error { + if err := serviceManifestIdentity(service, manifest); err != nil { return err } registry := &plugin.Registry{} factory := plugin.ExternalFactory("", manifest) if err := registry.Register(ctx, factory(nil), factory); err != nil { - return fmt.Errorf("invalid Ledger plugin manifest: %w", err) + return fmt.Errorf("invalid %s plugin manifest: %w", service, err) } check := &cobra.Command{Use: "fctl"} check.PersistentFlags().AddFlagSet(root.PersistentFlags()) diff --git a/cmd/plugins_target.go b/cmd/plugins_target.go index eee5f1d7..6e3abc71 100644 --- a/cmd/plugins_target.go +++ b/cmd/plugins_target.go @@ -16,19 +16,46 @@ import ( "github.com/formancehq/fctl/v4/internal/pluginmanager" ) +type serviceDescriptor struct { + name string + title string +} + +func supportedPluginService(service string) (serviceDescriptor, error) { + switch service { + case "auth": + return serviceDescriptor{name: "auth", title: "Auth"}, nil + case "ledger": + return serviceDescriptor{name: "ledger", title: "Ledger"}, nil + default: + return serviceDescriptor{}, fmt.Errorf("unsupported external plugin service %q", service) + } +} + func ledgerTarget(settings *connection.Settings, cmd *cobra.Command) (pluginmanager.Target, error) { + return pluginServiceTarget(settings, cmd, "ledger") +} + +func pluginServiceTarget(settings *connection.Settings, cmd *cobra.Command, service string) (pluginmanager.Target, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return pluginmanager.Target{}, err + } options, _, name, _, err := settings.Resolve(cmd) if err != nil { return pluginmanager.Target{}, err } endpoint := options.LedgerURL + if descriptor.name == "auth" { + endpoint = options.AuthURL + } if options.AuthMode == "cloud" { endpoint = cmp.Or(options.Issuer, cloud.DefaultIssuer) } else if endpoint == "" && options.StackURL != "" { - endpoint = strings.TrimRight(options.StackURL, "/") + "/api/ledger" + endpoint = strings.TrimRight(options.StackURL, "/") + "/api/" + descriptor.name } if endpoint == "" { - return pluginmanager.Target{}, fmt.Errorf("select a profile or configure --ledger-url or --stack-url") + return pluginmanager.Target{}, fmt.Errorf("select a profile or configure --%s-url or --stack-url", descriptor.name) } return pluginmanager.Target{Profile: name, Organization: options.Organization, Stack: options.Stack, Endpoint: endpoint}, nil } @@ -37,11 +64,18 @@ func ledgerTarget(settings *connection.Settings, cmd *cobra.Command) (pluginmana // omit organization or stack. It does not select the execution target: the // authenticated connection still resolves that from verified Cloud claims. func cachedLedgerLock(settings *connection.Settings, cmd *cobra.Command, manager *pluginmanager.Manager) (pluginmanager.Lock, error) { - target, err := ledgerTarget(settings, cmd) + return cachedServiceLock(settings, cmd, manager, "ledger") +} + +func cachedServiceLock(settings *connection.Settings, cmd *cobra.Command, manager *pluginmanager.Manager, service string) (pluginmanager.Lock, error) { + if _, err := supportedPluginService(service); err != nil { + return pluginmanager.Lock{}, err + } + target, err := pluginServiceTarget(settings, cmd, service) if err != nil { return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled } - lock, err := manager.Load(target, "ledger") + lock, err := manager.Load(target, service) if !errors.Is(err, pluginmanager.ErrNotInstalled) || (target.Organization != "" && target.Stack != "") { return lock, err } @@ -49,17 +83,21 @@ func cachedLedgerLock(settings *connection.Settings, cmd *cobra.Command, manager if err != nil || options.AuthMode != "cloud" { return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled } - return soleLedgerLock(manager, target) + return soleServiceLock(manager, target, service) } func soleLedgerLock(manager *pluginmanager.Manager, target pluginmanager.Target) (pluginmanager.Lock, error) { + return soleServiceLock(manager, target, "ledger") +} + +func soleServiceLock(manager *pluginmanager.Manager, target pluginmanager.Target, service string) (pluginmanager.Lock, error) { locks, err := manager.List() if err != nil { return pluginmanager.Lock{}, err } var selected pluginmanager.Lock for _, lock := range locks { - if lock.Service != "ledger" || lock.Target.Profile != target.Profile || lock.Target.Endpoint != target.Endpoint { + if lock.Service != service || lock.Target.Profile != target.Profile || lock.Target.Endpoint != target.Endpoint { continue } if (target.Organization != "" && lock.Target.Organization != target.Organization) || (target.Stack != "" && lock.Target.Stack != target.Stack) { @@ -77,19 +115,27 @@ func soleLedgerLock(manager *pluginmanager.Manager, target pluginmanager.Target) } func ledgerServiceVersion(ctx context.Context, client *api.Client) (string, error) { + return pluginServiceVersion(ctx, client, "ledger") +} + +func pluginServiceVersion(ctx context.Context, client *api.Client, service string) (string, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return "", err + } data, err := client.Do(ctx, "GET", "/_info", nil, nil, nil) if err != nil { - return "", fmt.Errorf("discover Ledger version: %w", err) + return "", fmt.Errorf("discover %s version: %w", descriptor.title, err) } var info struct { Version string `json:"version"` } if err := json.Unmarshal(data, &info); err != nil { - return "", fmt.Errorf("decode Ledger version: %w", err) + return "", fmt.Errorf("decode %s version: %w", descriptor.title, err) } version := strings.TrimPrefix(info.Version, "v") if version == "" { - return "", fmt.Errorf("ledger /_info did not report a version") + return "", fmt.Errorf("%s /_info did not report a version", descriptor.name) } return version, nil } diff --git a/cmd/root.go b/cmd/root.go index 9c934740..d05f000b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,10 +49,12 @@ func newRootCommand(ctx context.Context, args []string) *cobra.Command { settings := &connection.Settings{} settings.Bind(root) root.PersistentFlags().Bool("no-input", false, "Disable interactive forms and selections (FCTL_NO_INPUT, CI)") - ledgerFactory, preparationErr := prepareLedgerPlugin(ctx, root, settings, args) + authFactory, authPreparationErr := prepareServicePlugin(ctx, root, settings, args, "auth") + ledgerFactory, ledgerPreparationErr := prepareServicePlugin(ctx, root, settings, args, "ledger") + preparationErrors := map[string]error{"auth": authPreparationErr, "ledger": ledgerPreparationErr} root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { - if preparationErr != nil && serviceCommand(cmd) == "ledger" { - return preparationErr + if err := preparationErrors[serviceCommand(cmd)]; err != nil { + return err } return command.ConfigureOutput(cmd, settings.Output, settings.Color) } @@ -62,10 +64,13 @@ func newRootCommand(ctx context.Context, args []string) *cobra.Command { root.AddCommand(version.NewCommand(), profiles.NewCommand(settings), login.NewCommand(settings), login.NewLogoutCommand(settings)) root.AddCommand(newPluginsCommand(settings)) registry := &plugin.Registry{} + if authFactory == nil { + authFactory = auth.New + } if ledgerFactory == nil { ledgerFactory = ledger.New } - for _, factory := range []plugin.Factory{cloudplugin.New, auth.New, ledgerFactory, connectivity.New} { + for _, factory := range []plugin.Factory{cloudplugin.New, authFactory, ledgerFactory, connectivity.New} { if err := registry.Register(context.WithoutCancel(ctx), factory(nil), factory); err != nil { panic(err) // Embedded metadata is a build-time invariant, never user input. } diff --git a/cmd/service_loader_test.go b/cmd/service_loader_test.go new file mode 100644 index 00000000..4c000d1a --- /dev/null +++ b/cmd/service_loader_test.go @@ -0,0 +1,298 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func loaderManifest(service string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: service, Service: service, Version: "1.0.0", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: service, Short: "Cached external " + service, Subcommands: []pluginsdk.CommandSpec{{Use: "probe", Short: "External probe", Runnable: true}}}} +} + +func loaderSettings(t *testing.T) (*cobra.Command, *connection.Settings) { + t.Helper() + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + loaderFlag(t, root, "auth-mode", "none") + return root, settings +} + +func loaderFlag(t *testing.T, root *cobra.Command, name, value string) { + t.Helper() + if err := root.PersistentFlags().Set(name, value); err != nil { + t.Fatal(err) + } +} + +func TestServiceLoaderTargets(t *testing.T) { + for _, service := range []string{"auth", "ledger"} { + t.Run(service, func(t *testing.T) { testServiceLoaderTarget(t, service) }) + } + root, settings := loaderSettings(t) + if _, err := pluginServiceTarget(settings, root, "unknown"); err == nil { + t.Fatal("unknown service accepted") + } + if _, err := cachedServiceLock(settings, root, nil, "unknown"); err == nil { + t.Fatal("unknown cached service accepted") + } + if _, _, err := pluginServiceSyncTarget(t.Context(), settings, root, "unknown", "1.0.0"); err == nil { + t.Fatal("unknown sync service accepted") + } + if _, err := prepareServicePlugin(t.Context(), root, settings, nil, "unknown"); err == nil { + t.Fatal("unknown provider accepted") + } +} + +func testServiceLoaderTarget(t *testing.T, service string) { + t.Helper() + root, settings := loaderSettings(t) + loaderFlag(t, root, "stack-url", "https://stack.example/") + target, err := pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != "https://stack.example/api/"+service { + t.Fatalf("stack target: %+v %v", target, err) + } + loaderFlag(t, root, "auth-url", "https://auth.example") + loaderFlag(t, root, "ledger-url", "https://ledger.example") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != "https://"+service+".example" { + t.Fatalf("direct target: %+v %v", target, err) + } + loaderFlag(t, root, "auth-mode", "cloud") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != cloud.DefaultIssuer { + t.Fatalf("cloud default: %+v %v", target, err) + } + loaderFlag(t, root, "issuer", "https://issuer.example") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != settings.Options.Issuer { + t.Fatalf("cloud identity: %+v %v", target, err) + } +} + +func TestServiceLoaderManifestBoundary(t *testing.T) { + root, _ := loaderSettings(t) + for _, service := range []string{"auth", "ledger"} { + manifest := loaderManifest(service) + if err := validateServiceManifest(t.Context(), root, service, manifest); err != nil { + t.Fatal(err) + } + for _, change := range []func(*pluginsdk.Manifest){ + func(m *pluginsdk.Manifest) { m.Name = "cloud" }, + func(m *pluginsdk.Manifest) { m.Service = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Use = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Service = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Service = "cloud-apps" }, + } { + invalid := loaderManifest(service) + change(&invalid) + if err := validateServiceManifest(t.Context(), root, service, invalid); err == nil { + t.Fatalf("accepted foreign identity/boundary: %+v", invalid) + } + } + } + if err := validateServiceManifest(t.Context(), root, "cloud", loaderManifest("cloud")); err == nil { + t.Fatal("unsupported manifest accepted") + } +} + +func installLoaderFixture(t *testing.T, manager *pluginmanager.Manager, target pluginmanager.Target, service string) pluginmanager.Lock { + t.Helper() + path := filepath.Join(t.TempDir(), "not-an-executable") + if err := os.WriteFile(path, []byte("cached help must never start this file"), 0o600); err != nil { + t.Fatal(err) + } + lock, err := manager.InstallLocal(t.Context(), path, target, loaderManifest(service)) + if err != nil { + t.Fatal(err) + } + return lock +} + +//nolint:gocognit // One offline lifecycle verifies two cached providers and ambiguity isolation. +func TestServiceLoaderOfflineRootsAndCacheIsolation(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + root, settings := loaderSettings(t) + loaderFlag(t, root, "auth-mode", "cloud") + loaderFlag(t, root, "issuer", "https://issuer.example") + manager, err := pluginManager(settings, root) + if err != nil { + t.Fatal(err) + } + target := pluginmanager.Target{Profile: "", Organization: "org", Stack: "stack", Endpoint: settings.Options.Issuer} + for _, service := range []string{"auth", "ledger"} { + installLoaderFixture(t, manager, target, service) + } + for _, service := range []string{"auth", "ledger"} { + lock, err := cachedServiceLock(settings, root, manager, service) + if err != nil || lock.Service != service { + t.Fatalf("cached %s: %+v %v", service, lock, err) + } + for _, suffix := range [][]string{{service, "--help"}, {"__complete", service, ""}} { + args := append([]string{"--config-dir", settings.Directory, "--auth-mode", "cloud", "--issuer", settings.Options.Issuer}, suffix...) + command := NewRootCommandWithArgs(t.Context(), args) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + if err := command.ExecuteContext(t.Context()); err != nil { + t.Fatalf("offline %v: %v", suffix, err) + } + if !strings.Contains(output.String(), "probe") { + t.Fatalf("cached metadata not registered: %s", output.String()) + } + count := 0 + for _, child := range command.Commands() { + if child.Name() == service { + count++ + } + } + if count != 1 { + t.Fatalf("%s roots: %d", service, count) + } + } + } + installLoaderFixture(t, manager, pluginmanager.Target{Profile: "", Organization: "org", Stack: "other", Endpoint: settings.Options.Issuer}, "auth") + if _, err := cachedServiceLock(settings, root, manager, "auth"); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("ambiguous Auth metadata: %v", err) + } + if lock, err := cachedServiceLock(settings, root, manager, "ledger"); err != nil || lock.Service != "ledger" { + t.Fatalf("Auth ambiguity affected Ledger: %+v %v", lock, err) + } +} + +func TestAuthLoaderOfficialFallbackAndOffline(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + for _, test := range []struct { + name, data string + status int + embedded bool + }{ + {"empty", "schemaVersion: 1\nreleases: []\n", http.StatusOK, true}, + {"unavailable", "", http.StatusServiceUnavailable, true}, + {"invalid", "schemaVersion: 1\nreleases: invalid\n", http.StatusOK, false}, + } { + t.Run(test.name, func(t *testing.T) { + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != pluginmanager.DefaultCatalogue { + t.Fatalf("unexpected service request: %s", request.URL) + } + return &http.Response{StatusCode: test.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(test.data))}, nil + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"auth", "probe"}}) + if err == nil || errors.Is(err, pluginmanager.ErrNotInstalled) != test.embedded { + t.Fatalf("Auth fallback: %v", err) + } + }) + } + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("offline Auth accessed network: %s", request.URL) + return nil, context.Canceled + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + for _, plan := range []pluginBootstrap{{commands: []string{"auth"}, help: true}, {commands: []string{"__complete", "auth"}}, {commands: []string{"ledger", "list"}}, {}} { + if _, err := prep.resolve(t.Context(), plan); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("offline Auth: %v", err) + } + } +} + +func TestAuthLoaderExactVersionAndStrictTargets(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + base := http.DefaultTransport + http.DefaultTransport = discoveryTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != "http://127.0.0.1:1/_info" { + t.Fatalf("wrong Auth version endpoint: %s", request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.0.1"}`))}, nil + }) + t.Cleanup(func() { http.DefaultTransport = base }) + prep := discoveryPreparation(t, discoveryTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-mode", "none") + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + target, version, err := pluginServiceSyncTarget(t.Context(), prep.settings, prep.root, "auth", "") + if err != nil || version != "1.0.1" { + t.Fatalf("exact Auth discovery: %+v %s %v", target, version, err) + } + empty := &pluginmanager.Catalogue{SchemaVersion: pluginmanager.SchemaVersion} + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, empty); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("official unprepared missing release: %v", err) + } + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://custom.example/catalogue") + if _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"auth", "probe"}}); !errors.Is(err, pluginmanager.ErrCatalogueUnavailable) { + t.Fatalf("custom unavailable target fell back: %v", err) + } + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + installLoaderFixture(t, prep.manager, target, "auth") + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, empty); !errors.Is(err, pluginmanager.ErrNoRelease) { + t.Fatalf("prepared target accepted missing exact release: %v", err) + } + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, nil); !errors.Is(err, pluginmanager.ErrCatalogueUnavailable) { + t.Fatalf("prepared unavailable target fell back: %v", err) + } +} + +func TestLedgerLoaderCompatibility(t *testing.T) { + root, settings := loaderSettings(t) + loaderFlag(t, root, "ledger-url", "https://ledger.example") + target, err := ledgerTarget(settings, root) + if err != nil { + t.Fatal(err) + } + manager, err := pluginManager(settings, root) + if err != nil { + t.Fatal(err) + } + installLoaderFixture(t, manager, target, "ledger") + if _, err := cachedLedgerLock(settings, root, manager); err != nil { + t.Fatal(err) + } + if _, err := soleLedgerLock(manager, target); err != nil { + t.Fatal(err) + } + if _, version, err := pluginSyncTarget(t.Context(), settings, root, "v1.0.0"); err != nil || version != "1.0.0" { + t.Fatalf("legacy sync: %s %v", version, err) + } + if err := ledgerManifestIdentity(loaderManifest("ledger")); err != nil { + t.Fatal(err) + } + if err := validateLedgerManifest(t.Context(), root, loaderManifest("ledger")); err != nil { + t.Fatal(err) + } + if factory, err := prepareLedgerPlugin(t.Context(), root, settings, nil); err != nil || factory != nil { + t.Fatalf("embedded-only legacy root: %v", err) + } + client, err := api.New("https://ledger.example", &http.Client{Transport: discoveryTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.0.0"}`))}, nil + })}) + if err != nil { + t.Fatal(err) + } + if version, err := ledgerServiceVersion(t.Context(), client); err != nil || version != "1.0.0" { + t.Fatalf("legacy version: %s %v", version, err) + } +} diff --git a/docs/plugins.md b/docs/plugins.md index 50570e09..2b2e1445 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -78,11 +78,10 @@ and `--out` effects; they are not connection configuration. The shared public HTTP client performs requests without automatic mutation retries and preserves JSON numbers. -Cloud, Auth, Ledger and Connectivity remain available as embedded defaults. The -Ledger pilot can replace its embedded command tree with a cached external -manifest. Both providers use the same two-method SDK contract, forms, body -validation, authentication and output renderer. Exactly one provider owns the -Ledger command root. +Cloud, Auth, Ledger and Connectivity remain available as embedded defaults. Auth and Ledger providers can replace their embedded command trees with cached +external manifests. Both providers use the same two-method SDK contract, forms, +body validation, authentication and output renderer. Exactly one provider owns +each service command root. External executables use the SDK's `transport.Serve` and the host's `transport.Open`. The gRPC broker supplies a scoped HTTP callback; the host @@ -93,10 +92,23 @@ arguments. A separate process is not an operating-system sandbox: only trusted executables should be installed. The host checks the binary checksum and compares its runtime manifest with the -installed manifest before execution. It also checks the exact Ledger service +installed manifest before execution. It also checks the exact selected service version before service operations. Canceling an RPC closes the plugin process. Help and completion use cached metadata, without spawning a plugin or calling the service. -See [plugin distribution](plugin-distribution.md) for the Ledger pilot, exact +See [plugin distribution](plugin-distribution.md) for Auth and Ledger, exact version selection, public OCI packages, target locks and local testing. + +## Product-owned Auth plugin + +The independent module `github.com/formancehq/auth/misc/fctl-plugin` owns Auth +commands, API validation and declarative forms in the Auth repository. fctl's +`plugins/auth` package only registers its factory. The product module exposes +`New` for embedding and `NewVersion` for exact-version native executables. It +depends on the public plugin SDK and generated Auth client, without fctl core, +Cobra, profile storage or terminal libraries. + +Auth and Ledger external providers use the same generic host loader. The host +accepts only the selected service's command root and connection boundary, +including descendant service overrides. Locks are keyed by target and service. diff --git a/internal/plugin/external.go b/internal/plugin/external.go index 79c2dfb0..625e249a 100644 --- a/internal/plugin/external.go +++ b/internal/plugin/external.go @@ -73,7 +73,7 @@ func (p *external) Execute(ctx context.Context, request pluginsdk.ExecuteRequest func (p *external) checkVersion(ctx context.Context, endpoint string) error { p.mu.Lock() defer p.mu.Unlock() - if p.checked == endpoint { + if p.checked != "" && p.checked == endpoint { return nil } client, err := httpclient.New(endpoint, p.http) @@ -82,17 +82,17 @@ func (p *external) checkVersion(ctx context.Context, endpoint string) error { } data, err := client.Do(ctx, http.MethodGet, "/_info", nil, nil, nil) if err != nil { - return fmt.Errorf("check Ledger plugin version: %w", err) + return fmt.Errorf("check %s plugin version: %w", p.manifest.Service, err) } var info struct { Version string `json:"version"` } if err := json.Unmarshal(data, &info); err != nil { - return fmt.Errorf("decode Ledger version: %w", err) + return fmt.Errorf("decode %s version: %w", p.manifest.Service, err) } version := strings.TrimPrefix(info.Version, "v") if version != p.manifest.Version { - return fmt.Errorf("installed plugin targets Ledger %s, service reports %s; run fctl plugins sync or install the matching binary", p.manifest.Version, version) + return fmt.Errorf("installed plugin targets %s %s, service reports %s; run fctl plugins sync or install the matching binary", p.manifest.Service, p.manifest.Version, version) } p.checked = endpoint return nil diff --git a/internal/plugin/service_loader_test.go b/internal/plugin/service_loader_test.go new file mode 100644 index 00000000..a31edda5 --- /dev/null +++ b/internal/plugin/service_loader_test.go @@ -0,0 +1,57 @@ +package plugin + +import ( + "context" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type loaderTransport func(*http.Request) (*http.Response, error) + +func (f loaderTransport) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +//nolint:gocognit // The same version-cache and exact-mismatch lifecycle applies to both services. +func TestExternalServiceVersionBeforeProcess(t *testing.T) { + for _, service := range []string{"auth", "ledger"} { + t.Run(service, func(t *testing.T) { + var requests atomic.Int32 + p := &external{manifest: pluginsdk.Manifest{Service: service, Version: "1.2.3"}, http: &http.Client{Transport: loaderTransport(func(request *http.Request) (*http.Response, error) { + requests.Add(1) + if request.Method != http.MethodGet || request.URL.Path != "/api/"+service+"/_info" { + t.Fatalf("version request: %s %s", request.Method, request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.2.3"}`))}, nil + })}} + endpoint := "https://stack.example/api/" + service + for range 2 { + if err := p.checkVersion(t.Context(), endpoint); err != nil { + t.Fatal(err) + } + } + if requests.Load() != 1 { + t.Fatalf("version checks: %d", requests.Load()) + } + p.manifest.Version = "1.2.4" + p.checked = "" + err := p.checkVersion(t.Context(), endpoint) + if err == nil || !strings.Contains(err.Error(), service+" 1.2.4") { + t.Fatalf("service mismatch: %v", err) + } + }) + } +} + +func TestExternalAuthVersionFailureDoesNotStartExecutable(t *testing.T) { + p := &external{verify: func(_ context.Context) (string, error) { return "/does/not/exist", nil }, manifest: pluginsdk.Manifest{Service: "auth", Version: "1.2.3"}, http: &http.Client{Transport: loaderTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"1.2.4"}`))}, nil + })}} + _, err := p.Execute(t.Context(), pluginsdk.ExecuteRequest{Endpoint: "https://auth.example"}) + if err == nil || !strings.Contains(err.Error(), "targets auth 1.2.3") { + t.Fatalf("version check did not precede startup: %v", err) + } +} From 00b922141c7eafe0861aef42d2faedaf9df25c48 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:15:01 +0200 Subject: [PATCH 4/6] feat(plugins): distribute exact-version Auth executables from the catalogue --- cmd/auth_distribution_commands_test.go | 42 +++ cmd/auth_distribution_test.go | 328 ++++++++++++++++++ cmd/auth_product_test.go | 117 +++++++ cmd/external_distribution_test.go | 2 +- cmd/plugins_install.go | 24 +- cmd/plugins_show.go | 9 +- cmd/plugins_sync.go | 35 +- docs/plugin-distribution.md | 77 +++- internal/pluginmanager/auth_catalogue_test.go | 84 +++++ internal/pluginmanager/catalogue.go | 3 + 10 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 cmd/auth_distribution_commands_test.go create mode 100644 cmd/auth_distribution_test.go create mode 100644 cmd/auth_product_test.go create mode 100644 internal/pluginmanager/auth_catalogue_test.go diff --git a/cmd/auth_distribution_commands_test.go b/cmd/auth_distribution_commands_test.go new file mode 100644 index 00000000..16b9c02d --- /dev/null +++ b/cmd/auth_distribution_commands_test.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" +) + +func TestAuthDistributionCommandsPreserveLedgerDefaultsAndWrappers(t *testing.T) { + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + if err := root.PersistentFlags().Set("auth-mode", "none"); err != nil { + t.Fatal(err) + } + if err := root.PersistentFlags().Set("ledger-url", "http://127.0.0.1:1"); err != nil { + t.Fatal(err) + } + for _, command := range []*cobra.Command{newPluginsInstallCommand(settings), newPluginsSyncCommand(settings), newPluginsShowCommand(settings)} { + flag := command.Flags().Lookup("service") + if flag == nil || flag.DefValue != "ledger" { + t.Fatalf("%s changed Ledger default: %+v", command.Name(), flag) + } + } + root.SetContext(t.Context()) + if err := installLedgerPlugin(root, settings, "", ""); err == nil || !strings.Contains(err.Error(), "ledger plugin executable") { + t.Fatalf("Ledger install wrapper: %v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + root.SetContext(ctx) + if err := syncLedgerPlugin(root, settings, filepath.Join(t.TempDir(), "catalogue.json"), "3.0.0", 0); !errors.Is(err, context.Canceled) { + t.Fatalf("Ledger sync wrapper: %v", err) + } +} diff --git a/cmd/auth_distribution_test.go b/cmd/auth_distribution_test.go new file mode 100644 index 00000000..f06e94a1 --- /dev/null +++ b/cmd/auth_distribution_test.go @@ -0,0 +1,328 @@ +package cmd_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// This deliberately small executable tests the public transport and distribution +// lifecycle independently of private product sources. Product integration tests +// can additionally build github.com/formancehq/auth/misc/fctl-plugin. +func buildDistributionAuth(t *testing.T, version string) string { + t.Helper() + source := fmt.Sprintf(`package main +import ( + "context" + "net/http" + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) +type plugin struct { client *http.Client } +func (p *plugin) GetManifest(context.Context) (pluginsdk.Manifest,error) { + return pluginsdk.Manifest{Name:"auth", Service:"auth", Version:%q, ProtocolVersion:pluginsdk.ProtocolVersion, + Root:pluginsdk.CommandSpec{Use:"auth",Target:"stack",Short:"Distributed Auth fixture",Subcommands:[]pluginsdk.CommandSpec{ + {Use:"clients",Subcommands:[]pluginsdk.CommandSpec{{Use:"list",Runnable:true},{Use:"create",Runnable:true}}}, + }}},nil +} +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse,error) { + client,err:=httpclient.New(req.Endpoint,p.client) + if err!=nil { return pluginsdk.ExecuteResponse{},err } + method:=http.MethodGet + if req.CommandPath[len(req.CommandPath)-1]=="create" { method=http.MethodPost } + data,err:=client.Do(ctx,method,"/clients",nil,nil,nil) + return pluginsdk.ExecuteResponse{Data:data},err +} +func main() { transport.Serve(func(client *http.Client) pluginsdk.Plugin { return &plugin{client:client} }) } +`, version) + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "fctl-plugin-auth") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, path) //nolint:gosec // Fixed Go build command and test-owned source and output paths. + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Auth fixture: %v: %s", err, output) + } + return binary +} + +func authDistributionAPI(t *testing.T, version *atomic.Value, mutations *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/gateway/auth/_info": + externalInfoResponse(t, w, version) + case "/gateway/auth/clients": + if r.Method == http.MethodPost { + mutations.Add(1) + } + externalFixtureResponse(t, w, `{"data":[{"name":"distributed-client"}]}`) + default: + t.Errorf("unexpected Auth request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} +func authDistributionArgs(dir, endpoint string) []string { + return []string{"--config-dir", dir, "--auth-mode", "none", "--auth-url", endpoint + "/gateway/auth", "--no-input", "-o", "json"} +} +func runAuthDistribution(t *testing.T, args []string, tail ...string) string { + t.Helper() + out, trace, err := executeExternalCLI(t, append(append([]string{}, args...), tail...)) + if err != nil { + t.Fatalf("%v: stdout=%s stderr=%s err=%v", tail, out, trace, err) + } + return out +} +func assertAuthOffline(t *testing.T, args []string) { + t.Helper() + out := runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"service": "auth"`) { + t.Fatalf("Auth lock: %s", out) + } + out = runAuthDistribution(t, args, "auth", "--help") + if !strings.Contains(out, "Distributed Auth fixture") { + t.Fatalf("offline manifest: %s", out) + } + out = runAuthDistribution(t, args, "__complete", "auth", "") + if !strings.Contains(out, "clients") { + t.Fatalf("offline completion: %s", out) + } +} +func assertAuthDrift(t *testing.T, args []string, mutations *atomic.Int32) { + t.Helper() + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "auth", "clients", "create")) + if err == nil || out != "" || mutations.Load() != 1 { + t.Fatalf("version drift reached mutation: %s %v writes=%d", out, err, mutations.Load()) + } +} +func TestAuthLocalDistributionCLI(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + binary := buildDistributionAuth(t, "1.0.0") + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + out := runAuthDistribution(t, args, "plugins", "install", "--service", "auth", "--binary", binary) + if !strings.Contains(out, `"source": "local"`) { + t.Fatalf("local lock: %s", out) + } + out = runAuthDistribution(t, args, "auth", "clients", "create") + if !strings.Contains(out, "distributed-client") || mutations.Load() != 1 { + t.Fatalf("Auth mutation: %s writes=%d", out, mutations.Load()) + } + version.Store("1.0.1") + assertAuthDrift(t, args, &mutations) + server.Close() + // Offline metadata must survive a missing cached executable: no subprocess + // can provide the help or completion manifest after removing these bytes. + manager, err := pluginmanager.New(filepath.Join(args[1], "plugins"), nil) + if err != nil { + t.Fatal(err) + } + locks, err := manager.List() + if err != nil || len(locks) != 1 { + t.Fatalf("local Auth lock: %+v %v", locks, err) + } + cached, err := manager.Binary(locks[0]) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(cached); err != nil { + t.Fatal(err) + } + assertAuthOffline(t, args) +} + +// Real loopback OCI upload/download requests, with checksum validation on upload. +func authOCIRegistry(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + var mu sync.Mutex + objects := map[string][]byte{} + reads := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + const base = "/v2/formance/auth/" + if !strings.HasPrefix(r.URL.Path, base) { + w.WriteHeader(http.StatusNotFound) + return + } + switch r.Method { + case http.MethodPost: + w.Header().Set("Location", base+"blobs/uploads/fixture") + w.WriteHeader(http.StatusAccepted) + case http.MethodPut: + storeAuthOCIUpload(t, w, r, objects) + case http.MethodGet: + reads.Add(1) + data, ok := objects[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + externalFixtureResponse(t, w, string(data)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + return server, reads +} +func publishDistributionAuth(t *testing.T, manager *pluginmanager.Manager, registry, binary, version string, revision int) pluginmanager.Release { + t.Helper() + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect Auth: %v %v", err, closeErr) + } + release, err := manager.Publish(t.Context(), registry+"/formance/auth", pluginmanager.Release{Service: "auth", ServiceVersion: version, Revision: revision, Platform: pluginmanager.CurrentPlatform(), Manifest: manifest}, binary) + if err != nil { + t.Fatal(err) + } + return release +} +func writeAuthCatalogue(t *testing.T, path string, releases ...pluginmanager.Release) { + t.Helper() + data, err := json.Marshal(pluginmanager.Catalogue{SchemaVersion: pluginmanager.SchemaVersion, Releases: releases}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAuthOCIDistributionCLI(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + registry, reads := authOCIRegistry(t) + manager, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + binary := buildDistributionAuth(t, "1.0.0") + v1 := publishDistributionAuth(t, manager, registry.URL, binary, "1.0.0", 1) + next := buildDistributionAuth(t, "1.0.1") + v2 := publishDistributionAuth(t, manager, registry.URL, next, "1.0.1", 1) + catalogue := filepath.Join(t.TempDir(), "catalogue.json") + writeAuthCatalogue(t, catalogue, v1, v2) + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + out := runAuthDistribution(t, args, "plugins", "sync", "--service", "auth", "--catalogue", catalogue) + if !strings.Contains(out, `"service": "auth"`) || reads.Load() != 3 { + t.Fatalf("OCI lock/download: %s reads=%d", out, reads.Load()) + } + runAuthDistribution(t, args, "auth", "clients", "create") + v1.Revision = 2 + writeAuthCatalogue(t, catalogue, v1, v2) + runAuthDistribution(t, args, "auth", "clients", "list") + out = runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"revision": 1`) || reads.Load() != 3 { + t.Fatalf("revision retained: %s reads=%d", out, reads.Load()) + } + // Reuse the Auth lock's catalogue, rather than the Ledger/default source. + out = runAuthDistribution(t, args, "plugins", "sync", "--service", "auth") + if !strings.Contains(out, `"revision": 2`) { + t.Fatalf("revision upgrade: %s", out) + } + version.Store("1.0.1") + runAuthDistribution(t, args, "auth", "clients", "list") + if reads.Load() != 6 { + t.Fatalf("exact new version not downloaded: %d", reads.Load()) + } + version.Store("1.0.2") + assertAuthDrift(t, args, &mutations) + registry.Close() + version.Store("1.0.1") + runAuthDistribution(t, args, "auth", "clients", "list") + server.Close() + assertAuthOffline(t, args) +} + +func TestAuthDistributionRejectsUnknownAndMismatchedServices(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + dir := t.TempDir() + args := authDistributionArgs(dir, "http://127.0.0.1:1") + for _, command := range []string{"install", "sync", "show"} { + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "plugins", command, "--service", "wallets")) + if err == nil || !strings.Contains(err.Error(), "unsupported external plugin service") || out != "" { + t.Fatalf("%s accepted unknown service: %s %v", command, out, err) + } + } + binary := buildDistributionAuth(t, "1.0.0") + for _, tail := range [][]string{ + {"plugins", "install", "--binary", binary, "--service-version", "1.0.0"}, + {"plugins", "install", "--service", "auth", "--binary", binary, "--service-version", "1.0.1"}, + } { + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), tail...)) + if err == nil || out != "" { + t.Fatalf("mismatch accepted: %s %v", out, err) + } + } + manager, err := pluginmanager.New(filepath.Join(dir, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + locks, err := manager.List() + if err != nil || len(locks) != 0 { + t.Fatalf("rejected installation wrote locks: %+v %v", locks, err) + } +} + +func storeAuthOCIUpload(t *testing.T, w http.ResponseWriter, r *http.Request, objects map[string][]byte) { + t.Helper() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + path := r.URL.Path + digest := filepath.Base(path) + if strings.Contains(path, "/blobs/uploads/") { + digest = r.URL.Query().Get("digest") + path = "/v2/formance/auth/blobs/" + digest + } + checksum := sha256.Sum256(data) + if digest != "sha256:"+hex.EncodeToString(checksum[:]) { + t.Error("OCI upload checksum mismatch") + w.WriteHeader(http.StatusBadRequest) + return + } + objects[path] = data + w.Header().Set("Docker-Content-Digest", digest) + w.WriteHeader(http.StatusCreated) +} diff --git a/cmd/auth_product_test.go b/cmd/auth_product_test.go new file mode 100644 index 00000000..2d762d56 --- /dev/null +++ b/cmd/auth_product_test.go @@ -0,0 +1,117 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func buildAuthProduct(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), "fctl-plugin-auth") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.CommandContext(t.Context(), "go", "build", "-ldflags", "-X main.serviceVersion=1.0.0 -X main.revision=1", "-o", binary, "github.com/formancehq/auth/misc/fctl-plugin/cmd/fctl-plugin-auth") //nolint:gosec // Fixed build command and test-owned executable destination. + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Auth product: %v: %s", err, output) + } + return binary +} + +func authProductAPI(t *testing.T, version *atomic.Value, mutations *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/gateway/auth/_info": + externalInfoResponse(t, w, version) + case "/gateway/auth/clients": + if r.Method == http.MethodPost { + mutations.Add(1) + w.WriteHeader(http.StatusCreated) + externalFixtureResponse(t, w, `{"data":{"id":"product-id","name":"product-client"}}`) + } else { + externalFixtureResponse(t, w, `{"data":[{"id":"product-id","name":"distributed-client"}]}`) + } + default: + t.Errorf("unexpected Auth product request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func installAuthProduct(t *testing.T, args []string, source, binary string) { + t.Helper() + if source == "local" { + runAuthDistribution(t, args, "plugins", "install", "--service", "auth", "--binary", binary) + return + } + registry, _ := authOCIRegistry(t) + manager, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + release := publishDistributionAuth(t, manager, registry.URL, binary, "1.0.0", 1) + catalogue := filepath.Join(t.TempDir(), "catalogue.json") + writeAuthCatalogue(t, catalogue, release) + runAuthDistribution(t, args, "plugins", "sync", "--service", "auth", "--catalogue", catalogue) +} + +func assertAuthProductExecution(t *testing.T, args []string, version *atomic.Value, mutations *atomic.Int32) { + t.Helper() + out := runAuthDistribution(t, args, "auth", "clients", "list") + if !json.Valid([]byte(out)) || !strings.Contains(out, "distributed-client") { + t.Fatalf("product response: %s", out) + } + out = runAuthDistribution(t, args, "auth", "clients", "create", "--data", `{"name":"product-client"}`) + if !json.Valid([]byte(out)) || mutations.Load() != 1 { + t.Fatalf("product mutation: %s writes=%d", out, mutations.Load()) + } + version.Store("1.0.1") + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "auth", "clients", "create", "--data", `{"name":"must-not-write"}`)) + if err == nil || out != "" || mutations.Load() != 1 { + t.Fatalf("product drift: %s %v writes=%d", out, err, mutations.Load()) + } +} + +func assertAuthProductOffline(t *testing.T, args []string) { + t.Helper() + out := runAuthDistribution(t, args, "auth", "--help") + if !strings.Contains(out, "Manage Auth clients") { + t.Fatalf("product offline help: %s", out) + } + out = runAuthDistribution(t, args, "__complete", "auth", "") + if !strings.Contains(out, "clients") { + t.Fatalf("product offline completion: %s", out) + } +} + +// Exercise the product-owned executable through both supported sources. +func TestAuthProductExecutableDistribution(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + binary := buildAuthProduct(t) + for _, source := range []string{"local", "oci"} { + t.Run(source, func(t *testing.T) { + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authProductAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + installAuthProduct(t, args, source, binary) + assertAuthProductExecution(t, args, &version, &mutations) + server.Close() + assertAuthProductOffline(t, args) + }) + } +} diff --git a/cmd/external_distribution_test.go b/cmd/external_distribution_test.go index 606b6c3b..80e9b077 100644 --- a/cmd/external_distribution_test.go +++ b/cmd/external_distribution_test.go @@ -36,7 +36,7 @@ func (f *officialCatalogueTransport) RoundTrip(request *http.Request) (*http.Res if request.Header.Get("Authorization") != "" { f.t.Error("public catalogue received service credentials") } - data, err := os.ReadFile(f.path) + data, err := os.ReadFile(f.path) //nolint:gosec // The fixture reads its test-owned catalogue file, never a request path. if err != nil { return nil, err } diff --git a/cmd/plugins_install.go b/cmd/plugins_install.go index d86955f0..8d20c3da 100644 --- a/cmd/plugins_install.go +++ b/cmd/plugins_install.go @@ -15,24 +15,32 @@ import ( ) func newPluginsInstallCommand(settings *connection.Settings) *cobra.Command { - var binary, localVersion string + var binary, localVersion, service string install := &cobra.Command{ - Use: "install", Short: "Install a Ledger plugin executable built locally", Args: cobra.NoArgs, + Use: "install", Short: "Install a service plugin executable built locally", Args: cobra.NoArgs, Annotations: map[string]string{"fctl.target": "stack"}, Example: " fctl plugins install --binary ./build/fctl-plugin-ledger --profile local", } + install.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") install.Flags().StringVar(&binary, "binary", "", "Explicit path to the trusted executable") - install.Flags().StringVar(&localVersion, "service-version", "", "Exact service version; defaults to Ledger /_info discovery") + install.Flags().StringVar(&localVersion, "service-version", "", "Exact service version; defaults to selected service /_info discovery") install.RunE = func(cmd *cobra.Command, _ []string) error { - return installLedgerPlugin(cmd, settings, binary, localVersion) + return installServicePlugin(cmd, settings, service, binary, localVersion) } return install } func installLedgerPlugin(cmd *cobra.Command, settings *connection.Settings, binary, localVersion string) error { + return installServicePlugin(cmd, settings, "ledger", binary, localVersion) +} + +func installServicePlugin(cmd *cobra.Command, settings *connection.Settings, service, binary, localVersion string) error { + if err := distributionService(service); err != nil { + return err + } if binary == "" { - return fmt.Errorf("provide --binary PATH to the Ledger plugin executable") + return fmt.Errorf("provide --binary PATH to the %s plugin executable", service) } path, err := filepath.Abs(binary) if err != nil { @@ -43,15 +51,15 @@ func installLedgerPlugin(cmd *cobra.Command, settings *connection.Settings, bina return err } - if err := validateLedgerManifest(cmd.Context(), cmd.Root(), manifest); err != nil { + if err := validateServiceManifest(cmd.Context(), cmd.Root(), service, manifest); err != nil { return err } - target, version, err := pluginSyncTarget(cmd.Context(), settings, cmd, localVersion) + target, version, err := pluginServiceSyncTarget(cmd.Context(), settings, cmd, service, localVersion) if err != nil { return err } if manifest.Version != version { - return fmt.Errorf("plugin targets Ledger %s, selected service is %s; build the matching plugin", manifest.Version, version) + return fmt.Errorf("plugin targets %s %s, selected service is %s; build the matching plugin", service, manifest.Version, version) } manager, err := pluginManager(settings, cmd) if err != nil { diff --git a/cmd/plugins_show.go b/cmd/plugins_show.go index 081a6392..196d4ba2 100644 --- a/cmd/plugins_show.go +++ b/cmd/plugins_show.go @@ -7,13 +7,18 @@ import ( ) func newPluginsShowCommand(settings *connection.Settings) *cobra.Command { - show := &cobra.Command{Use: "show", Short: "Show the Ledger plugin locked for the selected target", Args: cobra.NoArgs} + var service string + show := &cobra.Command{Use: "show", Short: "Show the service plugin locked for the selected target", Args: cobra.NoArgs} + show.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") show.RunE = func(cmd *cobra.Command, _ []string) error { + if err := distributionService(service); err != nil { + return err + } manager, err := pluginManager(settings, cmd) if err != nil { return err } - lock, err := cachedLedgerLock(settings, cmd, manager) + lock, err := cachedServiceLock(settings, cmd, manager, service) if err != nil { return err } diff --git a/cmd/plugins_sync.go b/cmd/plugins_sync.go index 67a02655..ff53012e 100644 --- a/cmd/plugins_sync.go +++ b/cmd/plugins_sync.go @@ -10,42 +10,50 @@ import ( ) func newPluginsSyncCommand(settings *connection.Settings) *cobra.Command { - var catalogue, serviceVersion string + var catalogue, serviceVersion, service string var revision int sync := &cobra.Command{ - Use: "sync", Short: "Download the Ledger plugin for the exact deployed service version", Args: cobra.NoArgs, + Use: "sync", Short: "Download the service plugin for the exact deployed service version", Args: cobra.NoArgs, Annotations: map[string]string{"fctl.target": "stack"}, Example: " fctl plugins sync --organization ORG --stack STACK\n fctl plugins sync --catalogue registry.yaml --service-version 3.0.0 --profile local", } + sync.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") sync.Flags().StringVar(&catalogue, "catalogue", "", "YAML or JSON catalogue file or HTTPS URL; defaults to the official registry (FCTL_PLUGIN_CATALOGUE)") - sync.Flags().StringVar(&serviceVersion, "service-version", "", "Exact service version; defaults to Ledger /_info discovery") + sync.Flags().StringVar(&serviceVersion, "service-version", "", "Exact service version; defaults to selected service /_info discovery") sync.Flags().IntVar(&revision, "revision", 0, "Plugin revision; defaults to the highest for this exact service version") sync.RunE = func(cmd *cobra.Command, _ []string) error { - return syncLedgerPlugin(cmd, settings, catalogue, serviceVersion, revision) + return syncServicePlugin(cmd, settings, service, catalogue, serviceVersion, revision) } return sync } func syncLedgerPlugin(cmd *cobra.Command, settings *connection.Settings, catalogue, serviceVersion string, revision int) error { + return syncServicePlugin(cmd, settings, "ledger", catalogue, serviceVersion, revision) +} + +func syncServicePlugin(cmd *cobra.Command, settings *connection.Settings, service, catalogue, serviceVersion string, revision int) error { + if err := distributionService(service); err != nil { + return err + } manager, err := pluginManager(settings, cmd) if err != nil { return err } - catalogue, err = pluginCatalogue(cmd, settings, manager, catalogue) + catalogue, err = servicePluginCatalogue(cmd, settings, manager, service, catalogue) if err != nil { return err } - target, version, err := pluginSyncTarget(cmd.Context(), settings, cmd, serviceVersion) + target, version, err := pluginServiceSyncTarget(cmd.Context(), settings, cmd, service, serviceVersion) if err != nil { return err } - release, err := manager.Resolve(cmd.Context(), catalogue, "ledger", version, revision) + release, err := manager.Resolve(cmd.Context(), catalogue, service, version, revision) if err != nil { return err } - if err := validateLedgerManifest(cmd.Context(), cmd.Root(), release.Manifest); err != nil { + if err := validateServiceManifest(cmd.Context(), cmd.Root(), service, release.Manifest); err != nil { return err } lock, err := manager.InstallResolved(cmd.Context(), catalogue, target, release) @@ -57,15 +65,24 @@ func syncLedgerPlugin(cmd *cobra.Command, settings *connection.Settings, catalog } func pluginCatalogue(cmd *cobra.Command, settings *connection.Settings, manager *pluginmanager.Manager, catalogue string) (string, error) { + return servicePluginCatalogue(cmd, settings, manager, "ledger", catalogue) +} + +func servicePluginCatalogue(cmd *cobra.Command, settings *connection.Settings, manager *pluginmanager.Manager, service, catalogue string) (string, error) { if catalogue != "" { return catalogue, nil } if value := os.Getenv("FCTL_PLUGIN_CATALOGUE"); value != "" { return value, nil } - lock, err := cachedLedgerLock(settings, cmd, manager) + lock, err := cachedServiceLock(settings, cmd, manager, service) if err == nil && lock.Catalogue != "" { return lock.Catalogue, nil } return pluginmanager.DefaultCatalogue, nil } + +func distributionService(service string) error { + _, err := supportedPluginService(service) + return err +} diff --git a/docs/plugin-distribution.md b/docs/plugin-distribution.md index 194ad40f..9a77aa27 100644 --- a/docs/plugin-distribution.md +++ b/docs/plugin-distribution.md @@ -1,11 +1,19 @@ -# External Ledger plugin pilot +# External service plugin distribution -The Ledger plugin can run as an independent native executable. Its source and -release configuration live in the Ledger repository's `fctl-plugin` Go module. +Ledger and Auth plugins can run as independent native executables. Their product +modules own their commands and executable entry points: + +| Service | Product module | Executable | +| --- | --- | --- | +| Ledger | `github.com/formancehq/ledger/misc/fctl-plugin` | `fctl-plugin-ledger` | +| Auth | `github.com/formancehq/auth/misc/fctl-plugin` | `fctl-plugin-auth` | + +Auth product sources remain in the Auth repository. Public executable distribution +is a separate publication step and does not require publishing private sources. The public SDK is a separate module at `github.com/formancehq/fctl/pkg/pluginsdk`. Service plugins depend on this SDK; they do not depend on fctl's commands, profiles, forms or authentication code. -The embedded Ledger provider imports the same product package. +Embedded providers and external executables use the same product commands. ## Try a local executable @@ -35,9 +43,41 @@ debug traces still belong to fctl. Local endpoints support `none` and Terminal forms and tables work with both providers. No plugin-specific flags are added to ordinary Ledger commands. +## Auth installation + +Build the Auth executable from its product module, injecting the exact deployed +Auth service version. The executable supports `--manifest` and `--version` for +inspection. From the Auth checkout: + +```sh +cd misc/fctl-plugin +go build -ldflags "-X main.serviceVersion=1.0.0 -X main.revision=1" \ + -o /tmp/fctl-plugin-auth ./cmd/fctl-plugin-auth +/tmp/fctl-plugin-auth --manifest +/tmp/fctl-plugin-auth --version +``` + +Use the built executable through the generic host: + +```sh +fctl plugins install --service auth --binary /tmp/fctl-plugin-auth \ + --auth-mode none --auth-url http://localhost:8080 +fctl auth clients list --auth-mode none --auth-url http://localhost:8080 +fctl plugins show --service auth --auth-mode none --auth-url http://localhost:8080 +fctl plugins sync --service auth --catalogue ./registry.yaml --profile local +``` + +`plugins install`, `plugins sync` and `plugins show` accept `--service ledger` +or `--service auth`; the default remains `ledger`. Unknown services are rejected +before installation. The selected service must match the manifest service, name +and command root. The manifest version must equal the exact service version. +Without `--service-version`, installation and sync discover that version through +the selected service's `/_info`. An explicit version supports preparation without +contacting the service; execution still verifies the deployed version. + ## Public OCI distribution -For each Ledger service version, build six executables for Linux, macOS and +For each product service version, build six executables for Linux, macOS and Windows on amd64 and arm64. Publish them as public OCI artifacts. No container engine is required on the user's machine. Keeping service sources private does not require keeping their CLI binaries private; package visibility must be @@ -50,8 +90,8 @@ before advertising its mapping. The catalogue has `schemaVersion: 1` and a | Field | Meaning | | --- | --- | -| `service` | `ledger` | -| `serviceVersion` | Exact version reported by the Ledger service | +| `service` | `ledger` or `auth` | +| `serviceVersion` | Exact version reported by the selected service; identical to `manifest.version` | | `revision` | Positive plugin revision for that service version | | `platform` | Operating system and architecture | | `artifact` | Registry origin, repository and immutable `sha256:` manifest digest | @@ -72,7 +112,7 @@ fctl plugins sync --catalogue ./registry.yaml --profile local fctl ledger list --profile local ``` -New Ledger targets discover native releases from the official catalogue. If +New Ledger and Auth targets discover native releases from the official catalogue. If it has no matching release, they retain the embedded provider. A temporary catalogue transport failure also retains the embedded provider for unprepared targets; invalid metadata remains an error. An empty catalogue does not query @@ -80,7 +120,7 @@ the service version or trigger target selection. Set `FCTL_PLUGIN_CATALOGUE` to override the catalogue used for automatic preparation. Explicit `plugins sync` prefers `--catalogue`, then the environment -variable, then the target's saved catalogue, then the official URL. +variable, then the selected service's saved catalogue, then the official URL. The core reads `/_info`, selects the exact version and current platform, checks protocol compatibility, downloads the digest-addressed artifact and verifies the manifest, config and executable checksums. A missing exact release fails @@ -94,9 +134,10 @@ access. Only explicitly trusted local binaries should be installed. ## Locks and updates -Each target has a lock identified by profile, organization, stack and stable -connection endpoint. Two stacks in the same profile can use different Ledger -versions. Executables are cached by digest and platform under the private v4 +Locks are identified by service plus profile, organization, stack and stable +connection endpoint. Auth and Ledger keep independent locks for the same target. +Two stacks in the same profile can use different service versions. Executables +are cached by digest and platform under the private v4 configuration directory's `plugins` folder. Normal commands retain the locked plugin revision. A changed service version @@ -106,7 +147,7 @@ reports the mismatch and must be replaced. Version checks run before writes. An explicit `plugins sync` selects the highest plugin revision for the exact service version. A CLI-only fix increments that revision without requiring a -new Ledger server release. CI can pin both values: +new product server release. CI can pin both values: ```sh fctl plugins sync --catalogue ./catalogue.json --profile local \ @@ -116,14 +157,18 @@ fctl plugins sync --catalogue ./catalogue.json --profile local \ Help and completion read the target's cached manifest without network access or a running plugin process. Prepared commands can use cached executables without downloading from the registry again. Service operations still need -access to the Ledger service and its version endpoint. +access to the selected service and its version endpoint. ## Validation `just pc` checks the core and SDK modules. `just tests` runs both race suites. -The CLI tests publish actual plugin executables to an HTTP OCI test registry, +The CLI tests publish actual Ledger executables and public-SDK Auth fixture +executables to HTTP OCI test registries on localhost, download and execute two service versions, retain and upgrade plugin revisions, -and check offline help and completion. Additional tests cover host OAuth2 and +and check offline help and completion. Auth tests also reject mismatched catalogue +identity and exact versions before downloads or lock writes, and verify independent +Auth and Ledger locks. Product integration must separately validate the Auth executable +built from its product module. Additional tests cover host OAuth2 and debug redaction, exact large JSON integers, partial bulk errors, cancellation, endpoint restrictions, corrupt downloads and concurrent cache installation. diff --git a/internal/pluginmanager/auth_catalogue_test.go b/internal/pluginmanager/auth_catalogue_test.go new file mode 100644 index 00000000..8b4fd79e --- /dev/null +++ b/internal/pluginmanager/auth_catalogue_test.go @@ -0,0 +1,84 @@ +package pluginmanager + +import ( + "errors" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func authManifest(version string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "auth", Service: "auth", Version: version, ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "auth", Short: "Auth", Subcommands: []pluginsdk.CommandSpec{{Use: "clients", Runnable: true}}}} +} + +func TestAuthCatalogueExactVersionAndIndependentLocks(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + ledger := publish(t, m, registry, "3.0.0", 1) + auth, err := m.Publish(t.Context(), registry.server.URL+"/formance/ledger", Release{Service: "auth", ServiceVersion: "1.0.0", Revision: 1, Platform: CurrentPlatform(), Manifest: authManifest("1.0.0")}, localBinary(t, []byte("auth fixture"))) + if err != nil { + t.Fatal(err) + } + catalogue := catalogueFile(t, ledger, auth) + for service, version := range map[string]string{"ledger": "3.0.0", "auth": "1.0.0"} { + release, err := m.Resolve(t.Context(), catalogue, service, version, 0) + if err != nil { + t.Fatal(err) + } + lock, err := m.InstallResolved(t.Context(), catalogue, target("shared"), release) + if err != nil || lock.Service != service { + t.Fatalf("install %s: %+v %v", service, lock, err) + } + } + if _, err := m.Resolve(t.Context(), catalogue, "auth", "1.0.1", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("Auth version fallback: %v", err) + } + registry.server.Close() + for _, service := range []string{"ledger", "auth"} { + lock, err := m.Load(target("shared"), service) + if err != nil || lock.Service != service || lock.Manifest.Service != service { + t.Fatalf("independent offline lock %s: %+v %v", service, lock, err) + } + } + locks, err := m.List() + if err != nil || len(locks) != 2 { + t.Fatalf("multi-service locks: %+v %v", locks, err) + } +} + +func TestAuthCatalogueRejectsVersionAndIdentityMismatchBeforeDownload(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release, err := m.Publish(t.Context(), registry.server.URL+"/formance/ledger", Release{Service: "auth", ServiceVersion: "1.0.0", Revision: 1, Platform: CurrentPlatform(), Manifest: authManifest("1.0.0")}, localBinary(t, []byte("auth fixture"))) + if err != nil { + t.Fatal(err) + } + before := registry.requests.Load() + for _, test := range []struct { + name string + mutate func(*Release) + }{ + {"wrong manifest service", func(r *Release) { r.Manifest.Service = "ledger" }}, + {"wrong exact version", func(r *Release) { r.Manifest.Version = "1.0.1" }}, + {"wrong command root", func(r *Release) { r.Manifest.Root.Use = "ledger" }}, + } { + t.Run(test.name, func(t *testing.T) { + invalid := release + test.mutate(&invalid) + catalogue := catalogueFile(t, invalid) + if _, err := m.Resolve(t.Context(), catalogue, "auth", "1.0.0", 0); err == nil { + t.Fatal("invalid Auth catalogue accepted") + } + if _, err := m.InstallResolved(t.Context(), catalogue, target("reject"), invalid); err == nil { + t.Fatal("invalid resolved Auth release installed") + } + if registry.requests.Load() != before { + t.Fatal("invalid metadata reached registry") + } + if _, err := m.Load(target("reject"), "auth"); !errors.Is(err, ErrNotInstalled) { + t.Fatalf("invalid metadata wrote lock: %v", err) + } + }) + } +} diff --git a/internal/pluginmanager/catalogue.go b/internal/pluginmanager/catalogue.go index 108b0e9a..a7e5ae10 100644 --- a/internal/pluginmanager/catalogue.go +++ b/internal/pluginmanager/catalogue.go @@ -185,6 +185,9 @@ func validateRelease(release Release, local bool) error { if err := validateManifest(release.Manifest, release.Service); err != nil { return err } + if release.Manifest.Version != release.ServiceVersion { + return fmt.Errorf("manifest version %q does not match exact service version %q", release.Manifest.Version, release.ServiceVersion) + } if local { return nil } From fe6fdd9c9488a3cf79404518096ebb72784741ca Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:23:17 +0200 Subject: [PATCH 5/6] fix(auth): pin the published product plugin revision --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 053498a5..a32e1cff 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 github.com/coreos/go-oidc/v3 v3.17.0 - github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009151254-70cef9efe2e2 + github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.12.1 diff --git a/go.sum b/go.sum index 20d731a3..3cfe560e 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuL github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca h1:5K0eexxolqV1cnBL+nZPrAGL5fs5xyDPv3lV34tJ/ok= +github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca/go.mod h1:y1rflQQby0upghZhA9pXIOwl1R1+hFcp+brKMTifVIE= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba h1:UlEHshELSyVGhLQamC6zSXLzOvKW/8zAGndNIRa9THY= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba/go.mod h1:7w74DBUsVrg9FuGsogUMsmBCjGGloEKUEyvZGsNuUeg= github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d h1:A+Hgy+0j0Gy6ORjlkQl5k0M27fHH9MAjI5jZMmTvJYM= From c07b2b5531564baad541d851a14f88787185d536 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:40:05 +0200 Subject: [PATCH 6/6] build(auth): consume the release-enabled plugin revision --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a32e1cff..cb7e0b82 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 github.com/coreos/go-oidc/v3 v3.17.0 - github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca + github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009153732-add6bd4c5dd7 github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.12.1 diff --git a/go.sum b/go.sum index 3cfe560e..604b6356 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,8 @@ github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuL github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca h1:5K0eexxolqV1cnBL+nZPrAGL5fs5xyDPv3lV34tJ/ok= -github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009152040-94f7c95801ca/go.mod h1:y1rflQQby0upghZhA9pXIOwl1R1+hFcp+brKMTifVIE= +github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009153732-add6bd4c5dd7 h1:NlReUN+S5PgwrPohtfcHK6OMLnL1enGyX9/lYcYYIQ4= +github.com/formancehq/auth/misc/fctl-plugin v0.0.0-20261009153732-add6bd4c5dd7/go.mod h1:y1rflQQby0upghZhA9pXIOwl1R1+hFcp+brKMTifVIE= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba h1:UlEHshELSyVGhLQamC6zSXLzOvKW/8zAGndNIRa9THY= github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba/go.mod h1:7w74DBUsVrg9FuGsogUMsmBCjGGloEKUEyvZGsNuUeg= github.com/formancehq/ledger/misc/fctl-plugin v0.0.0-20261009143954-060fabc5af8d h1:A+Hgy+0j0Gy6ORjlkQl5k0M27fHH9MAjI5jZMmTvJYM=