diff --git a/cmd/connectivity/connectorinstances/completion.go b/cmd/connectivity/connectorinstances/completion.go new file mode 100644 index 00000000..dc10075c --- /dev/null +++ b/cmd/connectivity/connectorinstances/completion.go @@ -0,0 +1,242 @@ +package connectorinstances + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const instanceCompletionMaxPages = 5 + +type PathCompleter func(prefix string) ([]string, error) + +// ConnectorResolver names the Connector a command is operating on. +type ConnectorResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (string, error) + +// ConnectorVersionResolver resolves the ConnectorVersion whose configSchema +// drives value completion for a command. +type ConnectorVersionResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.ConnectorVersion, error) + +func CompleteConnectorInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + instances, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorInstance, bool, string, error) { + page, err := client.ListConnectorInstances(completionCommand.Context(), options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + candidates := make([]string, 0, len(instances)) + for _, instance := range instances { + name := stringValue(instance.Metadata.Name) + if name == "" || !strings.HasPrefix(name, toComplete) { + continue + } + description := strings.Join(nonEmptyStrings(instance.Spec.Connector, instance.Spec.Ledger), " · ") + if description != "" { + name += "\t" + description + } + candidates = append(candidates, name) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func CompleteVersions(factory connectivityinternal.ClientFactory, connectorArg func(*cobra.Command, []string) string) cobra.CompletionFunc { + return completeVersions(factory, func(_ context.Context, _ connectivityclient.Client, cmd *cobra.Command, args []string) (string, error) { + if connectorArg == nil { + return "", nil + } + return connectorArg(cmd, args), nil + }) +} + +func completeVersions(factory connectivityinternal.ClientFactory, resolveConnector ConnectorResolver) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if resolveConnector == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + connector, err := resolveConnector(completionCommand.Context(), client, completionCommand, args) + if err != nil || connector == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + versions, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(completionCommand.Context(), connector, options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidates := make([]string, 0, len(versions)) + for _, version := range versions { + if version.Version == "" || !strings.HasPrefix(version.Version, toComplete) { + continue + } + candidate := version.Version + description := version.Image + if description == "" { + description = stringValue(version.Digest) + } + if description != "" { + candidate += "\t" + description + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func CompleteSetValues( + factory connectivityinternal.ClientFactory, + resolveVersion ConnectorVersionResolver, + paths PathCompleter, +) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if marker := strings.Index(toComplete, "=@"); marker >= 0 { + if paths == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidatePrefix := toComplete[:marker+2] + pathPrefix := toComplete[marker+2:] + pathCandidates, err := paths(pathPrefix) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + candidates := make([]string, 0, len(pathCandidates)) + for _, path := range pathCandidates { + if strings.HasPrefix(path, pathPrefix) { + candidates = append(candidates, candidatePrefix+path) + } + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } + + if resolveVersion == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + version, err := resolveVersion(completionCommand.Context(), client, completionCommand, args) + if err != nil || version == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + fields, err := SchemaFields(version) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + supplied := suppliedSetKeys(cmd, args) + ordered := make([]SchemaField, 0, len(fields)) + for key, field := range fields { + if supplied[key] || !strings.HasPrefix(key, strings.TrimSuffix(toComplete, "=")) { + continue + } + ordered = append(ordered, field) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].Required != ordered[j].Required { + return ordered[i].Required + } + return ordered[i].Key < ordered[j].Key + }) + candidates := make([]string, 0, len(ordered)) + for _, field := range ordered { + candidates = append(candidates, field.Key+"=\t"+schemaFieldDescription(field)) + } + return candidates, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } +} + +func schemaFieldDescription(field SchemaField) string { + if field.Description != "" { + return field.Description + } + if field.Kind == ConfigFile { + return "file configuration" + } + return "environment configuration" +} + +func OSPathCompleter(prefix string) ([]string, error) { + directory, base := filepath.Split(prefix) + readDirectory := directory + if readDirectory == "" { + readDirectory = "." + } + entries, err := os.ReadDir(readDirectory) + if err != nil { + return nil, err + } + candidates := make([]string, 0, len(entries)) + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), base) { + continue + } + candidate := directory + entry.Name() + if entry.IsDir() { + candidate += string(filepath.Separator) + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, nil +} + +func suppliedSetKeys(cmd *cobra.Command, args []string) map[string]bool { + values := append([]string(nil), args...) + if cmd != nil && cmd.Flags().Lookup("set") != nil { + if setValues, err := cmd.Flags().GetStringArray("set"); err == nil { + values = append(values, setValues...) + } + } + supplied := make(map[string]bool) + for _, value := range values { + if separator := strings.IndexByte(value, '='); separator > 0 { + supplied[value[:separator]] = true + } + } + return supplied +} + +func nonEmptyStrings(values ...string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != "" { + result = append(result, value) + } + } + return result +} diff --git a/cmd/connectivity/connectorinstances/completion_test.go b/cmd/connectivity/connectorinstances/completion_test.go new file mode 100644 index 00000000..072695df --- /dev/null +++ b/cmd/connectivity/connectorinstances/completion_test.go @@ -0,0 +1,282 @@ +package connectorinstances + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type completionClientMock struct { + connectorVersions + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) +} + +func (m completionClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.listInstances(ctx, options) +} + +func TestCompleteConnectorInstanceNamesUsesBoundedNonInteractiveQueryAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + var remaining time.Duration + client := completionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("completion context has no deadline") + } + remaining = time.Until(deadline) + alpha := instanceFixture("alpha") + alpha.Spec.Connector = "stripe" + alpha.Spec.Ledger = "eu" + alpine := instanceFixture("alpine") + alpine.Spec.Connector = "wise" + alpine.Spec.Ledger = "uk" + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{ + instanceFixture("beta"), alpine, alpha, + }}, nil + }} + completion := CompleteConnectorInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("completion factory context is interactive") + } + return client, nil + }) + + candidates, directive := completion(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{PageSize: 100}) { + t.Fatalf("ListConnectorInstances options = %#v, want page size 100", gotOptions) + } + if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { + t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) + } + want := []string{"alpha\tstripe · eu", "alpine\twise · uk"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteVersionsUsesSelectedConnectorAndReturnsSortedPrefixMatchesWithDescriptions(t *testing.T) { + var gotName string + client := completionClientMock{connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + if !hasTwoSecondDeadline(ctx) { + t.Fatal("version completion context does not have the expected deadline") + } + if options.PageSize != 100 { + t.Fatalf("ListConnectorVersions page size = %d, want 100", options.PageSize) + } + gotName = name + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "2.1.0", Image: "", Digest: stringPtr("sha256:two")}, + {Version: "1.0.0", Image: "registry/connector:1"}, + {Version: "2.0.0", Image: "registry/connector:2"}, + }}, nil + }, + }} + completion := CompleteVersions(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("version completion factory context is interactive") + } + return client, nil + }, func(_ *cobra.Command, args []string) string { + return args[0] + }) + + candidates, directive := completion(&cobra.Command{}, []string{"stripe"}, "2") + + if gotName != "stripe" { + t.Fatalf("ListConnectorVersions connector = %q, want stripe", gotName) + } + want := []string{"2.0.0\tregistry/connector:2", "2.1.0\tsha256:two"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteSetValuesPrioritizesRequiredKeysDescribesAndOmitsSuppliedKeys(t *testing.T) { + var resolvedClient connectivityclient.Client + client := &completionClientMock{} + resolveVersion := func(ctx context.Context, got connectivityclient.Client, _ *cobra.Command, _ []string) (*connectivityclient.ConnectorVersion, error) { + if !connectivityinternal.IsNonInteractive(ctx) || !hasTwoSecondDeadline(ctx) { + t.Fatal("set completion resolver context must be non-interactive with a two-second deadline") + } + resolvedClient = got + return versionWithFullSchema(), nil + } + completion := CompleteSetValues(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("set completion factory context is interactive") + } + return client, nil + }, resolveVersion, mockPathCompleter(nil)) + command := &cobra.Command{} + command.Flags().StringArray("set", nil, "") + if err := command.Flags().Set("set", "TOKEN=secret://connector/token"); err != nil { + t.Fatal(err) + } + + candidates, directive := completion(command, nil, "") + + if resolvedClient != client { + t.Fatal("resolver did not receive the factory client") + } + want := []string{ + "/etc/plugin/config.yaml=\tConnector configuration", + "API_URL=\tAPI endpoint", + "/etc/plugin/ca.pem=\tfile configuration", + "TIMEOUT=\tenvironment configuration", + } + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want required then optional keys %#v", candidates, want) + } + wantDirective := cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + if directive != wantDirective { + t.Fatalf("directive = %v, want NoFileComp|NoSpace", directive) + } +} + +func TestCompleteSetValuesPreservesKeyAtPrefixForInjectedPathCandidates(t *testing.T) { + var gotPrefix string + paths := func(prefix string) ([]string, error) { + gotPrefix = prefix + return []string{"fixtures/alpha/", "fixtures/api-key.txt"}, nil + } + completion := CompleteSetValues(nil, nil, paths) + + candidates, directive := completion(&cobra.Command{}, nil, "API_KEY=@fixtures/a") + + if gotPrefix != "fixtures/a" { + t.Fatalf("path prefix = %q, want fixtures/a", gotPrefix) + } + want := []string{"API_KEY=@fixtures/alpha/", "API_KEY=@fixtures/api-key.txt"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want prefixed paths %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFunctionsReturnSilentlyOnFactoryAPIResolverPathAndTimeoutErrors(t *testing.T) { + apiErrorClient := completionClientMock{ + listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return nil, errors.New("unsupported deployment") + }, + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return nil, errors.New("unsupported deployment") + }, + }, + } + tests := map[string]struct { + completion cobra.CompletionFunc + command *cobra.Command + args []string + prefix string + }{ + "instance authentication": { + completion: CompleteConnectorInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }), + command: &cobra.Command{}, + }, + "instance nil client": { + completion: CompleteConnectorInstanceNames(func(*cobra.Command) (connectivityclient.Client, error) { return nil, nil }), + command: &cobra.Command{}, + }, + "instance API": {completion: CompleteConnectorInstanceNames(factoryReturning(apiErrorClient)), command: &cobra.Command{}}, + "instance pagination": { + completion: CompleteConnectorInstanceNames(factoryReturning(completionClientMock{listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + if options.Cursor != "" { + return nil, errors.New("second page unavailable") + } + return &connectivityclient.ConnectorInstanceList{HasMore: true, Next: "page-two"}, nil + }})), + command: &cobra.Command{}, + }, + "instance timeout": { + completion: CompleteConnectorInstanceNames(factoryReturning(completionClientMock{listInstances: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + <-ctx.Done() + return nil, ctx.Err() + }})), + command: commandWithExpiredContext(), + }, + "version empty connector": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "" }), command: &cobra.Command{}}, + "version API": {completion: CompleteVersions(factoryReturning(apiErrorClient), func(*cobra.Command, []string) string { return "stripe" }), command: &cobra.Command{}}, + "set resolver": { + completion: CompleteSetValues(factoryReturning(completionClientMock{}), func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.ConnectorVersion, error) { + return nil, errors.New("cannot resolve connector version") + }, mockPathCompleter(nil)), + command: &cobra.Command{}, + }, + "path": { + completion: CompleteSetValues(nil, nil, func(string) ([]string, error) { return nil, errors.New("cannot read directory") }), + command: &cobra.Command{}, + prefix: "API_KEY=@fixtures/a", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + candidates, directive := test.completion(test.command, test.args, test.prefix) + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} + +func TestCompletionClientCopyDoesNotMutateOriginalCommandContext(t *testing.T) { + originalContext := context.WithValue(context.Background(), struct{}{}, "original") + command := &cobra.Command{} + command.SetContext(originalContext) + completion := CompleteConnectorInstanceNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("copied completion command must be non-interactive") + } + return completionClientMock{listInstances: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return &connectivityclient.ConnectorInstanceList{}, nil + }}, nil + }) + + _, _ = completion(command, nil, "") + + if command.Context() != originalContext { + t.Fatal("completion mutated the original command context") + } + if connectivityinternal.IsNonInteractive(command.Context()) { + t.Fatal("original command context became non-interactive") + } +} + +func hasTwoSecondDeadline(ctx context.Context) bool { + deadline, ok := ctx.Deadline() + if !ok { + return false + } + remaining := time.Until(deadline) + return remaining > 1500*time.Millisecond && remaining <= 2*time.Second +} + +func commandWithExpiredContext() *cobra.Command { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + command := &cobra.Command{} + command.SetContext(ctx) + return command +} diff --git a/cmd/connectivity/connectorinstances/config.go b/cmd/connectivity/connectorinstances/config.go new file mode 100644 index 00000000..77bfc39f --- /dev/null +++ b/cmd/connectivity/connectorinstances/config.go @@ -0,0 +1,932 @@ +package connectorinstances + +import ( + "bufio" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type ConfigKind string + +const ( + ConfigEnv ConfigKind = "env" + ConfigFile ConfigKind = "file" +) + +type SchemaField struct { + Key string + Kind ConfigKind + Required bool + Password bool + Description string +} + +type InputOptions struct { + ConfigFile string + EnvFiles []string + SetValues []string +} + +type ReadFileFunc func(cmd *cobra.Command, path string) (string, error) + +func SchemaFields(version *connectivityclient.ConnectorVersion) (map[string]SchemaField, error) { + contract, err := schemaContractFor(version) + if err != nil { + return nil, err + } + return contract.fields, nil +} + +type schemaContract struct { + fields map[string]SchemaField + open map[ConfigKind]bool +} + +type schemaFieldInfo struct { + required bool + password bool + description string +} + +type sectionContract struct { + fields map[string]schemaFieldInfo + open bool +} + +func schemaContractFor(version *connectivityclient.ConnectorVersion) (schemaContract, error) { + if version == nil { + return schemaContract{}, fmt.Errorf("connector version is required") + } + if len(version.ConfigSchema) == 0 { + return schemaContract{fields: map[string]SchemaField{}, open: map[ConfigKind]bool{}}, nil + } + + contract := schemaContract{fields: make(map[string]SchemaField), open: make(map[ConfigKind]bool)} + sections := []struct { + name string + kind ConfigKind + raw any + }{ + {name: "env", kind: ConfigEnv, raw: version.ConfigSchema["env"]}, + {name: "files", kind: ConfigFile, raw: version.ConfigSchema["files"]}, + } + if sections[0].raw == nil && sections[1].raw == nil { + if _, hasLegacyProperties := version.ConfigSchema["properties"]; hasLegacyProperties { + sections[0].raw = version.ConfigSchema + } + } + + for _, section := range sections { + rawSection := section.raw + exists := rawSection != nil + if !exists { + continue + } + sectionSchema, ok := stringMap(rawSection) + if !ok { + return schemaContract{}, fmt.Errorf("connector config schema %s must be an object", section.name) + } + collected, err := collectSectionSchema(sectionSchema, sectionSchema, 0, map[string]bool{}) + if err != nil { + return schemaContract{}, fmt.Errorf("connector config schema %s: %w", section.name, err) + } + contract.open[section.kind] = collected.open + for key, definition := range collected.fields { + if _, exists := contract.fields[key]; exists { + return schemaContract{}, fmt.Errorf("configuration key %q is declared in both env and files", key) + } + contract.fields[key] = SchemaField{ + Key: key, + Kind: section.kind, + Required: definition.required, + Password: definition.password, + Description: definition.description, + } + } + } + + return contract, nil +} + +const maxSchemaCollectionDepth = 64 + +func collectSectionSchema(raw any, root map[string]any, depth int, refs map[string]bool) (sectionContract, error) { + if depth > maxSchemaCollectionDepth { + return sectionContract{}, fmt.Errorf("schema nesting exceeds %d", maxSchemaCollectionDepth) + } + node, ok := stringMap(raw) + if !ok { + return sectionContract{}, fmt.Errorf("schema must be an object") + } + result := sectionContract{fields: map[string]schemaFieldInfo{}, open: sectionOpen(node)} + properties, ok := stringMap(node["properties"]) + if !ok && node["properties"] != nil { + return sectionContract{}, fmt.Errorf("properties must be an object") + } + required, err := requiredSet(node["required"]) + if err != nil { + return sectionContract{}, err + } + for key, definition := range properties { + metadata, err := collectFieldMetadata(definition, root, depth+1, refs) + if err != nil { + return sectionContract{}, fmt.Errorf("field %q: %w", key, err) + } + metadata.required = required[key] + result.fields[key] = metadata + } + for key := range required { + if _, found := result.fields[key]; !found { + result.fields[key] = schemaFieldInfo{required: true} + } + } + for _, ref := range schemaLocalReferences(node) { + if refs[ref] { + continue // The API accepts recursive schemas; metadata is best effort. + } + if target, resolved := resolveLocalSchemaRef(root, ref); resolved { + refs[ref] = true + referenced, err := collectSectionSchema(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return sectionContract{}, err + } + result = mergeAllSections(result, referenced) + } + } + for _, keyword := range []string{"allOf"} { + parts, err := schemaArray(node[keyword]) + if err != nil { + return sectionContract{}, fmt.Errorf("%s: %w", keyword, err) + } + for _, part := range parts { + collected, err := collectSectionSchema(part, root, depth+1, refs) + if err != nil { + return sectionContract{}, err + } + result = mergeAllSections(result, collected) + } + } + for _, keyword := range []string{"anyOf", "oneOf"} { + parts, err := schemaArray(node[keyword]) + if err != nil { + return sectionContract{}, fmt.Errorf("%s: %w", keyword, err) + } + if len(parts) == 0 { + continue + } + alternatives := make([]sectionContract, 0, len(parts)) + for _, part := range parts { + collected, err := collectSectionSchema(part, root, depth+1, refs) + if err != nil { + return sectionContract{}, err + } + alternatives = append(alternatives, collected) + } + result = mergeAllSections(result, mergeAlternativeSections(alternatives)) + } + return result, nil +} + +func schemaArray(raw any) ([]any, error) { + if raw == nil { + return nil, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("must be an array") + } + return values, nil +} + +func sectionOpen(node map[string]any) bool { + additional, present := node["additionalProperties"] + open := !present || additional != false + if patterns, ok := stringMap(node["patternProperties"]); ok && len(patterns) > 0 { + return true + } + return open +} + +func mergeAllSections(left, right sectionContract) sectionContract { + merged := sectionContract{fields: make(map[string]schemaFieldInfo), open: left.open && right.open} + for key, value := range left.fields { + merged.fields[key] = value + } + for key, value := range right.fields { + current, exists := merged.fields[key] + if !exists { + merged.fields[key] = value + continue + } + merged.fields[key] = schemaFieldInfo{required: current.required || value.required, password: current.password || value.password, description: firstDescription(current.description, value.description)} + } + return merged +} + +func mergeAlternativeSections(alternatives []sectionContract) sectionContract { + merged := sectionContract{fields: make(map[string]schemaFieldInfo)} + for index, alternative := range alternatives { + merged.open = merged.open || alternative.open + for key, value := range alternative.fields { + current, exists := merged.fields[key] + if !exists { + value.required = index == 0 && value.required + merged.fields[key] = value + continue + } + current.required = current.required && value.required + current.password = current.password || value.password + current.description = firstDescription(current.description, value.description) + merged.fields[key] = current + } + if index > 0 { + for key, value := range merged.fields { + if _, exists := alternative.fields[key]; !exists { + value.required = false + merged.fields[key] = value + } + } + } + } + return merged +} + +func collectFieldMetadata(raw any, root map[string]any, depth int, refs map[string]bool) (schemaFieldInfo, error) { + if depth > maxSchemaCollectionDepth { + return schemaFieldInfo{}, fmt.Errorf("schema nesting exceeds %d", maxSchemaCollectionDepth) + } + node, ok := stringMap(raw) + if !ok { + return schemaFieldInfo{}, nil // Boolean schemas are delegated to the API. + } + result := schemaFieldInfo{} + if format, _ := node["format"].(string); format == "password" { + result.password = true + } + if secret, _ := node["x-secret"].(bool); secret { + result.password = true + } + result.description, _ = node["description"].(string) + for _, ref := range schemaLocalReferences(node) { + if refs[ref] { + continue // Recursive reference: leave the remaining validation to the API. + } + if target, found := resolveLocalSchemaRef(root, ref); found { + refs[ref] = true + resolved, err := collectFieldMetadata(target, root, depth+1, refs) + delete(refs, ref) + if err != nil { + return schemaFieldInfo{}, err + } + result.password = result.password || resolved.password + result.description = firstDescription(result.description, resolved.description) + } + } + for _, part := range appendSchemaCompositions(node) { + metadata, err := collectFieldMetadata(part, root, depth+1, refs) + if err != nil { + return schemaFieldInfo{}, err + } + result.password = result.password || metadata.password + result.description = firstDescription(result.description, metadata.description) + } + return result, nil +} + +func appendSchemaCompositions(node map[string]any) []any { + parts := make([]any, 0) + for _, keyword := range []string{"allOf", "anyOf", "oneOf"} { + if values, ok := node[keyword].([]any); ok { + parts = append(parts, values...) + } + } + return parts +} + +func schemaLocalReferences(node map[string]any) []string { + refs := make([]string, 0, 3) + for _, keyword := range []string{"$ref", "$dynamicRef", "$recursiveRef"} { + if ref, ok := node[keyword].(string); ok && strings.HasPrefix(ref, "#") { + refs = append(refs, ref) + } + } + return refs +} + +// resolveLocalSchemaRef follows JSON Pointer and named local anchors. It has +// the same deliberately tolerant contract as the API's schema metadata walk: +// unresolvable or external references are deferred to server-side validation. +func resolveLocalSchemaRef(root map[string]any, ref string) (any, bool) { + if !strings.HasPrefix(ref, "#") { + return nil, false + } + fragment, err := url.PathUnescape(strings.TrimPrefix(ref, "#")) + if err != nil { + return nil, false + } + if fragment == "" { + return root, true + } + if strings.HasPrefix(fragment, "/") { + var current any = root + for _, token := range strings.Split(strings.TrimPrefix(fragment, "/"), "/") { + object, ok := stringMap(current) + if !ok { + return nil, false + } + current, ok = object[strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")] + if !ok { + return nil, false + } + } + return current, true + } + return findSchemaAnchor(root, fragment) +} + +func findSchemaAnchor(root map[string]any, anchor string) (any, bool) { + remaining := 100000 + var find func(any, int) (any, bool) + find = func(node any, depth int) (any, bool) { + if depth > maxSchemaCollectionDepth || remaining == 0 { + return nil, false + } + remaining-- + switch node := node.(type) { + case map[string]any: + for _, keyword := range []string{"$anchor", "$dynamicAnchor"} { + if declared, _ := node[keyword].(string); declared == anchor { + return node, true + } + } + keys := make([]string, 0, len(node)) + for key := range node { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if target, found := find(node[key], depth+1); found { + return target, true + } + } + case []any: + for _, child := range node { + if target, found := find(child, depth+1); found { + return target, true + } + } + } + return nil, false + } + return find(root, 0) +} + +func firstDescription(left, right string) string { + if left != "" { + return left + } + return right +} + +func BuildInstallConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + return buildConfig(cmd, version, nil, inputs, read) +} + +func BuildConfigureConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, current *connectivityclient.ConnectorInstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + return buildConfig(cmd, version, current, inputs, read) +} + +func buildConfig(cmd *cobra.Command, version *connectivityclient.ConnectorVersion, base *connectivityclient.ConnectorInstanceConfig, inputs InputOptions, read ReadFileFunc) (*connectivityclient.ConnectorInstanceConfig, error) { + contract, err := schemaContractFor(version) + if err != nil { + return nil, err + } + fields := contract.fields + config := cloneConfig(base) + unknown := make(map[string]struct{}) + + if inputs.ConfigFile != "" { + contents, err := readInput(cmd, read, inputs.ConfigFile) + if err != nil { + return nil, fmt.Errorf("reading config %q: %w", inputs.ConfigFile, err) + } + if err := applyConfigDocument(config, contract, contents, unknown); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", inputs.ConfigFile, err) + } + } + + for _, path := range inputs.EnvFiles { + contents, err := readInput(cmd, read, path) + if err != nil { + return nil, fmt.Errorf("reading env file %q: %w", path, err) + } + if err := applyDotenv(config, contract, contents, unknown); err != nil { + return nil, fmt.Errorf("parsing env file %s:%w", path, err) + } + } + + for _, assignment := range inputs.SetValues { + key, value, err := parseAssignment(assignment) + if err != nil { + return nil, err + } + field, exists := assignmentField(contract, key) + if !exists { + unknown[key] = struct{}{} + continue + } + parsed, err := parseSetValue(cmd, read, value) + if err != nil { + return nil, fmt.Errorf("configuration key %q: %w", key, err) + } + applyValue(config, field, parsed) + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown configuration keys: %s", strings.Join(sortedKeys(unknown), ", ")) + } + + missing := make(map[string]struct{}) + for key, field := range fields { + if field.Required && !hasValue(config, field) { + missing[key] = struct{}{} + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required configuration keys: %s", strings.Join(sortedKeys(missing), ", ")) + } + + return config, nil +} + +type parsedValue struct { + value *string + secretRef *connectivityclient.KeyRef + configMapRef *connectivityclient.KeyRef +} + +type configDocument struct { + Env map[string]documentValue `yaml:"env"` + Files []documentFile `yaml:"files"` +} + +type documentValue struct { + Value *string `yaml:"value"` + SecretRef *documentRef `yaml:"secretRef"` + ConfigMapRef *documentRef `yaml:"configMapRef"` +} + +type documentRef struct { + Name string `yaml:"name"` + Key string `yaml:"key"` +} + +type documentFile struct { + Path string `yaml:"path"` + Value *string `yaml:"value"` + SecretRef *documentRef `yaml:"secretRef"` + ConfigMapRef *documentRef `yaml:"configMapRef"` + Mode *int32 `yaml:"mode"` +} + +func parseSetValue(cmd *cobra.Command, read ReadFileFunc, raw string) (parsedValue, error) { + if strings.HasPrefix(raw, "secret://") { + ref, err := parseReference(strings.TrimPrefix(raw, "secret://"), "secret") + return parsedValue{secretRef: ref}, err + } + if strings.HasPrefix(raw, "configmap://") { + ref, err := parseReference(strings.TrimPrefix(raw, "configmap://"), "configmap") + return parsedValue{configMapRef: ref}, err + } + if strings.HasPrefix(raw, "@") { + path := strings.TrimPrefix(raw, "@") + if path == "" { + return parsedValue{}, fmt.Errorf("file value path cannot be empty") + } + contents, err := readInput(cmd, read, path) + if err != nil { + return parsedValue{}, fmt.Errorf("reading value from %q: %w", path, err) + } + return parsedValue{value: stringPointer(contents)}, nil + } + return parsedValue{value: stringPointer(raw)}, nil +} + +func parseReference(raw, kind string) (*connectivityclient.KeyRef, error) { + if strings.Count(raw, "/") != 1 { + return nil, fmt.Errorf("malformed %s reference: expected %s://name/key", kind, kind) + } + parts := strings.SplitN(raw, "/", 2) + if parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("malformed %s reference: expected %s://name/key", kind, kind) + } + return &connectivityclient.KeyRef{Name: parts[0], Key: parts[1]}, nil +} + +func parseAssignment(raw string) (string, string, error) { + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid --set: expected KEY=value") + } + key := strings.TrimSpace(parts[0]) + if key == "" { + return "", "", fmt.Errorf("invalid --set: empty key") + } + return key, parts[1], nil +} + +func applyDotenv(config *connectivityclient.ConnectorInstanceConfig, contract schemaContract, contents string, unknown map[string]struct{}) error { + scanner := bufio.NewScanner(strings.NewReader(contents)) + line := 0 + for scanner.Scan() { + line++ + raw := strings.TrimSpace(scanner.Text()) + if raw == "" || strings.HasPrefix(raw, "#") { + continue + } + raw = strings.TrimPrefix(raw, "export ") + equals := strings.IndexByte(raw, '=') + if equals <= 0 { + return fmt.Errorf("%d: expected KEY=value", line) + } + key := strings.TrimSpace(raw[:equals]) + if key == "" { + return fmt.Errorf("%d: expected KEY=value", line) + } + value := unquoteEnvValue(raw[equals+1:]) + field, exists := configField(contract, key, ConfigEnv) + if !exists { + unknown[key] = struct{}{} + continue + } + applyValue(config, field, parsedValue{value: stringPointer(value)}) + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +func unquoteEnvValue(value string) string { + value = strings.TrimSpace(value) + if comment := dotenvCommentIndex(value); comment >= 0 { + value = strings.TrimSpace(value[:comment]) + } + if len(value) >= 2 { + if value[0] == '"' && value[len(value)-1] == '"' { + return applyDoubleQuoteEscapes(value[1 : len(value)-1]) + } + if value[0] == '\'' && value[len(value)-1] == '\'' { + return value[1 : len(value)-1] + } + } + return value +} + +func dotenvCommentIndex(value string) int { + var quote byte + for index := 0; index < len(value); index++ { + character := value[index] + if quote != 0 { + if quote == '"' && character == '\\' && index+1 < len(value) { + index++ + continue + } + if character == quote { + quote = 0 + } + continue + } + if character == '"' || character == '\'' { + quote = character + continue + } + if character == '#' && (index == 0 || value[index-1] == ' ' || value[index-1] == '\t') { + return index + } + } + return -1 +} + +func applyDoubleQuoteEscapes(value string) string { + var decoded strings.Builder + decoded.Grow(len(value)) + for index := 0; index < len(value); index++ { + if value[index] == '\\' && index+1 < len(value) { + index++ + switch value[index] { + case 'n': + decoded.WriteByte('\n') + case 't': + decoded.WriteByte('\t') + case 'r': + decoded.WriteByte('\r') + case '\\': + decoded.WriteByte('\\') + case '"': + decoded.WriteByte('"') + default: + decoded.WriteByte(value[index]) + } + continue + } + decoded.WriteByte(value[index]) + } + return decoded.String() +} + +func applyConfigDocument(config *connectivityclient.ConnectorInstanceConfig, contract schemaContract, contents string, unknown map[string]struct{}) error { + var root map[string]any + if err := yaml.Unmarshal([]byte(contents), &root); err != nil { + return err + } + if root == nil { + return nil + } + + _, hasEnv := root["env"] + _, hasFiles := root["files"] + if !hasEnv && !hasFiles { + for key, raw := range root { + field, exists := configField(contract, key, ConfigEnv) + if !exists { + unknown[key] = struct{}{} + continue + } + value, err := scalarString(raw) + if err != nil { + return fmt.Errorf("configuration key %q: %w", key, err) + } + applyValue(config, field, parsedValue{value: stringPointer(value)}) + } + return nil + } + + for key := range root { + if key != "env" && key != "files" { + unknown[key] = struct{}{} + } + } + var document configDocument + if err := yaml.Unmarshal([]byte(contents), &document); err != nil { + return err + } + for key, value := range document.Env { + _, exists := configField(contract, key, ConfigEnv) + if !exists { + unknown[key] = struct{}{} + continue + } + converted := connectivityclient.EnvValue{ + Value: cloneString(value.Value), + SecretRef: value.SecretRef.clientRef(), + ConfigMapRef: value.ConfigMapRef.clientRef(), + } + if err := validateSources(converted.Value, converted.SecretRef, converted.ConfigMapRef); err != nil { + return fmt.Errorf("configuration key %q: %w", key, err) + } + config.Env[key] = converted + } + for _, file := range document.Files { + if strings.TrimSpace(file.Path) == "" { + return fmt.Errorf("structured configuration file path is required") + } + _, exists := configField(contract, file.Path, ConfigFile) + if !exists { + unknown[file.Path] = struct{}{} + continue + } + converted := connectivityclient.FileMount{ + Path: file.Path, + Value: cloneString(file.Value), + SecretRef: file.SecretRef.clientRef(), + ConfigMapRef: file.ConfigMapRef.clientRef(), + Mode: cloneMode(file.Mode), + } + if err := validateSources(converted.Value, converted.SecretRef, converted.ConfigMapRef); err != nil { + return fmt.Errorf("configuration key %q: %w", file.Path, err) + } + replaceFile(config, converted) + } + return nil +} + +func configField(contract schemaContract, key string, kind ConfigKind) (SchemaField, bool) { + if field, exists := contract.fields[key]; exists { + return field, field.Kind == kind + } + if contract.open[kind] { + return SchemaField{Key: key, Kind: kind}, true + } + return SchemaField{}, false +} + +func assignmentField(contract schemaContract, key string) (SchemaField, bool) { + if field, exists := contract.fields[key]; exists { + return field, true + } + kind := ConfigEnv + if strings.HasPrefix(key, "/") { + kind = ConfigFile + } + return configField(contract, key, kind) +} + +func (ref *documentRef) clientRef() *connectivityclient.KeyRef { + if ref == nil { + return nil + } + return &connectivityclient.KeyRef{Name: ref.Name, Key: ref.Key} +} + +func validateSources(value *string, secretRef, configMapRef *connectivityclient.KeyRef) error { + count := 0 + if value != nil { + count++ + } + if secretRef != nil { + count++ + if secretRef.Name == "" || secretRef.Key == "" { + return fmt.Errorf("secret reference requires name and key") + } + } + if configMapRef != nil { + count++ + if configMapRef.Name == "" || configMapRef.Key == "" { + return fmt.Errorf("configmap reference requires name and key") + } + } + if count != 1 { + return fmt.Errorf("exactly one of value, secretRef, or configMapRef is required") + } + return nil +} + +func applyValue(config *connectivityclient.ConnectorInstanceConfig, field SchemaField, value parsedValue) { + if field.Kind == ConfigEnv { + config.Env[field.Key] = connectivityclient.EnvValue{ + Value: value.value, + SecretRef: value.secretRef, + ConfigMapRef: value.configMapRef, + } + return + } + replaceFile(config, connectivityclient.FileMount{ + Path: field.Key, + Value: value.value, + SecretRef: value.secretRef, + ConfigMapRef: value.configMapRef, + }) +} + +func replaceFile(config *connectivityclient.ConnectorInstanceConfig, replacement connectivityclient.FileMount) { + for index := range config.Files { + if config.Files[index].Path == replacement.Path { + if replacement.Mode == nil { + replacement.Mode = cloneMode(config.Files[index].Mode) + } + config.Files[index] = replacement + return + } + } + config.Files = append(config.Files, replacement) +} + +func hasValue(config *connectivityclient.ConnectorInstanceConfig, field SchemaField) bool { + if field.Kind == ConfigEnv { + value, exists := config.Env[field.Key] + return exists && validateSources(value.Value, value.SecretRef, value.ConfigMapRef) == nil + } + for _, file := range config.Files { + if file.Path == field.Key { + return validateSources(file.Value, file.SecretRef, file.ConfigMapRef) == nil + } + } + return false +} + +func cloneConfig(source *connectivityclient.ConnectorInstanceConfig) *connectivityclient.ConnectorInstanceConfig { + clone := &connectivityclient.ConnectorInstanceConfig{Env: make(map[string]connectivityclient.EnvValue)} + if source == nil { + return clone + } + for key, value := range source.Env { + clone.Env[key] = cloneEnvValue(value) + } + clone.Files = make([]connectivityclient.FileMount, len(source.Files)) + for index, file := range source.Files { + clone.Files[index] = cloneFileMount(file) + } + return clone +} + +func cloneEnvValue(value connectivityclient.EnvValue) connectivityclient.EnvValue { + return connectivityclient.EnvValue{ + Value: cloneString(value.Value), + SecretRef: cloneRef(value.SecretRef), + ConfigMapRef: cloneRef(value.ConfigMapRef), + } +} + +func cloneFileMount(file connectivityclient.FileMount) connectivityclient.FileMount { + clone := connectivityclient.FileMount{ + Path: file.Path, + Value: cloneString(file.Value), + SecretRef: cloneRef(file.SecretRef), + ConfigMapRef: cloneRef(file.ConfigMapRef), + } + clone.Mode = cloneMode(file.Mode) + return clone +} + +func cloneMode(value *int32) *int32 { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + return stringPointer(*value) +} + +func cloneRef(ref *connectivityclient.KeyRef) *connectivityclient.KeyRef { + if ref == nil { + return nil + } + return &connectivityclient.KeyRef{Name: ref.Name, Key: ref.Key} +} + +func readInput(cmd *cobra.Command, read ReadFileFunc, path string) (string, error) { + if read == nil { + return "", fmt.Errorf("file reader is required") + } + return read(cmd, path) +} + +func requiredSet(raw any) (map[string]bool, error) { + set := make(map[string]bool) + if raw == nil { + return set, nil + } + switch values := raw.(type) { + case []any: + for _, rawValue := range values { + value, ok := rawValue.(string) + if !ok { + return nil, fmt.Errorf("required entries must be strings") + } + set[value] = true + } + case []string: + for _, value := range values { + set[value] = true + } + default: + return nil, fmt.Errorf("required must be an array") + } + return set, nil +} + +func stringMap(raw any) (map[string]any, bool) { + value, ok := raw.(map[string]any) + return value, ok +} + +func scalarString(raw any) (string, error) { + switch value := raw.(type) { + case string: + return value, nil + case nil: + return "", nil + case bool, int, int64, uint64, float64: + return fmt.Sprint(value), nil + default: + return "", fmt.Errorf("flat configuration values must be scalars") + } +} + +func stringPointer(value string) *string { + return &value +} + +func sortedKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/connectivity/connectorinstances/config_test.go b/cmd/connectivity/connectorinstances/config_test.go new file mode 100644 index 00000000..f22ed60b --- /dev/null +++ b/cmd/connectivity/connectorinstances/config_test.go @@ -0,0 +1,458 @@ +package connectorinstances + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestSchemaFieldsExtractsEnvironmentAndFileMetadata(t *testing.T) { + fields, err := SchemaFields(versionWithFullSchema()) + + require.NoError(t, err) + require.Equal(t, SchemaField{ + Key: "TOKEN", + Kind: ConfigEnv, + Required: true, + Password: true, + Description: "Authentication token", + }, fields["TOKEN"]) + require.Equal(t, SchemaField{ + Key: "/etc/plugin/config.yaml", + Kind: ConfigFile, + Required: true, + Description: "Connector configuration", + }, fields["/etc/plugin/config.yaml"]) + require.False(t, fields["TIMEOUT"].Required) + require.True(t, fields["/etc/plugin/ca.pem"].Password) +} + +func TestSchemaFieldsRejectsAmbiguousAndMalformedSchemas(t *testing.T) { + tests := []struct { + name string + schema map[string]any + want string + }{ + { + name: "malformed env section", + schema: map[string]any{"env": "not-an-object"}, + want: "env", + }, + { + name: "duplicate env and file key", + schema: map[string]any{ + "env": map[string]any{"properties": map[string]any{"SAME": map[string]any{}}}, + "files": map[string]any{"properties": map[string]any{"SAME": map[string]any{}}}, + }, + want: "SAME", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: tt.schema} + + _, err := SchemaFields(version) + + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestSchemaFieldsSupportsLegacyFlatSchemaAndSecretMarker(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "type": "object", + "required": []any{"LEGACY_TOKEN"}, + "properties": map[string]any{ + "LEGACY_TOKEN": map[string]any{ + "type": "string", + "x-secret": true, + "description": "Legacy token", + }, + }, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.Equal(t, SchemaField{ + Key: "LEGACY_TOKEN", + Kind: ConfigEnv, + Required: true, + Password: true, + Description: "Legacy token", + }, fields["LEGACY_TOKEN"]) +} + +func TestSchemaFieldsDoesNotTreatMixedSectionAndLegacyShapesAsLegacy(t *testing.T) { + for _, schema := range []map[string]any{ + { + "env": map[string]any{"properties": map[string]any{"SECTION": map[string]any{}}}, + "properties": map[string]any{"LEGACY": map[string]any{}}, + }, + { + "files": map[string]any{"properties": map[string]any{"/etc/section": map[string]any{}}}, + "properties": map[string]any{"LEGACY": map[string]any{}}, + }, + } { + fields, err := SchemaFields(&connectivityclient.ConnectorVersion{ConfigSchema: schema}) + + require.NoError(t, err) + require.NotContains(t, fields, "LEGACY") + } +} + +func TestSchemaFieldsCollectsLocalReferencesAndCompositionsWithoutOverstatingRequiredKeys(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "$defs": map[string]any{ + "token": map[string]any{"type": "string", "format": "password", "description": "API token"}, + }, + "allOf": []any{ + map[string]any{"properties": map[string]any{"TOKEN": map[string]any{"$ref": "#/$defs/token"}}, "required": []any{"TOKEN"}}, + map[string]any{"anyOf": []any{ + map[string]any{"properties": map[string]any{"CLIENT_ID": map[string]any{"type": "string"}}, "required": []any{"CLIENT_ID"}}, + map[string]any{"properties": map[string]any{"CLIENT_SECRET": map[string]any{"type": "string"}}, "required": []any{"CLIENT_SECRET"}}, + }}, + }, + }, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.True(t, fields["TOKEN"].Required) + require.True(t, fields["TOKEN"].Password) + require.Equal(t, "API token", fields["TOKEN"].Description) + require.False(t, fields["CLIENT_ID"].Required, "either alternative can satisfy the schema") + require.False(t, fields["CLIENT_SECRET"].Required, "either alternative can satisfy the schema") +} + +func TestSchemaFieldsFollowsAnchoredDynamicAndRecursiveReferences(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "$defs": map[string]any{ + "secret": map[string]any{"$dynamicAnchor": "secret", "format": "password", "description": "Anchored secret"}, + }, + "properties": map[string]any{ + "TOKEN": map[string]any{"$dynamicRef": "#secret"}, + "LOOP": map[string]any{"$recursiveRef": "#"}, + }, + }, + }} + + fields, err := SchemaFields(version) + + require.NoError(t, err) + require.True(t, fields["TOKEN"].Password) + require.Equal(t, "Anchored secret", fields["TOKEN"].Description) + require.Contains(t, fields, "LOOP", "recursive schemas are accepted and delegated to API validation") +} + +func TestSchemaFieldsBoundsMetadataTraversalDepth(t *testing.T) { + field := any(map[string]any{"format": "password"}) + for index := 0; index < maxSchemaCollectionDepth+1; index++ { + field = map[string]any{"allOf": []any{field}} + } + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{"properties": map[string]any{"TOKEN": field}}, + }} + + _, err := SchemaFields(version) + + require.ErrorContains(t, err, "schema nesting exceeds") +} + +func TestBuildInstallConfigDefersOpenAndPatternSchemaKeysToTheAPI(t *testing.T) { + version := &connectivityclient.ConnectorVersion{ConfigSchema: map[string]any{ + "env": map[string]any{ + "type": "object", + "properties": map[string]any{"KNOWN": map[string]any{"type": "string"}}, + "patternProperties": map[string]any{"^DYNAMIC_": map[string]any{"type": "string"}}, + }, + }} + + config, err := BuildInstallConfig(&cobra.Command{}, version, InputOptions{SetValues: []string{"DYNAMIC_TOKEN=value"}}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "value", *config.Env["DYNAMIC_TOKEN"].Value) +} + +func TestBuildInstallConfigAllowsConnectorVersionWithoutSchema(t *testing.T) { + version := &connectivityclient.ConnectorVersion{Version: "2.0.0", Image: "example/connector:2.0.0"} + + got, err := BuildInstallConfig(&cobra.Command{}, version, InputOptions{}, mapReadFile(nil)) + + require.NoError(t, err) + require.Empty(t, got.Env) + require.Empty(t, got.Files) +} + +func TestBuildInstallConfigParsesFlatYAMLAsEnvironmentValues(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": "API_URL: https://config.example\nTOKEN: config-token\nTIMEOUT: 45s\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://config.example", *got.Env["API_URL"].Value) + require.Equal(t, "config-token", *got.Env["TOKEN"].Value) + require.Equal(t, "45s", *got.Env["TIMEOUT"].Value) +} + +func TestBuildInstallConfigParsesStructuredYAML(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": `env: + API_URL: + value: https://structured.example + TOKEN: + secretRef: + name: connector-secrets + key: token +files: + - path: /etc/plugin/config.yaml + configMapRef: + name: connector-config + key: config.yaml + - path: /etc/plugin/ca.pem + value: structured-ca +`, + })) + + require.NoError(t, err) + require.Equal(t, "https://structured.example", *got.Env["API_URL"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Nil(t, got.Env["TOKEN"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Equal(t, "structured-ca", *got.Files[1].Value) +} + +func TestBuildInstallConfigParsesDotenvFilesInOrder(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"first.env", "second.env"}}, mapReadFile(map[string]string{ + "first.env": "# comment\n\nAPI_URL=https://first.example\nTOKEN=first=token\n", + "second.env": "API_URL=https://second.example\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://second.example", *got.Env["API_URL"].Value) + require.Equal(t, "first=token", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigParsesEstablishedDotenvSyntax(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": ` export API_URL = https://env.example # endpoint +TOKEN="line-one\nline-two\t\"quoted\"\\tail" +TIMEOUT=' literal # value ' +`, + })) + + require.NoError(t, err) + require.Equal(t, "https://env.example", *got.Env["API_URL"].Value) + require.Equal(t, "line-one\nline-two\t\"quoted\"\\tail", *got.Env["TOKEN"].Value) + require.Equal(t, " literal # value ", *got.Env["TIMEOUT"].Value) +} + +func TestBuildInstallConfigUnquotesDotenvValueBeforeTrailingComment(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": "TOKEN=\"abc#def\" # trailing comment\n", + })) + + require.NoError(t, err) + require.Equal(t, "abc#def", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigPreservesHashWithinUnquotedDotenvValue(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"values.env"}}, mapReadFile(map[string]string{ + "values.env": "TOKEN=abc#def\n", + })) + + require.NoError(t, err) + require.Equal(t, "abc#def", *got.Env["TOKEN"].Value) +} + +func TestBuildInstallConfigAppliesDocumentedSourcePrecedence(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ + ConfigFile: "config.yaml", + EnvFiles: []string{"first.env", "second.env"}, + SetValues: []string{"API_URL=https://set-one.example", "API_URL=https://set-two.example"}, + }, mapReadFile(map[string]string{ + "config.yaml": "API_URL: https://config.example\n", + "first.env": "API_URL=https://env-one.example\n", + "second.env": "API_URL=https://env-two.example\n", + })) + + require.NoError(t, err) + require.Equal(t, "https://set-two.example", *got.Env["API_URL"].Value) + require.NotContains(t, got.Env, "TOKEN", "no source supplied TOKEN") +} + +func TestBuildInstallConfigReadsInlineFileAndStdinSetValues(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ + "TIMEOUT=90s", + "TOKEN=@token.txt", + "/etc/plugin/config.yaml=@-", + }}, mapReadFile(map[string]string{ + "token.txt": "file-token\n", + "-": "stdin-config\n", + })) + + require.NoError(t, err) + require.Equal(t, "90s", *got.Env["TIMEOUT"].Value) + require.Equal(t, "file-token\n", *got.Env["TOKEN"].Value) + require.Equal(t, "stdin-config\n", *got.Files[0].Value) +} + +func TestBuildInstallConfigBuildsSecretAndConfigMapReferences(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ + "TOKEN=secret://connector-secrets/token", + "/etc/plugin/config.yaml=configmap://connector-config/config.yaml", + }}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}, got.Env["TOKEN"].SecretRef) + require.Nil(t, got.Env["TOKEN"].Value) + require.Equal(t, &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, got.Files[0].ConfigMapRef) + require.Nil(t, got.Files[0].Value) +} + +func TestBuildInstallConfigRoutesAssignmentsUsingSchema(t *testing.T) { + got, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ + "API_URL=https://routed.example", + "/etc/plugin/ca.pem=routed-ca", + }}, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "https://routed.example", *got.Env["API_URL"].Value) + require.Equal(t, "/etc/plugin/ca.pem", got.Files[0].Path) + require.Equal(t, "routed-ca", *got.Files[0].Value) +} + +func TestBuildInstallConfigReportsSortedMissingRequiredKeys(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithFullSchema(), InputOptions{}, mapReadFile(nil)) + + require.EqualError(t, err, "missing required configuration keys: /etc/plugin/config.yaml, API_URL, TOKEN") +} + +func TestBuildInstallConfigReportsSortedUnknownKeys(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{ + "WRONG=value", + "BOGUS=value", + }}, mapReadFile(nil)) + + require.EqualError(t, err, "unknown configuration keys: BOGUS, WRONG") +} + +func TestBuildInstallConfigRejectsMalformedAssignmentsAndReferences(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "assignment without equals", value: "TOKEN", want: "KEY=value"}, + {name: "empty key", value: "=value", want: "empty key"}, + {name: "secret without key", value: "TOKEN=secret://name", want: "secret reference"}, + {name: "secret without name", value: "TOKEN=secret:///key", want: "secret reference"}, + {name: "configmap without key", value: "TOKEN=configmap://name/", want: "configmap reference"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{SetValues: []string{tt.value}}, mapReadFile(nil)) + + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestBuildInstallConfigMalformedSetErrorDoesNotEchoSecretAssignment(t *testing.T) { + const secretSentinel = "SECRET-SENTINEL-MUST-NOT-LEAK" + tests := []string{ + "TOKEN-" + secretSentinel, + "=" + secretSentinel, + } + + for _, assignment := range tests { + _, err := BuildInstallConfig( + &cobra.Command{}, + versionWithOptionalSchema(), + InputOptions{SetValues: []string{assignment}}, + mapReadFile(nil), + ) + + require.Error(t, err) + require.NotContains(t, err.Error(), assignment) + require.NotContains(t, err.Error(), secretSentinel) + } +} + +func TestBuildInstallConfigReturnsInputReadAndParseErrors(t *testing.T) { + t.Run("unreadable config", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "missing.yaml"}, mapReadFile(nil)) + + require.ErrorContains(t, err, "missing.yaml") + }) + + t.Run("malformed config", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{"config.yaml": "env: ["})) + + require.ErrorContains(t, err, "config.yaml") + }) + + t.Run("malformed dotenv", func(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{EnvFiles: []string{"bad.env"}}, mapReadFile(map[string]string{"bad.env": "TOKEN\n"})) + + require.ErrorContains(t, err, "bad.env:1") + }) +} + +func TestBuildConfigureConfigPreservesUntouchedFiles(t *testing.T) { + mode := int32(0o600) + current := &connectivityclient.ConnectorInstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("a"), Mode: &mode}, + {Path: "/etc/b", Value: stringPtr("b")}, + }} + got, err := BuildConfigureConfig(&cobra.Command{}, versionWithFileSchema(), current, + InputOptions{SetValues: []string{"/etc/a=@new"}}, + func(_ *cobra.Command, name string) (string, error) { + require.Equal(t, "new", name) + return "changed", nil + }) + + require.NoError(t, err) + require.Len(t, got.Files, 2) + require.Equal(t, "changed", *got.Files[0].Value) + require.NotNil(t, got.Files[0].Mode) + require.Equal(t, int32(0o600), *got.Files[0].Mode) + require.Equal(t, "b", *got.Files[1].Value) +} + +func TestBuildInstallConfigRejectsStructuredFileWithoutPath(t *testing.T) { + _, err := BuildInstallConfig(&cobra.Command{}, versionWithOptionalSchema(), InputOptions{ConfigFile: "config.yaml"}, mapReadFile(map[string]string{ + "config.yaml": "files:\n - value: missing-path\n", + })) + + require.ErrorContains(t, err, "file path is required") +} + +func TestBuildConfigureConfigDeepCopiesCurrentConfiguration(t *testing.T) { + current := &connectivityclient.ConnectorInstanceConfig{ + Env: map[string]connectivityclient.EnvValue{ + "API_URL": {Value: stringPtr("https://current.example")}, + }, + Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("current-a")}, + }, + } + + got, err := BuildConfigureConfig(&cobra.Command{}, versionWithFileSchema(), current, InputOptions{ + SetValues: []string{"API_URL=https://changed.example", "/etc/a=changed-a"}, + }, mapReadFile(nil)) + + require.NoError(t, err) + require.Equal(t, "https://changed.example", *got.Env["API_URL"].Value) + require.Equal(t, "changed-a", *got.Files[0].Value) + require.Equal(t, "https://current.example", *current.Env["API_URL"].Value) + require.Equal(t, "current-a", *current.Files[0].Value) +} diff --git a/cmd/connectivity/connectorinstances/configure.go b/cmd/connectivity/connectorinstances/configure.go new file mode 100644 index 00000000..d788d806 --- /dev/null +++ b/cmd/connectivity/connectorinstances/configure.go @@ -0,0 +1,216 @@ +package connectorinstances + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ConfigureStore struct { + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` +} + +type ConfigureController struct { + factory connectivityinternal.ClientFactory + read ReadFileFunc + approve approvalFunc + store *ConfigureStore +} + +var _ fctl.Controller[*ConfigureStore] = (*ConfigureController)(nil) + +func NewConfigureController(factory connectivityinternal.ClientFactory, read ReadFileFunc) *ConfigureController { + return &ConfigureController{ + factory: factory, + read: read, + approve: fctl.CheckStackApprobation, + store: &ConfigureStore{}, + } +} + +func NewConfigureCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { + controller := NewConfigureController(factory, read) + command := fctl.NewCommand( + "configure ", + fctl.WithAliases("config", "update", "c"), + fctl.WithShortDescription("Configure a Connectivity connector instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), + fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), + fctl.WithStringFlag(versionFlag, "", "Connector version"), + fctl.WithStringFlag(channelFlag, "", "Maturity channel to track (stable, rc, beta, alpha) when no version is pinned"), + fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), + fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), + fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), + fctl.WithStringArrayFlag(setFlag, nil, "Configuration value KEY=VALUE (repeatable)"), + fctl.WithConfirmFlag(), + fctl.WithController[*ConfigureStore](controller), + ) + if err := command.RegisterFlagCompletionFunc(versionFlag, completeVersions(factory, resolveConfigureConnector)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(channelFlag, connectivityinternal.CompleteChannels(factory)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveConfigureConnectorVersion, paths)); err != nil { + panic(err) + } + return command +} + +func resolveConfigureConnector(ctx context.Context, client connectivityclient.Client, _ *cobra.Command, args []string) (string, error) { + if len(args) == 0 { + return "", nil + } + instance, err := client.GetConnectorInstance(ctx, args[0]) + if err != nil || instance == nil { + return "", err + } + return instance.Spec.Connector, nil +} + +func resolveConfigureConnectorVersion(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.ConnectorVersion, error) { + if len(args) == 0 { + return nil, nil + } + instance, err := client.GetConnectorInstance(ctx, args[0]) + if err != nil || instance == nil { + return nil, err + } + pinned, channel := configureVersionSelector(cmd, instance) + if channel != "" { + return resolveChannelVersion(ctx, client, instance.Spec.Connector, channel, appliedChannelFloor(instance)) + } + return resolveConnectorVersion(ctx, client, instance.Spec.Connector, pinned) +} + +func configureVersionSelector(cmd *cobra.Command, instance *connectivityclient.ConnectorInstance) (string, string) { + pinned, channel := "", "" + if instance != nil && instance.Spec.Version != nil && *instance.Spec.Version != "" { + pinned = *instance.Spec.Version + } + if instance != nil && instance.Spec.Channel != nil && *instance.Spec.Channel != "" { + channel = *instance.Spec.Channel + } + // Match the API's post-merge-patch normalization. Empty selector values are + // removed, rather than selecting stable. A non-empty channel is the only + // channel change that clears an existing pin. + if cmd.Flags().Changed(versionFlag) { + pinned = fctl.GetString(cmd, versionFlag) + } + if cmd.Flags().Changed(channelFlag) { + channel = fctl.GetString(cmd, channelFlag) + if channel != "" && !cmd.Flags().Changed(versionFlag) { + pinned = "" + } + } + if pinned != "" { + return pinned, "" + } + if channel != "" { + return "", channel + } + if applied := appliedChannelFloor(instance); applied != "" { + return applied, "" + } + return "stable", "" +} + +func (c *ConfigureController) GetStore() *ConfigureStore { + return c.store +} + +func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + + name := args[0] + instance, err := client.GetConnectorInstance(cmd.Context(), name) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("configure connectivity connector instance %q: empty response", name) + } + + specPatch := map[string]any{} + configChanged := cmd.Flags().Changed(configFlag) || cmd.Flags().Changed(envFileFlag) || cmd.Flags().Changed(setFlag) + if configChanged { + pinned, channel := configureVersionSelector(cmd, instance) + var version *connectivityclient.ConnectorVersion + if channel != "" { + version, err = resolveChannelVersion(cmd.Context(), client, instance.Spec.Connector, channel, appliedChannelFloor(instance)) + } else { + version, err = resolveConnectorVersion(cmd.Context(), client, instance.Spec.Connector, pinned) + } + if err != nil { + return nil, err + } + envFiles, err := cmd.Flags().GetStringArray(envFileFlag) + if err != nil { + return nil, err + } + setValues, err := cmd.Flags().GetStringArray(setFlag) + if err != nil { + return nil, err + } + config, err := BuildConfigureConfig(cmd, version, instance.Spec.Config, InputOptions{ + ConfigFile: fctl.GetString(cmd, configFlag), + EnvFiles: envFiles, + SetValues: setValues, + }, c.read) + if err != nil { + return nil, err + } + specPatch["config"] = config + } + if cmd.Flags().Changed(versionFlag) { + specPatch["version"] = fctl.GetString(cmd, versionFlag) + } + if cmd.Flags().Changed(channelFlag) { + channel := fctl.GetString(cmd, channelFlag) + specPatch["channel"] = channel + if channel != "" && !cmd.Flags().Changed(versionFlag) { + // A pre-existing pin would otherwise continue to win over the new + // channel. JSON null deletes it under the API's merge-patch contract. + specPatch["version"] = nil + } + } + if cmd.Flags().Changed(ledgerFlag) { + specPatch["ledger"] = fctl.GetString(cmd, ledgerFlag) + } + if cmd.Flags().Changed(pollIntervalFlag) { + specPatch["pollInterval"] = fctl.GetString(cmd, pollIntervalFlag) + } + if len(specPatch) == 0 { + return nil, fmt.Errorf("no configuration changes requested") + } + if !c.approve(cmd, "You are about to configure Connectivity connector instance %q", name) { + return nil, fctl.ErrMissingApproval + } + + updated, err := client.PatchConnectorInstance(cmd.Context(), name, connectivityclient.ConnectorInstancePatch{"spec": specPatch}) + if err != nil { + return nil, err + } + if updated == nil { + return nil, fmt.Errorf("configure connectivity connector instance %q: empty response", name) + } + c.store.ConnectorInstance = *updated + return c, nil +} + +func (c *ConfigureController) Render(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q configured.\n", stringValue(c.store.ConnectorInstance.Metadata.Name)) + return err +} diff --git a/cmd/connectivity/connectorinstances/configure_test.go b/cmd/connectivity/connectorinstances/configure_test.go new file mode 100644 index 00000000..7e205cf1 --- /dev/null +++ b/cmd/connectivity/connectorinstances/configure_test.go @@ -0,0 +1,634 @@ +package connectorinstances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type configureClientMock struct { + connectorVersions + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) + getInstance func(context.Context, string) (*connectivityclient.ConnectorInstance, error) + patch func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) +} + +func (m configureClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m configureClientMock) GetConnectorInstance(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { + return m.getInstance(ctx, name) +} + +func (m configureClientMock) PatchConnectorInstance(ctx context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return m.patch(ctx, name, patch) +} + +func TestConfigureBuildsNestedPatchFromCurrentConfigAndChangedScalars(t *testing.T) { + mode := int32(384) + current := instanceFixture("stripe-eu") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{ + Env: map[string]connectivityclient.EnvValue{"API_URL": {Value: stringPtr("https://current.example")}}, + Files: []connectivityclient.FileMount{ + {Path: "/etc/a", Value: stringPtr("old"), Mode: &mode}, + {Path: "/etc/b", SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "b"}}, + }, + } + returned := instanceFixture("stripe-eu") + var order []string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(_ context.Context, name string) (*connectivityclient.ConnectorInstance, error) { + order = append(order, "instance:"+name) + return ¤t, nil + }, + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + order = append(order, "version:"+connector+"@"+version) + return versionWithFileSchema(), nil + }, + }, + patch: func(_ context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + order = append(order, "patch:"+name) + gotPatch = patch + return &returned, nil + }, + } + + output, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(map[string]string{"new": "changed"}), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=@new", "--version=3.0.0", "--ledger=archive", + "--poll-interval=15s", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, []string{"instance:stripe-eu", "version:stripe@3.0.0", "patch:stripe-eu"}, order, + "the requested version supplies the schema the new configuration is validated against") + spec, ok := gotPatch["spec"].(map[string]any) + require.True(t, ok) + require.Equal(t, "3.0.0", spec["version"]) + require.Equal(t, "archive", spec["ledger"]) + require.Equal(t, "15s", spec["pollInterval"]) + require.Len(t, spec, 4, "the immutable connector and unchanged fields must not be patched") + config, ok := spec["config"].(*connectivityclient.ConnectorInstanceConfig) + require.True(t, ok) + require.Equal(t, "https://current.example", *config.Env["API_URL"].Value, "the current configuration is the patch base") + require.Len(t, config.Files, 2) + require.Equal(t, "/etc/a", config.Files[0].Path) + require.Equal(t, "changed", *config.Files[0].Value) + require.Equal(t, mode, *config.Files[0].Mode) + require.Equal(t, current.Spec.Config.Files[1], config.Files[1], "untouched file mounts must be preserved") + require.Equal(t, `Connector instance "stripe-eu" configured.`, strings.TrimSpace(output)) +} + +func TestConfigureWithoutVersionFlagUsesTheInstancePin(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("1.4.2") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "1.4.2", gotVersion) +} + +func TestConfigureFallsBackToTheAppliedVersionWhenUnpinned(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Status.ResolvedVersion = stringPtr("1.9.9") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "1.9.9", gotVersion) +} + +func TestConfigureFallsBackToLegacyAppliedVersionWhenNoSelectorRemains(t *testing.T) { + for _, resolvedRef := range []*string{nil, stringPtr("")} { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = nil + current.Status.ResolvedConnectorRef = resolvedRef + current.Status.ResolvedVersion = stringPtr("1.9.9") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "1.9.9", gotVersion) + } +} + +func TestConfigureChannelTracksTheResolvedSameMajorHeadAcrossPages(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.0.0") + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedVersion = stringPtr("v1.0.0") + current.Status.ResolvedConnectorRef = stringPtr("stripe") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(_ context.Context, _ string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + switch options.Cursor { + case "": + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "v1.1.0", Image: "example:v1.1.0"}, + {Version: "v1.2.0-beta.1", Image: "example:v1.2.0-beta.1"}, + }, HasMore: true, Next: "second"}, nil + case "second": + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "v1.3.0", Image: "example:v1.3.0"}, + {Version: "v2.0.0", Image: "example:v2.0.0"}, + }}, nil + default: + t.Fatalf("unexpected version cursor %q", options.Cursor) + return nil, nil + } + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand( + NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=stable", "--set=/etc/a=kept", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "v1.3.0", gotVersion, "the server selects the highest stable candidate in the running major") + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "stable", spec["channel"]) + require.Contains(t, spec, "version", "switching to a channel removes an existing pin") + require.Nil(t, spec["version"]) +} + +func TestConfigureChannelUsesAppliedVersionOnlyForTheResolvedConnector(t *testing.T) { + for _, tt := range []struct { + name string + resolvedRef *string + wantErr string + wantVersion string + wantPatched bool + }{ + {name: "matching resolved connector prevents downgrade", resolvedRef: stringPtr("stripe"), wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "empty resolved connector retains legacy floor", resolvedRef: stringPtr(""), wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "absent resolved connector retains legacy floor", resolvedRef: nil, wantErr: "no candidate version satisfies channel", wantPatched: false}, + {name: "different resolved connector does not constrain channel", resolvedRef: stringPtr("other"), wantVersion: "v1.1.0", wantPatched: true}, + } { + t.Run(tt.name, func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.0.0") + current.Status.ResolvedVersion = stringPtr("v1.2.0") + current.Status.ResolvedConnectorRef = tt.resolvedRef + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + patched := false + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{{Version: "v1.1.0"}}}, nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patched = true + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=stable", "--set=/etc/a=kept", "--confirm") + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantVersion, gotVersion) + require.Equal(t, tt.wantPatched, patched) + }) + } +} + +func TestConfigureEmptySelectorsUseTheNormalizedPostPatchSelectorForConfig(t *testing.T) { + t.Run("empty channel keeps an existing version pin", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = stringPtr("v1.4.2") + current.Spec.Channel = stringPtr("stable") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "v1.4.2", gotVersion) + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "", spec["channel"]) + require.NotContains(t, spec, "version") + }) + + t.Run("empty version retains an existing channel", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedConnectorRef = stringPtr("stripe") + current.Status.ResolvedVersion = stringPtr("v1.0.0") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{{Version: "v1.1.0"}}}, nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }, + }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--version=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "v1.1.0", gotVersion) + spec := gotPatch["spec"].(map[string]any) + require.Equal(t, "", spec["version"]) + require.NotContains(t, spec, "channel") + }) + + t.Run("empty channel does not reuse an applied version for another connector", func(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Version = nil + current.Spec.Channel = stringPtr("stable") + current.Status.ResolvedConnectorRef = stringPtr("other") + current.Status.ResolvedVersion = stringPtr("v9.0.0") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + var gotVersion string + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithFileSchema(), nil + }}, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return ¤t, nil + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe-eu", "--channel=", "--set=/etc/a=kept", "--confirm") + + require.NoError(t, err) + require.Equal(t, "stable", gotVersion) + }) +} + +func TestConfigureOmitsUnchangedScalarsAndStoresCompleteReturnedInstance(t *testing.T) { + current := instanceFixture("stripe-eu") + current.Spec.Config = &connectivityclient.ConnectorInstanceConfig{} + returned := instanceFixture("stripe-eu") + returned.Metadata.Labels = map[string]string{"region": "eu"} + var gotPatch connectivityclient.ConnectorInstancePatch + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(_ context.Context, _ string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + gotPatch = patch + return &returned, nil + }, + } + command := NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "stripe-eu", "--ledger=archive", "--confirm", "--output=json") + + require.NoError(t, err) + require.Equal(t, connectivityclient.ConnectorInstancePatch{"spec": map[string]any{"ledger": "archive"}}, gotPatch) + var envelope struct { + Data ConfigureStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, returned, envelope.Data.ConnectorInstance) +} + +func TestConfigureRejectsNoChangesWithoutPatch(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patched = true + return nil, errors.New("PatchConnectorInstance must not be called") + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--confirm") + + require.EqualError(t, err, "no configuration changes requested") + require.False(t, patched) +} + +func TestConfigureConfirmationRejectionPreventsPatch(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patched = true + return nil, errors.New("PatchConnectorInstance must not be called") + }, + } + controller := NewConfigureController(factoryReturning(client), mockReadFile(nil)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + command := NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + require.NoError(t, command.ParseFlags([]string{"--ledger=archive"})) + + _, err := controller.Run(command, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, patched) +} + +func TestConfigureValidationAndArgumentsPreventPatch(t *testing.T) { + t.Run("invalid configuration", func(t *testing.T) { + current := instanceFixture("stripe-eu") + patched := false + client := configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return versionWithFileSchema(), nil + }, + }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patched = true + return nil, errors.New("PatchConnectorInstance must not be called") + }, + } + + _, err := executeCommand(NewConfigureCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe-eu", "--set=UNKNOWN=value", "--confirm") + + require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") + require.False(t, patched) + }) + + t.Run("invalid arguments", func(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + command := NewConfigureCommand(factory, mockReadFile(nil), mockPathCompleter(nil)) + + _, err := executeCommand(command, "stripe-eu", "extra", "--ledger=archive", "--confirm") + + require.Error(t, err) + require.False(t, usedFactory) + }) +} + +func TestConfigurePreservesGetAndPatchAPIErrorsAndRejectsEmptyResponses(t *testing.T) { + instanceError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORINSTANCE_NOT_FOUND", Message: "missing"} + versionError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORVERSION_NOT_FOUND", Message: "missing"} + patchError := &connectivityclient.APIError{StatusCode: 409, Code: "CONFLICT", Message: "stale"} + current := instanceFixture("stripe-eu") + tests := map[string]struct { + client configureClientMock + args []string + want error + text string + }{ + "instance API": { + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { + return nil, instanceError + }}, + want: instanceError, + }, + "empty instance": { + client: configureClientMock{getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return nil, nil }}, + text: "empty response", + }, + "version API": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, versionError + }, + }, + }, + args: []string{"--set=API_URL=https://example"}, + want: versionError, + }, + "empty version": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, nil }, + }, + }, + args: []string{"--set=API_URL=https://example"}, + text: "empty response", + }, + "patch API": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return nil, patchError + }, + }, + want: patchError, + }, + "empty patch": { + client: configureClientMock{ + getInstance: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { return ¤t, nil }, + patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return nil, nil + }, + }, + text: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + args := append([]string{"stripe-eu", "--ledger=archive", "--confirm"}, test.args...) + _, err := executeCommand(NewConfigureCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), args...) + require.Error(t, err) + if test.want != nil { + require.ErrorIs(t, err, test.want) + } + if test.text != "" { + require.ErrorContains(t, err, test.text) + } + }) + } +} + +func TestConfigureRegistersAliasesRootAndCompletions(t *testing.T) { + current := instanceFixture("stripe-eu") + var pathPrefix string + client := configureClientMock{ + listInstances: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{current}}, nil + }, + getInstance: func(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe-eu", name) + return ¤t, nil + }, + connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionListFixture(), nil + }, + getVersion: func(ctx context.Context, name, _ string) (*connectivityclient.ConnectorVersion, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionWithFileSchema(), nil + }, + }, + } + factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { + require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) + return client, nil + } + paths := func(prefix string) ([]string, error) { + pathPrefix = prefix + return []string{"fixtures/token.txt"}, nil + } + command := NewConfigureCommand(factory, mockReadFile(nil), paths) + + require.Equal(t, "configure ", command.Use) + require.Equal(t, []string{"config", "update", "c"}, command.Aliases) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + instances, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe · main"}, instances) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + versionCompletion, ok := command.GetFlagCompletionFunc(versionFlag) + require.True(t, ok) + versions, directive := versionCompletion(command, []string{"stripe-eu"}, "2") + require.Equal(t, []string{"2.0.0\texample/connector:2.0.0"}, versions) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + setCompletion, ok := command.GetFlagCompletionFunc(setFlag) + require.True(t, ok) + sets, directive := setCompletion(command, []string{"stripe-eu"}, "/etc") + require.Equal(t, []string{"/etc/a=\tPrimary config", "/etc/b=\tfile configuration"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) + sets, directive = setCompletion(command, []string{"stripe-eu"}, "/etc/a=@fixtures/t") + require.Equal(t, "fixtures/t", pathPrefix) + require.Equal(t, []string{"/etc/a=@fixtures/token.txt"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + _, configRegistered := command.GetFlagCompletionFunc(configFlag) + _, envFileRegistered := command.GetFlagCompletionFunc(envFileFlag) + require.False(t, configRegistered) + require.False(t, envFileRegistered) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"configure"}) + require.NoError(t, err) + require.Equal(t, "configure", child.Name()) + require.True(t, reflect.DeepEqual([]string{"config", "update", "c"}, child.Aliases)) +} + +func TestConfigureDoesNotExposeUnsupportedStartSequence(t *testing.T) { + command := NewConfigureCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + + require.Nil(t, command.Flags().Lookup("start-sequence")) +} diff --git a/cmd/connectivity/connectorinstances/install.go b/cmd/connectivity/connectorinstances/install.go new file mode 100644 index 00000000..7ae8ab99 --- /dev/null +++ b/cmd/connectivity/connectorinstances/install.go @@ -0,0 +1,209 @@ +package connectorinstances + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const ( + nameFlag = "name" + ledgerFlag = "ledger" + versionFlag = "version" + channelFlag = "channel" + pollIntervalFlag = "poll-interval" + configFlag = "config" + envFileFlag = "env-file" + setFlag = "set" +) + +type InstallStore struct { + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` +} + +type approvalFunc func(*cobra.Command, string, ...any) bool + +type InstallController struct { + factory connectivityinternal.ClientFactory + read ReadFileFunc + approve approvalFunc + store *InstallStore +} + +var _ fctl.Controller[*InstallStore] = (*InstallController)(nil) + +func NewInstallController(factory connectivityinternal.ClientFactory, read ReadFileFunc) *InstallController { + return &InstallController{ + factory: factory, + read: read, + approve: fctl.CheckStackApprobation, + store: &InstallStore{}, + } +} + +func NewInstallCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { + controller := NewInstallController(factory, read) + command := fctl.NewCommand( + "install ", + fctl.WithAliases("create", "in"), + fctl.WithShortDescription("Install a Connectivity connector instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(connectors.CompleteConnectorNames(factory)), + fctl.WithStringFlag(nameFlag, "", "Connector instance name (defaults to the connector name)"), + fctl.WithStringFlag(ledgerFlag, "", "Ledger name"), + fctl.WithStringFlag(versionFlag, "", "Connector version"), + fctl.WithStringFlag(channelFlag, "", "Maturity channel to track (stable, rc, beta, alpha) when no version is pinned"), + fctl.WithStringFlag(pollIntervalFlag, "", "Polling interval"), + fctl.WithStringFlag(configFlag, "", "YAML or JSON configuration file"), + fctl.WithStringArrayFlag(envFileFlag, nil, "Dotenv configuration file (repeatable)"), + fctl.WithStringArrayFlag(setFlag, nil, "Configuration value KEY=VALUE (repeatable)"), + fctl.WithConfirmFlag(), + fctl.WithController[*InstallStore](controller), + ) + if err := command.MarkFlagRequired(ledgerFlag); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(versionFlag, CompleteVersions(factory, installConnectorArgument)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(channelFlag, connectivityinternal.CompleteChannels(factory)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc(setFlag, CompleteSetValues(factory, resolveInstallConnectorVersion, paths)); err != nil { + panic(err) + } + return command +} + +func installConnectorArgument(_ *cobra.Command, args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +func resolveInstallConnectorVersion(ctx context.Context, client connectivityclient.Client, cmd *cobra.Command, args []string) (*connectivityclient.ConnectorVersion, error) { + if len(args) == 0 { + return nil, nil + } + return resolveConnectorVersion(ctx, client, args[0], installVersionSelector(cmd)) +} + +// installVersionSelector picks the {version} slot value governing the config +// schema: the explicit pin, else the tracked channel alias (resolved with +// exactly the rules installation uses), else `stable` for the API default. +func installVersionSelector(cmd *cobra.Command) string { + if pinned := fctl.GetString(cmd, versionFlag); pinned != "" { + return pinned + } + if channel := fctl.GetString(cmd, channelFlag); channel != "" { + return channel + } + return "stable" +} + +func (c *InstallController) GetStore() *InstallStore { + return c.store +} + +func (c *InstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + + connectorName := args[0] + version, err := resolveConnectorVersion(cmd.Context(), client, connectorName, installVersionSelector(cmd)) + if err != nil { + return nil, err + } + + envFiles, err := cmd.Flags().GetStringArray(envFileFlag) + if err != nil { + return nil, err + } + setValues, err := cmd.Flags().GetStringArray(setFlag) + if err != nil { + return nil, err + } + config, err := BuildInstallConfig(cmd, version, InputOptions{ + ConfigFile: fctl.GetString(cmd, configFlag), + EnvFiles: envFiles, + SetValues: setValues, + }, c.read) + if err != nil { + return nil, err + } + if !configHasData(config) { + config = nil + } + + name := fctl.GetString(cmd, nameFlag) + if name == "" { + name = connectorName + } + spec := connectivityclient.ConnectorInstanceSpec{ + Connector: connectorName, + Ledger: fctl.GetString(cmd, ledgerFlag), + Config: config, + } + if cmd.Flags().Changed(versionFlag) { + value, err := cmd.Flags().GetString(versionFlag) + if err != nil { + return nil, err + } + spec.Version = fctl.Ptr(value) + } + if cmd.Flags().Changed(channelFlag) { + value, err := cmd.Flags().GetString(channelFlag) + if err != nil { + return nil, err + } + spec.Channel = fctl.Ptr(value) + } + if cmd.Flags().Changed(pollIntervalFlag) { + value, err := cmd.Flags().GetString(pollIntervalFlag) + if err != nil { + return nil, err + } + spec.PollInterval = fctl.Ptr(value) + } + + if !c.approve(cmd, "You are about to install Connectivity connector instance %q", name) { + return nil, fctl.ErrMissingApproval + } + instance, err := client.CreateConnectorInstance(cmd.Context(), connectivityclient.ConnectorInstanceCreate{Name: name, Spec: spec}) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("install connectivity connector instance %q: empty response", name) + } + c.store.ConnectorInstance = *instance + return c, nil +} + +func configHasData(config *connectivityclient.ConnectorInstanceConfig) bool { + return config != nil && (len(config.Env) > 0 || len(config.Files) > 0) +} + +func (c *InstallController) Render(cmd *cobra.Command, _ []string) error { + instance := c.store.ConnectorInstance + _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Connector instance %q installed with connector %q for ledger %q.\n", + stringValue(instance.Metadata.Name), + instance.Spec.Connector, + instance.Spec.Ledger, + ) + return err +} diff --git a/cmd/connectivity/connectorinstances/install_test.go b/cmd/connectivity/connectorinstances/install_test.go new file mode 100644 index 00000000..20574eb8 --- /dev/null +++ b/cmd/connectivity/connectorinstances/install_test.go @@ -0,0 +1,400 @@ +package connectorinstances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type mutationClientMock struct { + connectorVersions + listConnectors func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + capabilities func(context.Context) (*connectivityclient.QueryCapabilities, error) + create func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) +} + +func (m mutationClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.listConnectors(ctx, options) +} + +func (m mutationClientMock) GetQueryCapabilities(ctx context.Context) (*connectivityclient.QueryCapabilities, error) { + return m.capabilities(ctx) +} + +func (m mutationClientMock) CreateConnectorInstance(ctx context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return m.create(ctx, body) +} + +func TestInstallBuildsConnectorInstanceFromVersionSchemaAndOnlySetsChangedScalars(t *testing.T) { + var gotConnector, gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + gotConnector, gotVersion = connector, version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + read := mockReadFile(map[string]string{ + "install.yaml": "env:\n API_URL:\n value: https://config.example\n", + "connector.env": "TIMEOUT=45s\n", + "token.txt": "file-token", + }) + + output, err := executeCommand( + NewInstallCommand(factoryReturning(client), read, mockPathCompleter(nil)), + "stripe", "--name=stripe-eu", "--ledger=main", "--version=2.0.0", + "--poll-interval=3s", "--config=install.yaml", + "--env-file=connector.env", "--set=TOKEN=@token.txt", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "stripe", gotConnector) + require.Equal(t, "2.0.0", gotVersion, "the pinned version supplies the config schema") + require.Equal(t, "stripe-eu", gotBody.Name) + require.Equal(t, "stripe", gotBody.Spec.Connector) + require.Equal(t, "main", gotBody.Spec.Ledger) + require.Equal(t, "2.0.0", *gotBody.Spec.Version) + require.Equal(t, "3s", *gotBody.Spec.PollInterval) + require.Equal(t, "https://config.example", *gotBody.Spec.Config.Env["API_URL"].Value) + require.Equal(t, "file-token", *gotBody.Spec.Config.Env["TOKEN"].Value) + require.Equal(t, "45s", *gotBody.Spec.Config.Env["TIMEOUT"].Value) + require.Empty(t, gotBody.Spec.Config.Files) + require.Equal(t, `Connector instance "stripe-eu" installed with connector "stripe" for ledger "main".`, strings.TrimSpace(output)) +} + +func TestInstallWithoutSelectorUsesStableHeadForTheSchema(t *testing.T) { + var gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--set=API_URL=https://example", "--confirm", + ) + + require.NoError(t, err) + // A newer prerelease must not supply the schema for a selector-less + // install: the API persists the stable head in that case. + require.Equal(t, "stable", gotVersion) + require.Nil(t, gotBody.Spec.Version, "an unpinned install must let the server resolve the version") +} + +func TestInstallWithChannelFlagTracksTheChannelAndUsesItsHeadForTheSchema(t *testing.T) { + var gotVersion string + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return versionWithOptionalSchema(), nil + }, + }, + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--channel=stable", "--confirm", + ) + + require.NoError(t, err) + require.Equal(t, "stable", gotVersion, "the channel alias resolves the schema exactly as installation will") + require.NotNil(t, gotBody.Spec.Channel) + require.Equal(t, "stable", *gotBody.Spec.Channel) + require.Nil(t, gotBody.Spec.Version) +} + +func TestInstallReportsPrereleaseOnlyCataloguesAsMissingStableVersion(t *testing.T) { + client := mutationClientMock{connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, &connectivityclient.APIError{StatusCode: 404, Code: "channel_empty", Message: "only v2.0.0-beta.1 is published"} + }, + }} + + _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") + + require.ErrorContains(t, err, `connectivity connector "stripe" has no published version`) +} + +func TestInstallDefaultsNameToConnectorAndOmitsUnsetOptionalData(t *testing.T) { + var gotBody connectivityclient.ConnectorInstanceCreate + client := mutationClientMock{ + create: func(_ context.Context, body connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + gotBody = body + return &connectivityclient.ConnectorInstance{Metadata: connectivityclient.ObjectMeta{Name: stringPtr(body.Name)}, Spec: body.Spec}, nil + }, + } + + _, err := executeCommand(NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), "wise", "--ledger=main", "--confirm") + + require.NoError(t, err) + require.Equal(t, "wise", gotBody.Name) + require.Nil(t, gotBody.Spec.Version) + require.Nil(t, gotBody.Spec.Channel) + require.Nil(t, gotBody.Spec.StartSequence) + require.Nil(t, gotBody.Spec.PollInterval) + require.Nil(t, gotBody.Spec.Config) +} + +func TestInstallRequiresLedgerBeforeUsingClient(t *testing.T) { + usedClient := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedClient = true + return nil, errors.New("client must not be used") + } + + _, err := executeCommand(NewInstallCommand(factory, mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--confirm") + + require.ErrorContains(t, err, `required flag(s) "ledger" not set`) + require.False(t, usedClient) +} + +func TestInstallConfirmationRejectionPreventsCreate(t *testing.T) { + created := false + client := mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + created = true + return nil, errors.New("CreateConnectorInstance must not be called") + }, + } + controller := NewInstallController(factoryReturning(client), mockReadFile(nil)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + require.NoError(t, command.ParseFlags([]string{"--ledger=main"})) + + _, err := controller.Run(command, []string{"stripe"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, created) +} + +func TestInstallValidationErrorPreventsCreate(t *testing.T) { + created := false + client := mutationClientMock{ + connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return versionWithOptionalSchema(), nil + }, + }, + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + created = true + return nil, errors.New("CreateConnectorInstance must not be called") + }, + } + + _, err := executeCommand( + NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)), + "stripe", "--ledger=main", "--set=UNKNOWN=value", "--confirm", + ) + + require.ErrorContains(t, err, "unknown configuration keys: UNKNOWN") + require.False(t, created) +} + +func TestInstallPreservesVersionAndCreateAPIErrorsAndRejectsEmptyResponses(t *testing.T) { + versionError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTOR_NOT_FOUND", Message: "missing"} + createError := &connectivityclient.APIError{StatusCode: 409, Code: "CONNECTORINSTANCE_EXISTS", Message: "duplicate"} + tests := map[string]struct { + client mutationClientMock + want error + text string + }{ + "version API": { + client: mutationClientMock{connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, versionError + }, + }}, + want: versionError, + }, + "empty version": { + client: mutationClientMock{connectorVersions: connectorVersions{ + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { return nil, nil }, + }}, + text: "empty response", + }, + "create API": { + client: mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return nil, createError + }, + }, + want: createError, + }, + "empty create": { + client: mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return nil, nil + }, + }, + text: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewInstallCommand(factoryReturning(test.client), mockReadFile(nil), mockPathCompleter(nil)), "stripe", "--ledger=main", "--confirm") + require.Error(t, err) + if test.want != nil { + require.ErrorIs(t, err, test.want) + } + if test.text != "" { + require.ErrorContains(t, err, test.text) + } + }) + } +} + +func TestInstallJSONStoresCompleteReturnedConnectorInstance(t *testing.T) { + returned := instanceFixture("stripe-eu") + returned.Metadata.Labels = map[string]string{"region": "eu"} + returned.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "TOKEN": {Value: stringPtr("preserved-in-json")}, + }} + client := mutationClientMock{ + create: func(context.Context, connectivityclient.ConnectorInstanceCreate) (*connectivityclient.ConnectorInstance, error) { + return &returned, nil + }, + } + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "stripe", "--ledger=main", "--confirm", "--output=json") + + require.NoError(t, err) + var envelope struct { + Data InstallStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, returned, envelope.Data.ConnectorInstance) +} + +func TestInstallRegistersAliasesAndRootIntegration(t *testing.T) { + standalone := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + require.Equal(t, "install ", standalone.Use) + require.Equal(t, []string{"create", "in"}, standalone.Aliases) + require.Error(t, standalone.Args(standalone, nil)) + require.NoError(t, standalone.Args(standalone, []string{"stripe"})) + require.Error(t, standalone.Args(standalone, []string{"stripe", "extra"})) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"install"}) + require.NoError(t, err) + require.Equal(t, "install", child.Name()) + require.True(t, reflect.DeepEqual([]string{"create", "in"}, child.Aliases)) +} + +func TestInstallWiresChannelCompletionFromCapabilities(t *testing.T) { + client := mutationClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return &connectivityclient.QueryCapabilities{Resources: map[string]map[string]connectivityclient.QueryFieldCapability{ + connectivityclient.ResourceConnectorInstances: { + "channel": {Operators: []string{"$match"}, Enum: []string{"stable", "rc", "beta", "alpha"}}, + }, + }}, nil + }} + command := NewInstallCommand(factoryReturning(client), mockReadFile(nil), mockPathCompleter(nil)) + + channelCompletion, ok := command.GetFlagCompletionFunc("channel") + require.True(t, ok) + channels, directive := channelCompletion(command, nil, "s") + require.Equal(t, []string{"stable"}, channels) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) +} + +func TestInstallDoesNotExposeUnsupportedStartSequence(t *testing.T) { + command := NewInstallCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + + require.Nil(t, command.Flags().Lookup("start-sequence")) +} + +func TestInstallWiresConnectorVersionSetAndFileCompletions(t *testing.T) { + connector := connectivityclient.Connector{ + Metadata: connectivityclient.ObjectMeta{Name: stringPtr("stripe")}, + Spec: connectivityclient.ConnectorSpec{Description: stringPtr("Stripe ingestion")}, + } + client := mutationClientMock{ + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}}, nil + }, + connectorVersions: connectorVersions{ + listVersions: func(ctx context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + require.Equal(t, int32(100), options.PageSize) + return versionListFixture(), nil + }, + getVersion: func(ctx context.Context, name, _ string) (*connectivityclient.ConnectorVersion, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, "stripe", name) + return versionWithFullSchema(), nil + }, + }, + } + var pathPrefix string + paths := func(prefix string) ([]string, error) { + pathPrefix = prefix + return []string{"fixtures/token.txt"}, nil + } + factory := func(cmd *cobra.Command) (connectivityclient.Client, error) { + require.True(t, connectivityinternal.IsNonInteractive(cmd.Context())) + return client, nil + } + command := NewInstallCommand(factory, mockReadFile(nil), paths) + + connectors, directive := command.ValidArgsFunction(command, nil, "str") + require.Equal(t, []string{"stripe\tStripe ingestion"}, connectors) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + versionCompletion, ok := command.GetFlagCompletionFunc("version") + require.True(t, ok) + versions, directive := versionCompletion(command, []string{"stripe"}, "2") + require.Equal(t, []string{"2.0.0\texample/connector:2.0.0"}, versions) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + setCompletion, ok := command.GetFlagCompletionFunc("set") + require.True(t, ok) + sets, directive := setCompletion(command, []string{"stripe"}, "API") + require.Equal(t, []string{"API_URL=\tAPI endpoint"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace, directive) + + sets, directive = setCompletion(command, []string{"stripe"}, "TOKEN=@fixtures/t") + require.Equal(t, "fixtures/t", pathPrefix) + require.Equal(t, []string{"TOKEN=@fixtures/token.txt"}, sets) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + _, configRegistered := command.GetFlagCompletionFunc("config") + _, envFileRegistered := command.GetFlagCompletionFunc("env-file") + require.False(t, configRegistered, "--config must retain Cobra's normal file completion") + require.False(t, envFileRegistered, "--env-file must retain Cobra's normal file completion") +} diff --git a/cmd/connectivity/connectorinstances/list.go b/cmd/connectivity/connectorinstances/list.go new file mode 100644 index 00000000..9d74f3cc --- /dev/null +++ b/cmd/connectivity/connectorinstances/list.go @@ -0,0 +1,172 @@ +package connectorinstances + +import ( + "fmt" + "strconv" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const connectorFlag = "connector" + +type ListStore struct { + ConnectorInstances []connectivityclient.ConnectorInstance `json:"connectorInstances"` + Cursor fctl.Cursor `json:"cursor"` +} + +type ListController struct { + factory connectivityinternal.ClientFactory + store *ListStore +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewListController(factory connectivityinternal.ClientFactory) *ListController { + return &ListController{ + factory: factory, + store: &ListStore{ConnectorInstances: []connectivityclient.ConnectorInstance{}}, + } +} + +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewListController(factory) + command := fctl.NewCommand( + "list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List Connectivity connector instances"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithStringFlag(connectorFlag, "", "Filter connector instances by connector"), + connectivityinternal.WithListQueryFlags(), + fctl.WithPageSizeFlag(), + fctl.WithCursorFlag(), + fctl.WithController[*ListStore](controller), + ) + if err := command.RegisterFlagCompletionFunc(connectorFlag, connectors.CompleteConnectorNames(factory)); err != nil { + panic(err) + } + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectorInstances), + ); err != nil { + panic(err) + } + return command +} + +func (c *ListController) GetStore() *ListStore { + return c.store +} + +func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + filters, err := cmd.Flags().GetStringArray(connectivityinternal.FilterFlag) + if err != nil { + return nil, err + } + // --connector is sugar for one more filter clause, so it composes with + // --filter and conflicts with --query exactly like the other filters. + if connector := fctl.GetString(cmd, connectorFlag); connector != "" { + filters = append(filters, connectorFlag+"="+connector) + } + query, err := connectivityinternal.BuildListQuery(fctl.GetString(cmd, connectivityinternal.QueryFlag), filters) + if err != nil { + return nil, err + } + + response, err := client.ListConnectorInstances(cmd.Context(), connectivityclient.ListOptions{ + Query: query, + PageSize: pageSize, + Cursor: cursor, + }) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("list connectivity connector instances: empty response") + } + + c.store.ConnectorInstances = response.Items + c.store.Cursor = fctl.Cursor{PageSize: int64(response.PageSize), HasMore: response.HasMore} + if response.Next != "" { + c.store.Cursor.Next = fctl.Ptr(response.Next) + } + return c, nil +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + rows := fctl.Map(c.store.ConnectorInstances, func(instance connectivityclient.ConnectorInstance) []string { + return []string{ + stringValue(instance.Metadata.Name), + instance.Spec.Connector, + stringValue(instance.Spec.Version), + stringValue(instance.Spec.Channel), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedVersion }), + instance.Spec.Ledger, + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase }), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.CurrentSequence }), + instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.SourceTipSequence }), + instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.LastError }), + } + }) + rows = fctl.Prepend(rows, []string{ + "Name", "Connector", "Version", "Channel", "Resolved Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + }) + if err := pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(rows). + Render(); err != nil { + return err + } + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func int64Value(value *int64) string { + if value == nil { + return "" + } + return strconv.FormatInt(*value, 10) +} + +func instanceStatusValue(status *connectivityclient.ConnectorInstanceStatus, field func(*connectivityclient.ConnectorInstanceStatus) *string) string { + if status == nil { + return "" + } + return stringValue(field(status)) +} + +func instanceStatusInt64(status *connectivityclient.ConnectorInstanceStatus, field func(*connectivityclient.ConnectorInstanceStatus) *int64) string { + if status == nil { + return "" + } + return int64Value(field(status)) +} diff --git a/cmd/connectivity/connectorinstances/list_test.go b/cmd/connectivity/connectorinstances/list_test.go new file mode 100644 index 00000000..75189140 --- /dev/null +++ b/cmd/connectivity/connectorinstances/list_test.go @@ -0,0 +1,186 @@ +package connectorinstances + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type listInstanceClientMock struct { + connectivityclient.Client + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) +} + +func (m listInstanceClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.list(ctx, options) +} + +func TestListConnectorInstancesPassesFiltersAndPaginationAndRendersApprovedColumns(t *testing.T) { + var gotOptions connectivityclient.ListOptions + channelInstance := instanceFixture("stripe-eu") + channelInstance.Spec.Version = nil + channelInstance.Spec.Channel = stringPtr("stable") + channelInstance.Status.ResolvedVersion = stringPtr("v1.3.0") + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + return &connectivityclient.ConnectorInstanceList{ + Items: []connectivityclient.ConnectorInstance{channelInstance}, + PageSize: 7, + HasMore: true, + Next: "next-page", + }, nil + }} + + output, err := executeCommand(NewListCommand(factoryReturning(client)), "--connector", "stripe", "--page-size", "7", "--cursor", "current-page") + + require.NoError(t, err) + wantOptions := connectivityclient.ListOptions{ + Query: `{"$match":{"connector":"stripe"}}`, + PageSize: 7, + Cursor: "current-page", + } + require.Equal(t, wantOptions, gotOptions) + for _, expected := range []string{ + "Name", "Connector", "Version", "Channel", "Resolved Version", "Ledger", "Phase", "State", "Current Sequence", "Source Tip Sequence", "Last Error", + "stripe-eu", "stripe", "stable", "v1.3.0", "main", "Ready", "Running", "42", "48", "source temporarily unavailable", + "HasMore", "true", "PageSize", "7", "Next", "next-page", + } { + require.Contains(t, output, expected) + } +} + +func TestListConnectorInstancesConjoinsConnectorAndFilterExpressions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{}}, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--filter", "phase=Running", "--connector", "stripe") + + require.NoError(t, err) + want := `{"$and":[{"$match":{"phase":"Running"}},{"$match":{"connector":"stripe"}}]}` + require.Equal(t, want, gotOptions.Query) +} + +func TestListConnectorInstancesPassesRawQueryThrough(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := listInstanceClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + gotOptions = options + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{}}, nil + }} + raw := `{"$exists":{"channel":true}}` + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", raw) + + require.NoError(t, err) + require.Equal(t, raw, gotOptions.Query) +} + +func TestListConnectorInstancesRejectsRawQueryCombinedWithSugarFlags(t *testing.T) { + client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + t.Fatal("ListConnectorInstances must not run with conflicting flags") + return nil, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", `{"$match":{"phase":"Running"}}`, "--connector", "stripe") + + require.Error(t, err) + require.Contains(t, err.Error(), "--query cannot be combined with --filter") +} + +func TestListConnectorInstancesWiresFilterAndConnectorCompletions(t *testing.T) { + command := NewListCommand(factoryReturning(listInstanceClientMock{})) + + _, filterRegistered := command.GetFlagCompletionFunc("filter") + require.True(t, filterRegistered, "--filter must complete from /_query/capabilities") + _, connectorRegistered := command.GetFlagCompletionFunc("connector") + require.True(t, connectorRegistered, "--connector must complete from the connector catalogue") +} + +func TestListConnectorInstancesJSONPreservesCompleteModelsAndContinuation(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Metadata.Labels = map[string]string{"region": "eu"} + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("json-keeps-full-model")}, + }} + client := listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}, PageSize: 4, HasMore: true, Next: "next-page"}, nil + }} + command := NewListCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "--output", "json", "--page-size", "4") + + require.NoError(t, err) + var envelope struct { + Data ListStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, []connectivityclient.ConnectorInstance{instance}, envelope.Data.ConnectorInstances) + require.True(t, envelope.Data.Cursor.HasMore) + require.Equal(t, int64(4), envelope.Data.Cursor.PageSize) + require.NotNil(t, envelope.Data.Cursor.Next) + require.Equal(t, "next-page", *envelope.Data.Cursor.Next) +} + +func TestConnectorInstanceRootRegistersReadCommandsAndAliases(t *testing.T) { + command := NewCommand(factoryReturning(listInstanceClientMock{}), mockReadFile(nil), mockPathCompleter(nil)) + + require.Equal(t, "connectorinstances", command.Use) + require.Equal(t, []string{"connectorinstance", "instances", "instance", "ci"}, command.Aliases) + wantAliases := map[string][]string{ + "list": {"ls", "l"}, + "show": {"get", "g", "sh", "s"}, + } + for name, aliases := range wantAliases { + child, _, err := command.Find([]string{name}) + require.NoError(t, err) + require.Equal(t, name, child.Name()) + require.True(t, reflect.DeepEqual(aliases, child.Aliases), "%s aliases = %v", name, child.Aliases) + } +} + +func TestListConnectorInstancesReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "missing factory": {factory: nil, want: "factory is required"}, + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return nil, errors.New("connector instances unavailable") + }}), + want: "connector instances unavailable", + }, + "empty response": { + factory: factoryReturning(listInstanceClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewListCommand(test.factory)) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), test.want), "error = %v", err) + }) + } +} diff --git a/cmd/connectivity/connectorinstances/root.go b/cmd/connectivity/connectorinstances/root.go new file mode 100644 index 00000000..72277219 --- /dev/null +++ b/cmd/connectivity/connectorinstances/root.go @@ -0,0 +1,25 @@ +package connectorinstances + +import ( + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand(factory connectivityinternal.ClientFactory, read ReadFileFunc, paths PathCompleter) *cobra.Command { + return fctl.NewCommand( + "connectorinstances", + fctl.WithAliases("connectorinstance", "instances", "instance", "ci"), + fctl.WithShortDescription("Manage Connectivity connector instances"), + fctl.WithChildCommands( + NewListCommand(factory), + NewShowCommand(factory), + NewInstallCommand(factory, read, paths), + NewConfigureCommand(factory, read, paths), + NewSuspendCommand(factory), + NewUnsuspendCommand(factory), + NewUninstallCommand(factory), + ), + ) +} diff --git a/cmd/connectivity/connectorinstances/show.go b/cmd/connectivity/connectorinstances/show.go new file mode 100644 index 00000000..f3af1860 --- /dev/null +++ b/cmd/connectivity/connectorinstances/show.go @@ -0,0 +1,220 @@ +package connectorinstances + +import ( + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ShowStore struct { + ConnectorInstance connectivityclient.ConnectorInstance `json:"connectorInstance"` +} + +type ShowController struct { + factory connectivityinternal.ClientFactory + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func NewShowController(factory connectivityinternal.ClientFactory) *ShowController { + return &ShowController{factory: factory, store: &ShowStore{}} +} + +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewShowController(factory) + return fctl.NewCommand( + "show ", + fctl.WithAliases("get", "g", "sh", "s"), + fctl.WithShortDescription("Show a Connectivity connector instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), + fctl.WithController[*ShowStore](controller), + ) +} + +func (c *ShowController) GetStore() *ShowStore { + return c.store +} + +func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + instance, err := client.GetConnectorInstance(cmd.Context(), args[0]) + if err != nil { + return nil, err + } + if instance == nil { + return nil, fmt.Errorf("show connectivity connector instance %q: empty response", args[0]) + } + c.store.ConnectorInstance = *instance + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + instance := c.store.ConnectorInstance + out := cmd.OutOrStdout() + + if err := renderInstanceSection(out, "Information", pterm.TableData{ + {pterm.LightCyan("Name"), stringValue(instance.Metadata.Name)}, + {pterm.LightCyan("Namespace"), stringValue(instance.Metadata.Namespace)}, + {pterm.LightCyan("UID"), stringValue(instance.Metadata.UID)}, + {pterm.LightCyan("Resource Version"), stringValue(instance.Metadata.ResourceVersion)}, + {pterm.LightCyan("Created At"), timeValue(instance.Metadata.CreationTimestamp)}, + {pterm.LightCyan("Labels"), mapValue(instance.Metadata.Labels)}, + {pterm.LightCyan("Annotations"), mapValue(instance.Metadata.Annotations)}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Desired Specification", pterm.TableData{ + {pterm.LightCyan("Connector"), instance.Spec.Connector}, + {pterm.LightCyan("Version"), stringValue(instance.Spec.Version)}, + {pterm.LightCyan("Channel"), stringValue(instance.Spec.Channel)}, + {pterm.LightCyan("Connectivity Reference"), stringValue(instance.Spec.ConnectivityRef)}, + {pterm.LightCyan("Ledger"), instance.Spec.Ledger}, + {pterm.LightCyan("Poll Interval"), stringValue(instance.Spec.PollInterval)}, + {pterm.LightCyan("Suspend"), boolValue(instance.Spec.Suspend)}, + {pterm.LightCyan("Replicas"), int32Value(instance.Spec.Replicas)}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Lifecycle", pterm.TableData{ + {pterm.LightCyan("Resolved Connector"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedConnectorRef })}, + {pterm.LightCyan("Resolved Version"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedVersion })}, + {pterm.LightCyan("Resolved Image"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedImage })}, + {pterm.LightCyan("Resolved Digest"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ResolvedDigest })}, + {pterm.LightCyan("Connector Address"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.ConnectorAddress })}, + {pterm.LightCyan("Phase"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Phase })}, + {pterm.LightCyan("State"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.State })}, + {pterm.LightCyan("Suspended by"), instanceSuspensionSources(instance.Status)}, + }); err != nil { + return err + } + if err := renderInstanceSection(out, "Ingestion Progress", pterm.TableData{ + {pterm.LightCyan("Current Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.CurrentSequence })}, + {pterm.LightCyan("Source Tip Sequence"), instanceStatusInt64(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *int64 { return status.SourceTipSequence })}, + {pterm.LightCyan("Last Error"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.LastError })}, + {pterm.LightCyan("Message"), instanceStatusValue(instance.Status, func(status *connectivityclient.ConnectorInstanceStatus) *string { return status.Message })}, + }); err != nil { + return err + } + + fctl.Section.WithWriter(out).Println("Configuration") + rows := configRows(instance.Spec.Config) + if len(rows) == 0 { + _, err := fmt.Fprintln(out, "No configuration entries.") + return err + } + rows = fctl.Prepend(rows, []string{"Key", "Kind", "Source", "Mode"}) + return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(rows).Render() +} + +func renderInstanceSection(out io.Writer, title string, data pterm.TableData) error { + fctl.Section.WithWriter(out).Println(title) + return pterm.DefaultTable.WithWriter(out).WithData(data).Render() +} + +func configRows(config *connectivityclient.ConnectorInstanceConfig) [][]string { + if config == nil { + return nil + } + rows := make([][]string, 0, len(config.Env)+len(config.Files)) + envKeys := make([]string, 0, len(config.Env)) + for key := range config.Env { + envKeys = append(envKeys, key) + } + sort.Strings(envKeys) + for _, key := range envKeys { + value := config.Env[key] + rows = append(rows, []string{key, "environment", configSource(value.Value, value.SecretRef, value.ConfigMapRef), ""}) + } + files := append([]connectivityclient.FileMount(nil), config.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + for _, file := range files { + mode := "" + if file.Mode != nil { + mode = strconv.FormatInt(int64(*file.Mode), 10) + } + rows = append(rows, []string{file.Path, "file", configSource(file.Value, file.SecretRef, file.ConfigMapRef), mode}) + } + return rows +} + +func configSource(value *string, secretRef, configMapRef *connectivityclient.KeyRef) string { + if secretRef != nil { + return "secret:" + secretRef.Name + "/" + secretRef.Key + } + if configMapRef != nil { + return "configmap:" + configMapRef.Name + "/" + configMapRef.Key + } + if value != nil { + return "inline" + } + return "" +} + +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} + +func mapValue(value map[string]string) string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, key+"="+value[key]) + } + return strings.Join(entries, ", ") +} + +func boolValue(value *bool) string { + if value == nil { + return "" + } + return strconv.FormatBool(*value) +} + +func int32Value(value *int32) string { + if value == nil { + return "" + } + return strconv.FormatInt(int64(*value), 10) +} + +func instanceSuspensionSources(status *connectivityclient.ConnectorInstanceStatus) string { + if status == nil { + return "" + } + // Connectivity orders instance fields first and Policies by priority/name. + // Preserve that API order because Policy priority is not part of this model. + entries := make([]string, 0, len(status.SuspendedBy)) + for _, source := range status.SuspendedBy { + entry := strings.Join(nonEmptyStrings(source.Kind, source.Name), "/") + if source.Field != "" { + entry += " (" + source.Field + ")" + } + entries = append(entries, entry) + } + return strings.Join(entries, ", ") +} diff --git a/cmd/connectivity/connectorinstances/show_test.go b/cmd/connectivity/connectorinstances/show_test.go new file mode 100644 index 00000000..53c82c9f --- /dev/null +++ b/cmd/connectivity/connectorinstances/show_test.go @@ -0,0 +1,177 @@ +package connectorinstances + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type showInstanceClientMock struct { + connectivityclient.Client + get func(context.Context, string) (*connectivityclient.ConnectorInstance, error) +} + +func (m showInstanceClientMock) GetConnectorInstance(ctx context.Context, name string) (*connectivityclient.ConnectorInstance, error) { + return m.get(ctx, name) +} + +func TestShowConnectorInstanceRendersMetadataDesiredStateLifecycleProgressAndRedactedConfigSources(t *testing.T) { + created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) + instance := instanceFixture("stripe-eu") + instance.Metadata.Namespace = stringPtr("formance") + instance.Metadata.ResourceVersion = stringPtr("42") + instance.Metadata.UID = stringPtr("instance-uid") + instance.Metadata.CreationTimestamp = &created + instance.Metadata.Labels = map[string]string{"region": "eu"} + instance.Metadata.Annotations = map[string]string{"owner": "platform"} + mode := int32(420) + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{ + Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("must-not-leak")}, + "TOKEN": {SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "token"}}, + }, + Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", ConfigMapRef: &connectivityclient.KeyRef{Name: "connector-config", Key: "config.yaml"}, Mode: &mode}, + {Path: "/etc/plugin/inline.pem", Value: stringPtr("also-must-not-leak")}, + }, + } + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + for _, expected := range []string{ + "Information", "Name", "stripe-eu", "Namespace", "formance", "UID", "instance-uid", "Resource Version", "42", + "Created At", created.Format(time.RFC3339), "Labels", "region=eu", "Annotations", "owner=platform", + "Desired Specification", "Connector", "stripe", "Version", "2.0.0", "Ledger", "main", "Poll Interval", "5s", + "Lifecycle", "Resolved Connector", "Resolved Version", "Resolved Image", "registry/connector:2.0.0", + "Resolved Digest", "sha256:deadbeef", "Connector Address", "http://stripe.default.svc", "Phase", "Ready", "State", "Running", + "Ingestion Progress", "Current Sequence", "42", "Source Tip Sequence", "48", "Last Error", "source temporarily unavailable", "Message", "retrying ingestion", + "Configuration", "API_KEY", "environment", "inline", "TOKEN", "secret:connector-secrets/token", + "/etc/plugin/config.yaml", "file", "configmap:connector-config/config.yaml", "420", "/etc/plugin/inline.pem", + } { + require.Contains(t, output, expected) + } + require.NotContains(t, output, "must-not-leak") + require.NotContains(t, output, "also-must-not-leak") +} + +func TestShowPlainOutputNeverPrintsInlineConfigValues(t *testing.T) { + instance := instanceWithTwoFiles() + instance.Spec.Config.Env = map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("must-not-leak")}, + } + command := NewShowCommand(factoryWithInstance(*instance)) + + output, err := executeCommand(command, "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "API_KEY") + require.Contains(t, output, "inline") + require.NotContains(t, output, "must-not-leak") + require.NotContains(t, output, "private config") +} + +func TestShowPlainOutputOmitsUnsupportedStartSequence(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.StartSequence = fctl.Ptr(int64(987654321)) + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.NotContains(t, output, "Start Sequence") + require.NotContains(t, output, "987654321") +} + +func TestShowConnectorInstanceJSONPreservesCompleteModel(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Env: map[string]connectivityclient.EnvValue{ + "API_KEY": {Value: stringPtr("json-keeps-full-model")}, + }} + command := NewShowCommand(factoryWithInstance(instance)) + command.Flags().String(fctl.OutputFlag, "plain", "") + + output, err := executeCommand(command, "--output", "json", "stripe-eu") + + require.NoError(t, err) + var envelope struct { + Data ShowStore `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Equal(t, instance, envelope.Data.ConnectorInstance) +} + +func TestShowConnectorInstanceHandlesMissingStatusAndConfig(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Status = nil + instance.Spec.Config = nil + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "No configuration entries.") +} + +func TestShowConnectorInstanceRendersDesiredSuspensionReplicasAndOrderedProvenance(t *testing.T) { + instance := instanceFixture("stripe-eu") + instance.Spec.Suspend = fctl.Ptr(true) + instance.Spec.Replicas = fctl.Ptr(int32(0)) + instance.Status.SuspendedBy = []connectivityclient.SuspensionSource{ + {Kind: "ConnectorInstance", Name: "stripe-eu", Field: "spec.suspend"}, + {Kind: "ConnectorInstance", Name: "stripe-eu", Field: "spec.replicas"}, + {Kind: "Policy", Name: "maintenance", Field: "spec.mutate.suspend"}, + } + + output, err := executeCommand(NewShowCommand(factoryWithInstance(instance)), "stripe-eu") + + require.NoError(t, err) + require.Contains(t, output, "Suspend") + require.Contains(t, output, "true") + require.Contains(t, output, "Replicas") + require.Contains(t, output, "0") + require.Contains(t, output, "Suspended by") + require.Contains(t, output, "ConnectorInstance/stripe-eu (spec.suspend), ConnectorInstance/stripe-eu (spec.replicas), Policy/maintenance (spec.mutate.suspend)") +} + +func TestShowConnectorInstanceReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "missing factory": {factory: nil, want: "factory is required"}, + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { + return nil, errors.New("connector instance unavailable") + }}), + want: "connector instance unavailable", + }, + "empty response": { + factory: factoryReturning(showInstanceClientMock{get: func(context.Context, string) (*connectivityclient.ConnectorInstance, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewShowCommand(test.factory), "stripe-eu") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), test.want), "error = %v", err) + }) + } +} diff --git a/cmd/connectivity/connectorinstances/suspend.go b/cmd/connectivity/connectorinstances/suspend.go new file mode 100644 index 00000000..08dc576e --- /dev/null +++ b/cmd/connectivity/connectorinstances/suspend.go @@ -0,0 +1,94 @@ +package connectorinstances + +import ( + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type SuspensionStore struct { + Name string `json:"name"` + Suspend bool `json:"suspend"` +} + +type SuspensionController struct { + factory connectivityinternal.ClientFactory + approve approvalFunc + store *SuspensionStore + suspend bool +} + +var _ fctl.Controller[*SuspensionStore] = (*SuspensionController)(nil) + +func NewSuspensionController(factory connectivityinternal.ClientFactory, suspend bool) *SuspensionController { + return &SuspensionController{ + factory: factory, + approve: fctl.CheckStackApprobation, + store: &SuspensionStore{}, + suspend: suspend, + } +} + +func NewSuspendCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return newSuspensionCommand(factory, "suspend", "Suspend a Connectivity connector instance", true) +} + +func NewUnsuspendCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return newSuspensionCommand(factory, "unsuspend", "Resume a Connectivity connector instance", false) +} + +func newSuspensionCommand(factory connectivityinternal.ClientFactory, name, description string, suspend bool) *cobra.Command { + controller := NewSuspensionController(factory, suspend) + return fctl.NewCommand( + name+" ", + fctl.WithShortDescription(description), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), + fctl.WithConfirmFlag(), + fctl.WithController[*SuspensionStore](controller), + ) +} + +func (c *SuspensionController) GetStore() *SuspensionStore { + return c.store +} + +func (c *SuspensionController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + name := args[0] + action := "suspend" + if !c.suspend { + action = "resume" + } + if !c.approve(cmd, "You are about to %s Connectivity connector instance %q", action, name) { + return nil, fctl.ErrMissingApproval + } + _, err = client.PatchConnectorInstance(cmd.Context(), name, connectivityclient.ConnectorInstancePatch{ + "spec": map[string]any{"suspend": c.suspend}, + }) + if err != nil { + return nil, err + } + c.store.Name = name + c.store.Suspend = c.suspend + return c, nil +} + +func (c *SuspensionController) Render(cmd *cobra.Command, _ []string) error { + request := "suspension" + if !c.store.Suspend { + request = "resumption" + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q %s requested.\n", c.store.Name, request) + return err +} diff --git a/cmd/connectivity/connectorinstances/suspend_test.go b/cmd/connectivity/connectorinstances/suspend_test.go new file mode 100644 index 00000000..b3882753 --- /dev/null +++ b/cmd/connectivity/connectorinstances/suspend_test.go @@ -0,0 +1,136 @@ +package connectorinstances + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type suspensionClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) + patch func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) +} + +func (m suspensionClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m suspensionClientMock) PatchConnectorInstance(ctx context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return m.patch(ctx, name, patch) +} + +func TestSuspensionCommandsPatchOnlyDesiredBooleanAndTreatAlreadyConvergedResponseAsSuccess(t *testing.T) { + tests := []struct { + name string + command func(connectivityinternal.ClientFactory) *cobra.Command + suspend bool + wantOutput string + }{ + {name: "suspend", command: NewSuspendCommand, suspend: true, wantOutput: `Connector instance "stripe-eu" suspension requested.`}, + {name: "unsuspend", command: NewUnsuspendCommand, suspend: false, wantOutput: `Connector instance "stripe-eu" resumption requested.`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + client := suspensionClientMock{patch: func(_ context.Context, name string, patch connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + calls++ + require.Equal(t, "stripe-eu", name) + require.Equal(t, connectivityclient.ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, patch) + instance := instanceFixture(name) + instance.Spec.Suspend = fctl.Ptr(test.suspend) + return &instance, nil + }} + + output, err := executeCommand(test.command(factoryReturning(client)), "stripe-eu", "--confirm") + + require.NoError(t, err) + require.Equal(t, 1, calls) + require.Equal(t, test.wantOutput, strings.TrimSpace(output)) + }) + } +} + +func TestSuspensionCommandConfirmationRejectionMakesNoPatch(t *testing.T) { + patchCalls := 0 + client := suspensionClientMock{patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + patchCalls++ + return nil, errors.New("PatchConnectorInstance must not be called") + }} + controller := NewSuspensionController(factoryReturning(client), true) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + + _, err := controller.Run(&cobra.Command{}, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.Zero(t, patchCalls) +} + +func TestSuspensionCommandsPreserveAPIError(t *testing.T) { + apiError := &connectivityclient.APIError{StatusCode: 409, Code: "CONFLICT", Message: "stale"} + client := suspensionClientMock{patch: func(context.Context, string, connectivityclient.ConnectorInstancePatch) (*connectivityclient.ConnectorInstance, error) { + return nil, apiError + }} + + for _, command := range []func(connectivityinternal.ClientFactory) *cobra.Command{NewSuspendCommand, NewUnsuspendCommand} { + _, err := executeCommand(command(factoryReturning(client)), "stripe-eu", "--confirm") + require.ErrorIs(t, err, apiError) + } +} + +func TestSuspensionCommandsRejectInvalidArgumentsBeforeUsingClient(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + + for _, command := range []func(connectivityinternal.ClientFactory) *cobra.Command{NewSuspendCommand, NewUnsuspendCommand} { + _, err := executeCommand(command(factory), "stripe-eu", "extra", "--confirm") + require.Error(t, err) + } + require.False(t, usedFactory) +} + +func TestSuspensionCommandsRegisterConfirmationCompletionAndRootChildren(t *testing.T) { + instance := instanceFixture("stripe-eu") + client := suspensionClientMock{listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}}, nil + }} + + for _, test := range []struct { + name string + command func(connectivityinternal.ClientFactory) *cobra.Command + }{ + {name: "suspend", command: NewSuspendCommand}, + {name: "unsuspend", command: NewUnsuspendCommand}, + } { + command := test.command(factoryReturning(client)) + require.Equal(t, test.name+" ", command.Use) + require.NotNil(t, command.Flags().Lookup("confirm")) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + candidates, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe · main"}, candidates) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + } + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + for _, name := range []string{"suspend", "unsuspend"} { + child, _, err := root.Find([]string{name}) + require.NoError(t, err) + require.Equal(t, name, child.Name()) + } +} diff --git a/cmd/connectivity/connectorinstances/test_helpers_test.go b/cmd/connectivity/connectorinstances/test_helpers_test.go new file mode 100644 index 00000000..8f183579 --- /dev/null +++ b/cmd/connectivity/connectorinstances/test_helpers_test.go @@ -0,0 +1,198 @@ +package connectorinstances + +import ( + "bytes" + "context" + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func stringPtr(value string) *string { + return &value +} + +// connectorVersions serves the catalog half of the client interface so command +// mocks only spell out the calls their own test exercises. The zero value +// publishes two versions whose newest carries no schema, which is what a test +// that does not care about configuration wants. +type connectorVersions struct { + connectivityclient.Client + listVersions func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) + getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) +} + +func (v connectorVersions) ListConnectorVersions(ctx context.Context, connector string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + if v.listVersions == nil { + return versionListFixture(), nil + } + return v.listVersions(ctx, connector, options) +} + +func (v connectorVersions) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + if v.getVersion == nil { + return &connectivityclient.ConnectorVersion{Version: version, Image: "example/connector:" + version}, nil + } + return v.getVersion(ctx, connector, version) +} + +func versionListFixture() *connectivityclient.ConnectorVersionList { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "1.0.0", Image: "example/connector:1.0.0"}, + {Version: "2.0.0", Image: "example/connector:2.0.0"}, + }} +} + +func instanceFixture(name string) connectivityclient.ConnectorInstance { + return connectivityclient.ConnectorInstance{ + Metadata: connectivityclient.ObjectMeta{Name: stringPtr(name)}, + Spec: connectivityclient.ConnectorInstanceSpec{ + Connector: "stripe", + Version: stringPtr("2.0.0"), + Ledger: "main", + PollInterval: stringPtr("5s"), + StartSequence: fctl.Ptr(int64(10)), + }, + Status: &connectivityclient.ConnectorInstanceStatus{ + Phase: stringPtr("Ready"), + State: stringPtr("Running"), + ResolvedImage: stringPtr("registry/connector:2.0.0"), + ResolvedConnectorRef: stringPtr("stripe"), + ResolvedVersion: stringPtr("2.0.0"), + ResolvedDigest: stringPtr("sha256:deadbeef"), + ConnectorAddress: stringPtr("http://stripe.default.svc"), + CurrentSequence: fctl.Ptr(int64(42)), + SourceTipSequence: fctl.Ptr(int64(48)), + LastError: stringPtr("source temporarily unavailable"), + Message: stringPtr("retrying ingestion"), + }, + } +} + +func instanceWithTwoFiles() *connectivityclient.ConnectorInstance { + instance := instanceFixture("stripe-eu") + instance.Spec.Config = &connectivityclient.ConnectorInstanceConfig{Files: []connectivityclient.FileMount{ + {Path: "/etc/plugin/config.yaml", Value: stringPtr("private config")}, + {Path: "/etc/plugin/ca.pem", SecretRef: &connectivityclient.KeyRef{Name: "connector-secrets", Key: "ca.pem"}}, + }} + return &instance +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func factoryWithInstance(instance connectivityclient.ConnectorInstance) connectivityinternal.ClientFactory { + return factoryReturning(showInstanceClientMock{get: func(_ context.Context, _ string) (*connectivityclient.ConnectorInstance, error) { + return &instance, nil + }}) +} + +func mockReadFile(files map[string]string) ReadFileFunc { + return func(_ *cobra.Command, name string) (string, error) { + value, ok := files[name] + if !ok { + return "", fmt.Errorf("test file %q not found", name) + } + return value, nil + } +} + +func mapReadFile(files map[string]string) ReadFileFunc { + return mockReadFile(files) +} + +func mockPathCompleter(paths []string) PathCompleter { + return func(string) ([]string, error) { + return paths, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} + +// versionWithFileSchema declares one optional environment key and two file +// keys, the first of them required. +func versionWithFileSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + }, + "required": []any{}, + }, + "files": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "/etc/a": map[string]any{"type": "string", "description": "Primary config"}, + "/etc/b": map[string]any{"type": "string", "format": "password"}, + }, + "required": []any{"/etc/a"}, + }, + }, + } +} + +// versionWithFullSchema is the reference schema: two required environment keys, +// one optional, one required file and one optional password file. +func versionWithFullSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: fullConfigSchema([]any{"API_URL", "TOKEN"}, []any{"/etc/plugin/config.yaml"}), + } +} + +// versionWithOptionalSchema exposes the same keys as versionWithFullSchema with +// nothing required, so input-parsing tests need not satisfy every key. +func versionWithOptionalSchema() *connectivityclient.ConnectorVersion { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "example/connector:2.0.0", + ConfigSchema: fullConfigSchema([]any{}, []any{}), + } +} + +func fullConfigSchema(requiredEnv, requiredFiles []any) map[string]any { + return map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "API_URL": map[string]any{"type": "string", "description": "API endpoint"}, + "TOKEN": map[string]any{"type": "string", "format": "password", "description": "Authentication token"}, + "TIMEOUT": map[string]any{"type": "string"}, + }, + "required": requiredEnv, + }, + "files": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "/etc/plugin/config.yaml": map[string]any{"type": "string", "description": "Connector configuration"}, + "/etc/plugin/ca.pem": map[string]any{"type": "string", "format": "password"}, + }, + "required": requiredFiles, + }, + } +} diff --git a/cmd/connectivity/connectorinstances/uninstall.go b/cmd/connectivity/connectorinstances/uninstall.go new file mode 100644 index 00000000..46823f1a --- /dev/null +++ b/cmd/connectivity/connectorinstances/uninstall.go @@ -0,0 +1,71 @@ +package connectorinstances + +import ( + "fmt" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type UninstallStore struct { + Name string `json:"name"` +} + +type UninstallController struct { + factory connectivityinternal.ClientFactory + approve approvalFunc + store *UninstallStore +} + +var _ fctl.Controller[*UninstallStore] = (*UninstallController)(nil) + +func NewUninstallController(factory connectivityinternal.ClientFactory) *UninstallController { + return &UninstallController{ + factory: factory, + approve: fctl.CheckStackApprobation, + store: &UninstallStore{}, + } +} + +func NewUninstallCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewUninstallController(factory) + return fctl.NewCommand( + "uninstall ", + fctl.WithAliases("delete", "remove", "rm", "u"), + fctl.WithShortDescription("Uninstall a Connectivity connector instance"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorInstanceNames(factory)), + fctl.WithConfirmFlag(), + fctl.WithController[*UninstallStore](controller), + ) +} + +func (c *UninstallController) GetStore() *UninstallStore { + return c.store +} + +func (c *UninstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + name := args[0] + if !c.approve(cmd, "You are about to uninstall Connectivity connector instance %q", name) { + return nil, fctl.ErrMissingApproval + } + if err := client.DeleteConnectorInstance(cmd.Context(), name); err != nil { + return nil, err + } + c.store.Name = name + return c, nil +} + +func (c *UninstallController) Render(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Connector instance %q uninstalled.\n", c.store.Name) + return err +} diff --git a/cmd/connectivity/connectorinstances/uninstall_test.go b/cmd/connectivity/connectorinstances/uninstall_test.go new file mode 100644 index 00000000..9a0dd79b --- /dev/null +++ b/cmd/connectivity/connectorinstances/uninstall_test.go @@ -0,0 +1,108 @@ +package connectorinstances + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type uninstallClientMock struct { + connectivityclient.Client + listInstances func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) + delete func(context.Context, string) error +} + +func (m uninstallClientMock) ListConnectorInstances(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + return m.listInstances(ctx, options) +} + +func (m uninstallClientMock) DeleteConnectorInstance(ctx context.Context, name string) error { + return m.delete(ctx, name) +} + +func TestUninstallDeletesConfirmedConnectorInstanceAndRendersSuccess(t *testing.T) { + var deletedName string + client := uninstallClientMock{delete: func(_ context.Context, name string) error { + deletedName = name + return nil + }} + + output, err := executeCommand(NewUninstallCommand(factoryReturning(client)), "stripe-eu", "--confirm") + + require.NoError(t, err) + require.Equal(t, "stripe-eu", deletedName) + require.Equal(t, `Connector instance "stripe-eu" uninstalled.`, strings.TrimSpace(output)) +} + +func TestUninstallConfirmationRejectionPreventsDelete(t *testing.T) { + deleted := false + client := uninstallClientMock{delete: func(context.Context, string) error { + deleted = true + return errors.New("DeleteConnectorInstance must not be called") + }} + controller := NewUninstallController(factoryReturning(client)) + controller.approve = func(*cobra.Command, string, ...any) bool { return false } + + _, err := controller.Run(&cobra.Command{}, []string{"stripe-eu"}) + + require.ErrorIs(t, err, fctl.ErrMissingApproval) + require.False(t, deleted) +} + +func TestUninstallPreservesDeleteAPIError(t *testing.T) { + apiError := &connectivityclient.APIError{StatusCode: 404, Code: "CONNECTORINSTANCE_NOT_FOUND", Message: "missing"} + client := uninstallClientMock{delete: func(context.Context, string) error { return apiError }} + + _, err := executeCommand(NewUninstallCommand(factoryReturning(client)), "stripe-eu", "--confirm") + + require.ErrorIs(t, err, apiError) +} + +func TestUninstallRegistersAliasesRootAndConnectorInstanceCompletion(t *testing.T) { + instance := instanceFixture("stripe-eu") + client := uninstallClientMock{ + listInstances: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorInstanceList, error) { + require.True(t, connectivityinternal.IsNonInteractive(ctx)) + require.Equal(t, connectivityclient.ListOptions{PageSize: 100}, options) + return &connectivityclient.ConnectorInstanceList{Items: []connectivityclient.ConnectorInstance{instance}}, nil + }, + } + command := NewUninstallCommand(factoryReturning(client)) + + require.Equal(t, "uninstall ", command.Use) + require.Equal(t, []string{"delete", "remove", "rm", "u"}, command.Aliases) + require.Error(t, command.Args(command, nil)) + require.NoError(t, command.Args(command, []string{"stripe-eu"})) + require.Error(t, command.Args(command, []string{"stripe-eu", "extra"})) + candidates, directive := command.ValidArgsFunction(command, nil, "stripe") + require.Equal(t, []string{"stripe-eu\tstripe · main"}, candidates) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + root := NewCommand(nil, mockReadFile(nil), mockPathCompleter(nil)) + child, _, err := root.Find([]string{"uninstall"}) + require.NoError(t, err) + require.Equal(t, "uninstall", child.Name()) + require.True(t, reflect.DeepEqual([]string{"delete", "remove", "rm", "u"}, child.Aliases)) +} + +func TestUninstallInvalidArgumentsDoNotDelete(t *testing.T) { + usedFactory := false + factory := func(*cobra.Command) (connectivityclient.Client, error) { + usedFactory = true + return nil, errors.New("factory must not be used") + } + + _, err := executeCommand(NewUninstallCommand(factory), "stripe-eu", "extra", "--confirm") + + require.Error(t, err) + require.False(t, usedFactory) +} diff --git a/cmd/connectivity/connectorinstances/version.go b/cmd/connectivity/connectorinstances/version.go new file mode 100644 index 00000000..290d71cb --- /dev/null +++ b/cmd/connectivity/connectorinstances/version.go @@ -0,0 +1,155 @@ +package connectorinstances + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + xmodsemver "golang.org/x/mod/semver" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const ( + versionResolvePageSize = int32(100) + versionResolveMaxPages = 100 +) + +// resolveConnectorVersion returns the ConnectorVersion whose configSchema governs +// the configuration being assembled: the requested pin (or channel alias) when +// there is one, the server-resolved stable head otherwise. This mirrors an +// unselected install, whose API write persists the stable selector. +func resolveConnectorVersion(ctx context.Context, client connectivityclient.Client, connector, pinned string) (*connectivityclient.ConnectorVersion, error) { + if connector == "" { + return nil, fmt.Errorf("connector name is required") + } + if pinned == "" { + pinned = "stable" + } + version, err := client.GetConnectorVersion(ctx, connector, pinned) + var apiErr *connectivityclient.APIError + if (pinned == "stable" || pinned == connectivityclient.VersionAliasLatest) && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("connectivity connector %q has no published version: %w", connector, err) + } + if err != nil { + return nil, err + } + if version == nil { + return nil, fmt.Errorf("get connectivity connector %q version %q: empty response", connector, pinned) + } + return version, nil +} + +// appliedChannelFloor returns the version currently applied to this exact +// connector. The API only uses this status value as a channel resolver floor +// after confirming its resolved connector identity; desired spec.version is +// deliberately not a floor when switching from a pin to a channel. +func appliedChannelFloor(instance *connectivityclient.ConnectorInstance) string { + if instance == nil || instance.Status == nil || instance.Status.ResolvedVersion == nil { + return "" + } + // Older API responses did not stamp resolvedConnectorRef. The server treats + // that empty identity as the instance's previous connector, while a nonempty + // different identity must never constrain this connector's channel. + if instance.Status.ResolvedConnectorRef != nil && *instance.Status.ResolvedConnectorRef != "" && *instance.Status.ResolvedConnectorRef != instance.Spec.Connector { + return "" + } + return *instance.Status.ResolvedVersion +} + +// resolveChannelVersion mirrors the operator resolver for the schema selected +// during a channel-tracked configure. It reads every version page because the +// selected channel head is not necessarily present on the first page. +func resolveChannelVersion(ctx context.Context, client connectivityclient.Client, connector, channel, running string) (*connectivityclient.ConnectorVersion, error) { + versions, err := connectivityinternal.CollectPages(versionResolvePageSize, versionResolveMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(ctx, connector, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("list connectivity connector %q versions: empty response", connector) + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, err + } + candidates := make([]string, 0, len(versions)) + for _, version := range versions { + candidates = append(candidates, version.Version) + } + selected, err := selectChannelVersion(candidates, channel, running) + if err != nil { + return nil, fmt.Errorf("resolve connectivity connector %q channel %q: %w", connector, channel, err) + } + return resolveConnectorVersion(ctx, client, connector, selected) +} + +func selectChannelVersion(versions []string, channel, running string) (string, error) { + allowed, ok := channelPrereleases[channel] + if !ok { + return "", fmt.Errorf("unsupported channel %q", channel) + } + if running != "" && !isStrictSemver(running) { + return "", fmt.Errorf("parsing running version %q: invalid semantic version", running) + } + chosen := "" + for _, version := range versions { + if !isStrictSemver(version) { + return "", fmt.Errorf("parsing candidate version %q: invalid semantic version", version) + } + if !channelAdmits(version, allowed) || (running != "" && (xmodsemver.Major(normalizeSemver(version)) != xmodsemver.Major(normalizeSemver(running)) || compareSemver(version, running) < 0)) { + continue + } + if chosen == "" || compareSemver(version, chosen) > 0 { + chosen = version + } + } + if chosen == "" { + return "", fmt.Errorf("no candidate version satisfies channel %q", channel) + } + return chosen, nil +} + +var channelPrereleases = map[string]map[string]struct{}{ + "stable": {}, + "rc": {"rc": {}}, + "beta": {"rc": {}, "beta": {}}, + "alpha": {"rc": {}, "beta": {}, "alpha": {}}, +} + +func channelAdmits(version string, allowed map[string]struct{}) bool { + prerelease := strings.TrimPrefix(xmodsemver.Prerelease(normalizeSemver(version)), "-") + if prerelease == "" { + return true + } + identifier, _, _ := strings.Cut(prerelease, ".") + _, ok := allowed[identifier] + return ok +} + +func isStrictSemver(version string) bool { + core := strings.TrimPrefix(version, "v") + if suffix := strings.IndexAny(core, "-+"); suffix >= 0 { + core = core[:suffix] + } + return len(strings.Split(core, ".")) == 3 && xmodsemver.IsValid(normalizeSemver(version)) +} + +func normalizeSemver(version string) string { + if strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} + +func compareSemver(left, right string) int { + if result := xmodsemver.Compare(normalizeSemver(left), normalizeSemver(right)); result != 0 { + return result + } + return strings.Compare(left, right) +} diff --git a/cmd/connectivity/connectors/completion.go b/cmd/connectivity/connectors/completion.go new file mode 100644 index 00000000..205961d3 --- /dev/null +++ b/cmd/connectivity/connectors/completion.go @@ -0,0 +1,49 @@ +package connectors + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const connectorCompletionMaxPages = 5 + +func CompleteConnectorNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + connectors, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, connectorCompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.Connector, bool, string, error) { + page, err := client.ListConnectors(completionCommand.Context(), options) + if err != nil || page == nil { + return nil, false, "", err + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + candidates := make([]string, 0, len(connectors)) + for _, connector := range connectors { + name := stringValue(connector.Metadata.Name) + if name == "" || !strings.HasPrefix(name, toComplete) { + continue + } + if description := stringValue(connector.Spec.Description); description != "" { + name += "\t" + description + } + candidates = append(candidates, name) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/cmd/connectivity/connectors/completion_test.go b/cmd/connectivity/connectors/completion_test.go new file mode 100644 index 00000000..3ac6c3a4 --- /dev/null +++ b/cmd/connectivity/connectors/completion_test.go @@ -0,0 +1,123 @@ +package connectors + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestCompleteConnectorNamesUsesBoundedCatalogQueryAndReturnsPrefixMatchesWithDescriptions(t *testing.T) { + var gotOptions connectivityclient.ListOptions + var remaining time.Duration + client := connectorClientMock{list: func(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("completion context has no deadline") + } + remaining = time.Until(deadline) + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{ + connectorFixture("alpha"), connectorFixture("beta"), + }}, nil + }} + + completion := CompleteConnectorNames(func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !connectivityinternal.IsNonInteractive(cmd.Context()) { + t.Fatal("completion factory context is interactive") + } + return client, nil + }) + candidates, directive := completion(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotOptions, connectivityclient.ListOptions{PageSize: 100}) { + t.Fatalf("ListConnectors options = %#v, want page size 100", gotOptions) + } + if remaining <= 1500*time.Millisecond || remaining > 2*time.Second { + t.Fatalf("completion deadline remaining = %s, want approximately 2s", remaining) + } + if !reflect.DeepEqual(candidates, []string{"alpha\tConnector description"}) { + t.Fatalf("candidates = %#v, want prefix match with description", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteConnectorNamesFollowsCursorPagination(t *testing.T) { + var gotCursors []string + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotCursors = append(gotCursors, options.Cursor) + if options.Cursor == "" { + return &connectivityclient.ConnectorList{ + Items: []connectivityclient.Connector{connectorFixture("alpha")}, + HasMore: true, + Next: "page-two", + }, nil + } + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connectorFixture("alpine")}}, nil + }} + + candidates, _ := CompleteConnectorNames(factoryReturning(client))(&cobra.Command{}, nil, "al") + + if !reflect.DeepEqual(gotCursors, []string{"", "page-two"}) { + t.Fatalf("list cursors = %#v, want the continuation to be followed", gotCursors) + } + want := []string{"alpha\tConnector description", "alpine\tConnector description"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want both pages merged %#v", candidates, want) + } +} + +func TestCompleteConnectorNamesReturnsSilentlyOnFactoryAPIAndTimeoutErrors(t *testing.T) { + tests := map[string]struct { + command *cobra.Command + factory func(*cobra.Command) (connectivityclient.Client, error) + }{ + "authentication": { + command: &cobra.Command{}, + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("not authenticated") + }, + }, + "API": { + command: &cobra.Command{}, + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return nil, errors.New("unsupported deployment") + }}), + }, + "timeout": { + command: commandWithExpiredContext(), + factory: factoryReturning(connectorClientMock{list: func(ctx context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + <-ctx.Done() + return nil, ctx.Err() + }}), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + candidates, directive := CompleteConnectorNames(test.factory)(test.command, nil, "") + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} + +func commandWithExpiredContext() *cobra.Command { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + command := &cobra.Command{} + command.SetContext(ctx) + return command +} diff --git a/cmd/connectivity/connectors/facets.go b/cmd/connectivity/connectors/facets.go new file mode 100644 index 00000000..efa030a0 --- /dev/null +++ b/cmd/connectivity/connectors/facets.go @@ -0,0 +1,113 @@ +package connectors + +import ( + "fmt" + "sort" + "strconv" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type FacetsStore struct { + Total int64 `json:"total"` + Facets map[string]map[string]int64 `json:"facets"` +} + +type FacetsController struct { + factory connectivityinternal.ClientFactory + store *FacetsStore +} + +var _ fctl.Controller[*FacetsStore] = (*FacetsController)(nil) + +func NewFacetsController(factory connectivityinternal.ClientFactory) *FacetsController { + return &FacetsController{ + factory: factory, + store: &FacetsStore{Facets: map[string]map[string]int64{}}, + } +} + +func NewFacetsCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewFacetsController(factory) + command := fctl.NewCommand( + "facets", + fctl.WithAliases("facet", "f"), + fctl.WithShortDescription("Show the facet-value distribution of the connector catalogue"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + connectivityinternal.WithListQueryFlags(), + fctl.WithController[*FacetsStore](controller), + ) + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectors), + ); err != nil { + panic(err) + } + return command +} + +func (c *FacetsController) GetStore() *FacetsStore { + return c.store +} + +func (c *FacetsController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + query, err := connectivityinternal.GetListQuery(cmd) + if err != nil { + return nil, err + } + + response, err := client.GetConnectorFacets(cmd.Context(), query) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("get connectivity connector facets: empty response") + } + + c.store.Total = response.Total + c.store.Facets = response.Facets + return c, nil +} + +func (c *FacetsController) Render(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "Total: %d\n", c.store.Total); err != nil { + return err + } + + rows := make([][]string, 0) + facets := make([]string, 0, len(c.store.Facets)) + for facet := range c.store.Facets { + facets = append(facets, facet) + } + sort.Strings(facets) + for _, facet := range facets { + values := make([]string, 0, len(c.store.Facets[facet])) + for value := range c.store.Facets[facet] { + values = append(values, value) + } + sort.Strings(values) + for _, value := range values { + rows = append(rows, []string{facet, value, strconv.FormatInt(c.store.Facets[facet][value], 10)}) + } + } + rows = fctl.Prepend(rows, []string{"Facet", "Value", "Connectors"}) + return pterm.DefaultTable. + WithHasHeader(). + WithWriter(out). + WithData(rows). + Render() +} diff --git a/cmd/connectivity/connectors/facets_test.go b/cmd/connectivity/connectors/facets_test.go new file mode 100644 index 00000000..44f1743f --- /dev/null +++ b/cmd/connectivity/connectors/facets_test.go @@ -0,0 +1,111 @@ +package connectors + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func facetDistributionFixture() *connectivityclient.FacetDistribution { + return &connectivityclient.FacetDistribution{Total: 8, Facets: map[string]map[string]int64{ + "provider": {"psp": 6, "bank": 2}, + "domain": {"payouts": 3}, + }} +} + +func TestFacetsRendersTotalAndSortedDistribution(t *testing.T) { + var gotQuery string + client := connectorClientMock{facets: func(_ context.Context, query string) (*connectivityclient.FacetDistribution, error) { + gotQuery = query + return facetDistributionFixture(), nil + }} + + output, err := executeCommand(NewFacetsCommand(factoryReturning(client)), "--filter", "catalog=ee") + if err != nil { + t.Fatalf("execute facets command: %v", err) + } + + if want := `{"$match":{"catalog":"ee"}}`; gotQuery != want { + t.Fatalf("GetConnectorFacets query = %q, want %q", gotQuery, want) + } + for _, expected := range []string{ + "Total", "8", + "Facet", "Value", "Connectors", + "domain", "payouts", "3", + "provider", "bank", "2", + "psp", "6", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } + if strings.Index(output, "domain") > strings.Index(output, "provider") { + t.Errorf("facets are not sorted by facet name:\n%s", output) + } +} + +func TestFacetsJSONPreservesCompleteDistribution(t *testing.T) { + client := connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return facetDistributionFixture(), nil + }} + + command := NewFacetsCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json") + if err != nil { + t.Fatalf("execute JSON facets command: %v", err) + } + + var envelope struct { + Data FacetsStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if envelope.Data.Total != 8 || !reflect.DeepEqual(envelope.Data.Facets, facetDistributionFixture().Facets) { + t.Fatalf("JSON facets = %#v, want the complete distribution", envelope.Data) + } +} + +func TestFacetsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, errors.New("catalog unavailable") + }}), + want: "catalog unavailable", + }, + "empty response": { + factory: factoryReturning(connectorClientMock{facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewFacetsCommand(test.factory)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} diff --git a/cmd/connectivity/connectors/list.go b/cmd/connectivity/connectors/list.go new file mode 100644 index 00000000..0417afd3 --- /dev/null +++ b/cmd/connectivity/connectors/list.go @@ -0,0 +1,141 @@ +package connectors + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ListStore struct { + Connectors []connectivityclient.Connector `json:"connectors"` + Cursor fctl.Cursor `json:"cursor"` +} + +type ListController struct { + factory connectivityinternal.ClientFactory + store *ListStore +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewListController(factory connectivityinternal.ClientFactory) *ListController { + return &ListController{ + factory: factory, + store: &ListStore{Connectors: []connectivityclient.Connector{}}, + } +} + +func NewListCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewListController(factory) + command := fctl.NewCommand( + "list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List available Connectivity connectors"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + connectivityinternal.WithListQueryFlags(), + fctl.WithPageSizeFlag(), + fctl.WithCursorFlag(), + fctl.WithController[*ListStore](controller), + ) + if err := command.RegisterFlagCompletionFunc( + connectivityinternal.FilterFlag, + connectivityinternal.CompleteFilterExpressions(factory, connectivityclient.ResourceConnectors), + ); err != nil { + panic(err) + } + return command +} + +func (c *ListController) GetStore() *ListStore { + return c.store +} + +func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + query, err := connectivityinternal.GetListQuery(cmd) + if err != nil { + return nil, err + } + + response, err := client.ListConnectors(cmd.Context(), connectivityclient.ListOptions{ + PageSize: pageSize, + Cursor: cursor, + Query: query, + }) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("list connectivity connectors: empty response") + } + + c.store.Connectors = response.Items + c.store.Cursor = cursorFromList(response.PageSize, response.HasMore, response.Next) + return c, nil +} + +func cursorFromList(pageSize int32, hasMore bool, next string) fctl.Cursor { + cursor := fctl.Cursor{PageSize: int64(pageSize), HasMore: hasMore} + if next != "" { + cursor.Next = fctl.Ptr(next) + } + return cursor +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + rows := fctl.Map(c.store.Connectors, func(connector connectivityclient.Connector) []string { + return []string{ + stringValue(connector.Metadata.Name), + stringValue(connector.Spec.DisplayName), + strings.Join(connector.Spec.Tags, ", "), + connectorPhase(connector.Status), + } + }) + // No description column: descriptions are full sentences, and pterm sizes + // columns to their content, so one connector wrapped every row and the + // table stopped lining up. `connectors show` prints the description. + rows = fctl.Prepend(rows, []string{"Name", "Display Name", "Tags", "Phase"}) + if err := pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(rows). + Render(); err != nil { + return err + } + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func connectorPhase(status *connectivityclient.ConnectorStatus) string { + if status == nil { + return "" + } + return stringValue(status.Phase) +} diff --git a/cmd/connectivity/connectors/list_test.go b/cmd/connectivity/connectors/list_test.go new file mode 100644 index 00000000..51792f66 --- /dev/null +++ b/cmd/connectivity/connectors/list_test.go @@ -0,0 +1,189 @@ +package connectors + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func TestListConnectorsPassesPaginationAndRendersApprovedColumnsAndContinuation(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + return &connectivityclient.ConnectorList{ + Items: []connectivityclient.Connector{connectorFixture("stripe")}, + PageSize: 7, + HasMore: true, + Next: "next-page", + }, nil + }} + + command := NewListCommand(factoryReturning(client)) + output, err := executeCommand(command, "--page-size", "7", "--cursor", "current-page") + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + wantOptions := connectivityclient.ListOptions{PageSize: 7, Cursor: "current-page"} + if !reflect.DeepEqual(gotOptions, wantOptions) { + t.Fatalf("ListConnectors options = %#v, want %#v", gotOptions, wantOptions) + } + for _, expected := range []string{ + "Name", "Display Name", "Tags", "Phase", + "stripe", "Connector display name", "payments, webhooks", "Ready", + "HasMore", "true", "PageSize", "7", "Next", "next-page", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } + // The description is deliberately absent: it is a full sentence, and pterm + // widens a column to its content, so carrying it here wrapped every row. + for _, absent := range []string{"Description", "Connector description"} { + if strings.Contains(output, absent) { + t.Errorf("plain output should not carry %q:\n%s", absent, output) + } + } +} + +func TestListConnectorsBuildsQueryFromFilterFlags(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{}}, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--filter", "catalog=ee", "--filter", "phase=Ready") + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + want := `{"$and":[{"$match":{"catalog":"ee"}},{"$match":{"phase":"Ready"}}]}` + if gotOptions.Query != want { + t.Fatalf("ListConnectors query = %q, want %q", gotOptions.Query, want) + } +} + +func TestListConnectorsPassesRawQueryThrough(t *testing.T) { + var gotOptions connectivityclient.ListOptions + client := connectorClientMock{list: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + gotOptions = options + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{}}, nil + }} + raw := `{"$exists":{"catalog":false}}` + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", raw) + if err != nil { + t.Fatalf("execute list command: %v", err) + } + + if gotOptions.Query != raw { + t.Fatalf("ListConnectors query = %q, want %q", gotOptions.Query, raw) + } +} + +func TestListConnectorsRejectsCombinedQueryAndFilter(t *testing.T) { + client := connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + t.Fatal("ListConnectors must not run with conflicting flags") + return nil, nil + }} + + _, err := executeCommand(NewListCommand(factoryReturning(client)), "--query", `{"$match":{"catalog":"ee"}}`, "--filter", "phase=Ready") + + if err == nil || !strings.Contains(err.Error(), "--query cannot be combined with --filter") { + t.Fatalf("error = %v, want the conflicting flags error", err) + } +} + +func TestListConnectorsJSONPreservesCompleteModelsAndContinuation(t *testing.T) { + connector := connectorFixture("stripe") + connector.Metadata.Labels = map[string]string{"region": "eu"} + client := connectorClientMock{list: func(_ context.Context, _ connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{connector}, PageSize: 4, HasMore: true, Next: "next-page"}, nil + }} + + command := NewListCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json", "--page-size", "4") + if err != nil { + t.Fatalf("execute JSON list command: %v", err) + } + + var envelope struct { + Data ListStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if !reflect.DeepEqual(envelope.Data.Connectors, []connectivityclient.Connector{connector}) { + t.Fatalf("JSON connectors = %#v, want complete model %#v", envelope.Data.Connectors, connector) + } + if !envelope.Data.Cursor.HasMore || envelope.Data.Cursor.PageSize != 4 || envelope.Data.Cursor.Next == nil || *envelope.Data.Cursor.Next != "next-page" { + t.Fatalf("JSON cursor = %#v, want continuation and served page size", envelope.Data.Cursor) + } +} + +func TestConnectorRootRegistersApprovedCommandsAndAliases(t *testing.T) { + command := NewCommand(factoryReturning(connectorClientMock{})) + if command.Use != "connectors" || !reflect.DeepEqual(command.Aliases, []string{"connector", "c"}) { + t.Fatalf("connector root = %q aliases %v", command.Use, command.Aliases) + } + + wantAliases := map[string][]string{ + "list": {"ls", "l"}, + "show": {"get", "g", "sh", "s"}, + "facets": {"facet", "f"}, + } + for name, aliases := range wantAliases { + child, _, err := command.Find([]string{name}) + if err != nil { + t.Fatalf("find %s command: %v", name, err) + } + if child.Name() != name || !reflect.DeepEqual(child.Aliases, aliases) { + t.Errorf("%s aliases = %v, want %v", name, child.Aliases, aliases) + } + } +} + +func TestListConnectorsReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return nil, errors.New("catalog unavailable") + }}), + want: "catalog unavailable", + }, + "empty response": { + factory: factoryReturning(connectorClientMock{list: func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return nil, nil + }}), + want: "empty response", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewListCommand(test.factory)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} diff --git a/cmd/connectivity/connectors/root.go b/cmd/connectivity/connectors/root.go new file mode 100644 index 00000000..f3387abd --- /dev/null +++ b/cmd/connectivity/connectors/root.go @@ -0,0 +1,21 @@ +package connectors + +import ( + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + return fctl.NewCommand( + "connectors", + fctl.WithAliases("connector", "c"), + fctl.WithShortDescription("Browse Connectivity connectors"), + fctl.WithChildCommands( + NewListCommand(factory), + NewShowCommand(factory), + NewFacetsCommand(factory), + ), + ) +} diff --git a/cmd/connectivity/connectors/show.go b/cmd/connectivity/connectors/show.go new file mode 100644 index 00000000..fd4d2d41 --- /dev/null +++ b/cmd/connectivity/connectors/show.go @@ -0,0 +1,279 @@ +package connectors + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const ( + showVersionsPageSize = int32(100) + showVersionsMaxPages = 20 +) + +type ShowStore struct { + Connector connectivityclient.Connector `json:"connector"` + Versions []connectivityclient.ConnectorVersionSummary `json:"versions"` + Version *connectivityclient.ConnectorVersion `json:"version,omitempty"` +} + +type ShowController struct { + factory connectivityinternal.ClientFactory + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func NewShowController(factory connectivityinternal.ClientFactory) *ShowController { + return &ShowController{ + factory: factory, + store: &ShowStore{Versions: []connectivityclient.ConnectorVersionSummary{}}, + } +} + +func NewShowCommand(factory connectivityinternal.ClientFactory) *cobra.Command { + controller := NewShowController(factory) + return fctl.NewCommand( + "show ", + fctl.WithAliases("get", "g", "sh", "s"), + fctl.WithShortDescription("Show a Connectivity connector"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(CompleteConnectorNames(factory)), + fctl.WithController[*ShowStore](controller), + ) +} + +func (c *ShowController) GetStore() *ShowStore { + return c.store +} + +func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + if c.factory == nil { + return nil, fmt.Errorf("connectivity client factory is required") + } + client, err := c.factory(cmd) + if err != nil { + return nil, err + } + name := args[0] + connector, err := client.GetConnector(cmd.Context(), name) + if err != nil { + return nil, err + } + if connector == nil { + return nil, fmt.Errorf("show connectivity connector %q: empty response", name) + } + c.store.Connector = *connector + + versions, err := connectivityinternal.CollectPages(showVersionsPageSize, showVersionsMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) { + page, err := client.ListConnectorVersions(cmd.Context(), name, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("show connectivity connector %q: empty version list response", name) + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil, err + } + c.store.Versions = versions + + // The server resolves the `latest` alias to the newest Validated version; + // published-but-unresolvable catalogues degrade to the no-version notice. + if len(versions) > 0 { + version, err := client.GetConnectorVersion(cmd.Context(), name, connectivityclient.VersionAliasLatest) + var apiErr *connectivityclient.APIError + switch { + case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound: + case err != nil: + return nil, err + case version == nil: + return nil, fmt.Errorf("show connectivity connector %q: empty latest version response", name) + default: + c.store.Version = version + } + } + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + connector := c.store.Connector + out := cmd.OutOrStdout() + + fctl.Section.WithWriter(out).Println("Information") + information := pterm.TableData{ + {pterm.LightCyan("Name"), stringValue(connector.Metadata.Name)}, + {pterm.LightCyan("Namespace"), stringValue(connector.Metadata.Namespace)}, + {pterm.LightCyan("UID"), stringValue(connector.Metadata.UID)}, + {pterm.LightCyan("Resource Version"), stringValue(connector.Metadata.ResourceVersion)}, + {pterm.LightCyan("Created At"), timeValue(connector.Metadata.CreationTimestamp)}, + {pterm.LightCyan("Labels"), mapValue(connector.Metadata.Labels)}, + {pterm.LightCyan("Annotations"), mapValue(connector.Metadata.Annotations)}, + {pterm.LightCyan("Display Name"), stringValue(connector.Spec.DisplayName)}, + {pterm.LightCyan("Description"), stringValue(connector.Spec.Description)}, + {pterm.LightCyan("Image URL"), stringValue(connector.Spec.ImageURL)}, + {pterm.LightCyan("Catalog"), stringValue(connector.Spec.Catalog)}, + {pterm.LightCyan("Tags"), strings.Join(connector.Spec.Tags, ", ")}, + {pterm.LightCyan("Tagline"), stringValue(connector.Spec.Tagline)}, + {pterm.LightCyan("Latest Version"), stringValue(connector.Spec.LatestVersion)}, + {pterm.LightCyan("Phase"), connectorPhase(connector.Status)}, + {pterm.LightCyan("Status Message"), connectorStatusMessage(connector.Status)}, + } + if err := pterm.DefaultTable.WithWriter(out).WithData(information).Render(); err != nil { + return err + } + + if len(c.store.Versions) > 0 { + fctl.Section.WithWriter(out).Println("Versions") + versions := fctl.Map(c.store.Versions, func(version connectivityclient.ConnectorVersionSummary) []string { + return []string{version.Version, stringValue(version.Digest), version.Image, timeValue(version.ReleaseDate)} + }) + versions = fctl.Prepend(versions, []string{"Version", "Digest", "Image", "Released"}) + if err := pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(versions).Render(); err != nil { + return err + } + } + + if c.store.Version == nil { + fctl.Section.WithWriter(out).Println("Configuration Schema") + _, err := fmt.Fprintln(out, "No published version.") + return err + } + + fctl.Section.WithWriter(out).Println("Configuration Schema (" + c.store.Version.Version + ")") + schemaRows := summarizeSchema(c.store.Version.ConfigSchema) + if len(schemaRows) == 0 { + _, err := fmt.Fprintln(out, "No configurable fields.") + return err + } + schemaRows = fctl.Prepend(schemaRows, []string{"Key", "Source", "Requirement", "Format", "Description"}) + return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(schemaRows).Render() +} + +func summarizeSchema(schema map[string]any) [][]string { + type field struct { + key, source, requirement, format, description string + } + fields := make([]field, 0) + sections := []struct { + name string + source string + }{ + {name: "env", source: "environment"}, + {name: "files", source: "file"}, + } + + for _, section := range sections { + sectionSchema, ok := objectValue(schema[section.name]) + if !ok { + continue + } + properties, _ := objectValue(sectionSchema["properties"]) + required := requiredKeys(sectionSchema["required"]) + for key, rawDefinition := range properties { + definition, _ := objectValue(rawDefinition) + format, _ := definition["format"].(string) + if format == "" { + format, _ = definition["type"].(string) + } + description, _ := definition["description"].(string) + requirement := "optional" + if required[key] { + requirement = "required" + } + fields = append(fields, field{key, section.source, requirement, format, description}) + } + } + + if len(fields) == 0 { + properties, ok := objectValue(schema["properties"]) + if ok { + required := requiredKeys(schema["required"]) + for key, rawDefinition := range properties { + definition, _ := objectValue(rawDefinition) + format, _ := definition["format"].(string) + if format == "" { + format, _ = definition["type"].(string) + } + description, _ := definition["description"].(string) + requirement := "optional" + if required[key] { + requirement = "required" + } + fields = append(fields, field{key, "environment", requirement, format, description}) + } + } + } + + sort.Slice(fields, func(i, j int) bool { + if fields[i].source == fields[j].source { + return fields[i].key < fields[j].key + } + return fields[i].source < fields[j].source + }) + return fctl.Map(fields, func(value field) []string { + return []string{value.key, value.source, value.requirement, value.format, value.description} + }) +} + +func objectValue(value any) (map[string]any, bool) { + object, ok := value.(map[string]any) + return object, ok +} + +func requiredKeys(value any) map[string]bool { + result := map[string]bool{} + switch values := value.(type) { + case []any: + for _, value := range values { + if key, ok := value.(string); ok { + result[key] = true + } + } + case []string: + for _, key := range values { + result[key] = true + } + } + return result +} + +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} + +func mapValue(value map[string]string) string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, key+"="+value[key]) + } + return strings.Join(entries, ", ") +} + +func connectorStatusMessage(status *connectivityclient.ConnectorStatus) string { + if status == nil { + return "" + } + return stringValue(status.Message) +} diff --git a/cmd/connectivity/connectors/show_test.go b/cmd/connectivity/connectors/show_test.go new file mode 100644 index 00000000..bfe6afb3 --- /dev/null +++ b/cmd/connectivity/connectors/show_test.go @@ -0,0 +1,323 @@ +package connectors + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func versionsFixture() *connectivityclient.ConnectorVersionList { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{ + {Version: "1.0.0", Image: "registry/connector:1", Digest: fctl.Ptr("sha256:one")}, + {Version: "2.0.0", Image: "registry/connector:2"}, + }} +} + +func TestShowConnectorRendersMetadataVersionsTagsStatusAndLatestVersionSchema(t *testing.T) { + created := time.Date(2026, time.August, 7, 10, 30, 0, 0, time.UTC) + connector := connectorFixture("stripe") + connector.Metadata.Namespace = fctl.Ptr("formance") + connector.Metadata.ResourceVersion = fctl.Ptr("42") + connector.Metadata.UID = fctl.Ptr("connector-uid") + connector.Metadata.CreationTimestamp = &created + connector.Metadata.Labels = map[string]string{"region": "eu"} + connector.Metadata.Annotations = map[string]string{"owner": "platform"} + connector.Status.Message = fctl.Ptr("Catalog entry is healthy") + + var gotVersion string + client := connectorClientMock{ + get: func(_ context.Context, name string) (*connectivityclient.Connector, error) { + if name != "stripe" { + t.Fatalf("GetConnector name = %q, want stripe", name) + } + return &connector, nil + }, + listVersions: func(_ context.Context, name string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + if name != "stripe" { + t.Fatalf("ListConnectorVersions name = %q, want stripe", name) + } + if options.PageSize != 100 { + t.Fatalf("ListConnectorVersions page size = %d, want 100", options.PageSize) + } + return versionsFixture(), nil + }, + getVersion: func(_ context.Context, _, version string) (*connectivityclient.ConnectorVersion, error) { + gotVersion = version + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "registry/connector:2", + ConfigSchema: map[string]any{ + "type": "object", + "env": map[string]any{ + "type": "object", + "required": []any{"API_KEY"}, + "properties": map[string]any{"API_KEY": map[string]any{"type": "string", "format": "password", "description": "API credential"}}, + }, + "files": map[string]any{ + "type": "object", + "properties": map[string]any{"/etc/connector.yaml": map[string]any{"type": "string", "description": "Connector settings"}}, + }, + }, + }, nil + }, + } + command := NewShowCommand(factoryReturning(client)) + output, err := executeCommand(command, "stripe") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + + if gotVersion != connectivityclient.VersionAliasLatest { + t.Fatalf("GetConnectorVersion version = %q, want the server-resolved latest alias", gotVersion) + } + for _, expected := range []string{ + "stripe", "formance", "42", "connector-uid", created.Format(time.RFC3339), "region=eu", "owner=platform", + "Connector display name", "Connector description", "registry/connector", "public", + "payments, webhooks", "Payments infrastructure", "Ready", "Catalog entry is healthy", + "Latest Version", "2.0.0", + "1.0.0", "sha256:one", + "Configuration Schema (2.0.0)", "API_KEY", "environment", "required", "password", "API credential", + "/etc/connector.yaml", "file", "Connector settings", + } { + if !strings.Contains(output, expected) { + t.Errorf("plain output missing %q:\n%s", expected, output) + } + } +} + +func TestShowConnectorFollowsVersionPagination(t *testing.T) { + connector := connectorFixture("stripe") + var gotCursors []string + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(_ context.Context, _ string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + gotCursors = append(gotCursors, options.Cursor) + if options.Cursor == "" { + return &connectivityclient.ConnectorVersionList{ + Items: []connectivityclient.ConnectorVersionSummary{{Version: "1.0.0", Image: "registry/connector:1"}}, + HasMore: true, + Next: "page-two", + }, nil + } + return &connectivityclient.ConnectorVersionList{ + Items: []connectivityclient.ConnectorVersionSummary{{Version: "2.0.0", Image: "registry/connector:2"}}, + }, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "stripe") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + + if !reflect.DeepEqual(gotCursors, []string{"", "page-two"}) { + t.Fatalf("version list cursors = %#v, want the continuation to be followed", gotCursors) + } + for _, expected := range []string{"1.0.0", "2.0.0"} { + if !strings.Contains(output, expected) { + t.Errorf("output missing version %q:\n%s", expected, output) + } + } +} + +func TestShowConnectorWithoutVersionsReportsNoPublishedVersion(t *testing.T) { + connector := connectorFixture("fresh") + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + t.Fatal("GetConnectorVersion must not run without a published version") + return nil, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "fresh") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + if !strings.Contains(output, "No published version.") { + t.Fatalf("output missing the no-version notice:\n%s", output) + } +} + +// `latest` resolves to the newest Validated version; published-but-unvalidated +// versions answer 404, which show degrades to the no-version notice. +func TestShowConnectorTreatsUnresolvableLatestAsNoPublishedVersion(t *testing.T) { + connector := connectorFixture("pending") + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, &connectivityclient.APIError{StatusCode: http.StatusNotFound, Code: "channel_empty"} + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "pending") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + if !strings.Contains(output, "No published version.") { + t.Fatalf("output missing the no-version notice:\n%s", output) + } +} + +func TestShowConnectorJSONPreservesCompleteModel(t *testing.T) { + connector := connectorFixture("stripe") + version := &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "registry/connector:2", + ConfigSchema: map[string]any{"type": "object"}, + } + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return version, nil + }, + } + + command := NewShowCommand(factoryReturning(client)) + command.Flags().String(fctl.OutputFlag, "plain", "") + output, err := executeCommand(command, "--output", "json", "stripe") + if err != nil { + t.Fatalf("execute JSON show command: %v", err) + } + + var envelope struct { + Data ShowStore `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode JSON output %q: %v", output, err) + } + if !reflect.DeepEqual(envelope.Data.Connector, connector) { + t.Fatalf("JSON connector = %#v, want complete model %#v", envelope.Data.Connector, connector) + } + if !reflect.DeepEqual(envelope.Data.Versions, versionsFixture().Items) { + t.Fatalf("JSON versions = %#v, want the complete version list", envelope.Data.Versions) + } + if !reflect.DeepEqual(envelope.Data.Version, version) { + t.Fatalf("JSON version = %#v, want complete model %#v", envelope.Data.Version, version) + } +} + +func TestShowConnectorReturnsFactoryAPIAndEmptyResponseErrors(t *testing.T) { + tests := map[string]struct { + factory func(*cobra.Command) (connectivityclient.Client, error) + want string + }{ + "factory": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return nil, errors.New("authentication failed") + }, + want: "authentication failed", + }, + "API": { + factory: factoryReturning(connectorClientMock{get: func(context.Context, string) (*connectivityclient.Connector, error) { + return nil, errors.New("connector unavailable") + }}), + want: "connector unavailable", + }, + "empty response": { + factory: factoryReturning(connectorClientMock{get: func(context.Context, string) (*connectivityclient.Connector, error) { + return nil, nil + }}), + want: "empty response", + }, + "version list API": { + factory: factoryReturning(connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + connector := connectorFixture("stripe") + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return nil, errors.New("catalog unavailable") + }, + }), + want: "catalog unavailable", + }, + "latest version API": { + factory: factoryReturning(connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + connector := connectorFixture("stripe") + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return nil, errors.New("catalog unavailable") + }, + }), + want: "catalog unavailable", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := executeCommand(NewShowCommand(test.factory), "stripe") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want one containing %q", err, test.want) + } + }) + } +} + +func TestShowConnectorSummarizesLegacyFlatSchema(t *testing.T) { + connector := connectorFixture("legacy") + connector.Status = nil + client := connectorClientMock{ + get: func(context.Context, string) (*connectivityclient.Connector, error) { + return &connector, nil + }, + listVersions: func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + return versionsFixture(), nil + }, + getVersion: func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) { + return &connectivityclient.ConnectorVersion{ + Version: "2.0.0", + Image: "registry/connector:2", + ConfigSchema: map[string]any{ + "type": "object", + "required": []string{"ENDPOINT"}, + "properties": map[string]any{ + "ENDPOINT": map[string]any{"type": "string", "description": "Service endpoint"}, + }, + }, + }, nil + }, + } + + output, err := executeCommand(NewShowCommand(factoryReturning(client)), "legacy") + if err != nil { + t.Fatalf("execute show command: %v", err) + } + for _, expected := range []string{"ENDPOINT", "environment", "required", "string", "Service endpoint"} { + if !strings.Contains(output, expected) { + t.Errorf("legacy schema output missing %q:\n%s", expected, output) + } + } +} diff --git a/cmd/connectivity/connectors/test_helpers_test.go b/cmd/connectivity/connectors/test_helpers_test.go new file mode 100644 index 00000000..9ef752a2 --- /dev/null +++ b/cmd/connectivity/connectors/test_helpers_test.go @@ -0,0 +1,78 @@ +package connectors + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type connectorClientMock struct { + connectivityclient.Client + list func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) + facets func(context.Context, string) (*connectivityclient.FacetDistribution, error) + get func(context.Context, string) (*connectivityclient.Connector, error) + listVersions func(context.Context, string, connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) + getVersion func(context.Context, string, string) (*connectivityclient.ConnectorVersion, error) +} + +func (m connectorClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.list(ctx, options) +} + +func (m connectorClientMock) GetConnectorFacets(ctx context.Context, query string) (*connectivityclient.FacetDistribution, error) { + return m.facets(ctx, query) +} + +func (m connectorClientMock) GetConnector(ctx context.Context, name string) (*connectivityclient.Connector, error) { + return m.get(ctx, name) +} + +func (m connectorClientMock) ListConnectorVersions(ctx context.Context, connector string, options connectivityclient.ListOptions) (*connectivityclient.ConnectorVersionList, error) { + if m.listVersions == nil { + return &connectivityclient.ConnectorVersionList{Items: []connectivityclient.ConnectorVersionSummary{}}, nil + } + return m.listVersions(ctx, connector, options) +} + +func (m connectorClientMock) GetConnectorVersion(ctx context.Context, connector, version string) (*connectivityclient.ConnectorVersion, error) { + if m.getVersion == nil { + return &connectivityclient.ConnectorVersion{Version: version, Image: "registry/connector:" + version}, nil + } + return m.getVersion(ctx, connector, version) +} + +func connectorFixture(name string) connectivityclient.Connector { + return connectivityclient.Connector{ + Metadata: connectivityclient.ObjectMeta{Name: fctl.Ptr(name)}, + Spec: connectivityclient.ConnectorSpec{ + DisplayName: fctl.Ptr("Connector display name"), + Description: fctl.Ptr("Connector description"), + ImageURL: fctl.Ptr("registry/connector"), + Catalog: fctl.Ptr("public"), + Tags: []string{"payments", "webhooks"}, + Tagline: fctl.Ptr("Payments infrastructure"), + LatestVersion: fctl.Ptr("2.0.0"), + }, + Status: &connectivityclient.ConnectorStatus{Phase: fctl.Ptr("Ready")}, + } +} + +func factoryReturning(client connectivityclient.Client) connectivityinternal.ClientFactory { + return func(*cobra.Command) (connectivityclient.Client, error) { + return client, nil + } +} + +func executeCommand(command *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} diff --git a/cmd/connectivity/internal/client.go b/cmd/connectivity/internal/client.go new file mode 100644 index 00000000..68c53b39 --- /dev/null +++ b/cmd/connectivity/internal/client.go @@ -0,0 +1,87 @@ +package internal + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + oidcclient "github.com/formancehq/go-libs/v4/oidc/client" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ClientFactory func(*cobra.Command) (connectivityclient.Client, error) + +type nonInteractiveContextKey struct{} + +func WithNonInteractive(ctx context.Context) context.Context { + return context.WithValue(ctx, nonInteractiveContextKey{}, true) +} + +func IsNonInteractive(ctx context.Context) bool { + if ctx == nil { + return false + } + nonInteractive, _ := ctx.Value(nonInteractiveContextKey{}).(bool) + return nonInteractive +} + +type clientFactoryDependencies struct { + loadAndAuthenticateCurrentProfile func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) + resolveStackID func(*cobra.Command, fctl.Profile) (string, string, error) + readStackToken func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) + newStackClientsFromFlags func(*cobra.Command, oidcclient.RelyingParty, fctl.Dialog, string, fctl.Profile) (*fctl.StackClients, error) +} + +func NewClientFactory() ClientFactory { + return newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: fctl.LoadAndAuthenticateCurrentProfile, + resolveStackID: fctl.ResolveStackID, + readStackToken: fctl.ReadStackToken, + newStackClientsFromFlags: fctl.NewStackClientsFromFlags, + }) +} + +func newClientFactory(dependencies clientFactoryDependencies) ClientFactory { + return func(cmd *cobra.Command) (connectivityclient.Client, error) { + _, profile, profileName, relyingParty, err := dependencies.loadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + dialog := fctl.NewPTermDialog() + if IsNonInteractive(cmd.Context()) { + organizationID, stackID, err := dependencies.resolveStackID(cmd, *profile) + if err != nil { + return nil, err + } + stackToken, err := dependencies.readStackToken(cmd, profileName, organizationID, stackID) + if err != nil { + return nil, err + } + if stackToken == nil || stackToken.Expired() { + return nil, fmt.Errorf("connectivity completion requires an existing unexpired stack token") + } + dialog = silentDialog{} + } + + clients, err := dependencies.newStackClientsFromFlags( + cmd, + relyingParty, + dialog, + profileName, + *profile, + ) + if err != nil { + return nil, err + } + + return connectivityclient.New(clients.URI, clients.HTTPClient), nil + } +} + +type silentDialog struct{} + +func (silentDialog) Info(string, ...any) {} diff --git a/cmd/connectivity/internal/client_test.go b/cmd/connectivity/internal/client_test.go new file mode 100644 index 00000000..b0de07ac --- /dev/null +++ b/cmd/connectivity/internal/client_test.go @@ -0,0 +1,139 @@ +package internal + +import ( + "bytes" + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/go-libs/v4/oidc" + oidcclient "github.com/formancehq/go-libs/v4/oidc/client" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func TestClientFactoryNonInteractiveModeRejectsUnusableTokensBeforeAuthentication(t *testing.T) { + tests := map[string]*fctl.AccessToken{ + "missing": nil, + "expired": accessTokenExpiringAt(time.Now().Add(-time.Minute)), + } + + for name, token := range tests { + t.Run(name, func(t *testing.T) { + var terminal, commandOutput bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + authenticationStarted := false + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + resolveStackID: func(*cobra.Command, fctl.Profile) (string, string, error) { + return "org", "stack", nil + }, + readStackToken: func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) { + return token, nil + }, + newStackClientsFromFlags: func(*cobra.Command, oidcclient.RelyingParty, fctl.Dialog, string, fctl.Profile) (*fctl.StackClients, error) { + authenticationStarted = true + return nil, nil + }, + }) + command := &cobra.Command{} + command.SetContext(WithNonInteractive(context.Background())) + command.SetOut(&commandOutput) + command.SetErr(&commandOutput) + + client, err := factory(command) + if err == nil { + t.Fatal("factory error = nil, want unusable-token error") + } + if client != nil { + t.Fatalf("client = %#v, want nil", client) + } + if authenticationStarted { + t.Fatal("interactive authentication boundary was called") + } + if terminal.Len() != 0 || commandOutput.Len() != 0 { + t.Fatalf("completion emitted output: terminal=%q command=%q", terminal.String(), commandOutput.String()) + } + }) + } +} + +func TestClientFactoryNonInteractiveModeUsesSilentDialogWithValidToken(t *testing.T) { + var terminal bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + resolveStackID: func(*cobra.Command, fctl.Profile) (string, string, error) { + return "org", "stack", nil + }, + readStackToken: func(*cobra.Command, string, string, string) (*fctl.AccessToken, error) { + return accessTokenExpiringAt(time.Now().Add(time.Hour)), nil + }, + newStackClientsFromFlags: func(_ *cobra.Command, _ oidcclient.RelyingParty, dialog fctl.Dialog, _ string, _ fctl.Profile) (*fctl.StackClients, error) { + dialog.Info("must stay silent") + return &fctl.StackClients{URI: "https://stack.example", HTTPClient: &http.Client{}}, nil + }, + }) + command := &cobra.Command{} + command.SetContext(WithNonInteractive(context.Background())) + + client, err := factory(command) + if err != nil { + t.Fatalf("factory error = %v", err) + } + if client == nil { + t.Fatal("client = nil, want Connectivity client") + } + if terminal.Len() != 0 { + t.Fatalf("non-interactive dialog output = %q, want silence", terminal.String()) + } +} + +func TestClientFactoryNormalModeRetainsInteractiveDialog(t *testing.T) { + var terminal bytes.Buffer + originalLogger := pterm.DefaultLogger + pterm.DefaultLogger.Writer = &terminal + t.Cleanup(func() { pterm.DefaultLogger = originalLogger }) + + factory := newClientFactory(clientFactoryDependencies{ + loadAndAuthenticateCurrentProfile: testProfileLoader(), + newStackClientsFromFlags: func(_ *cobra.Command, _ oidcclient.RelyingParty, dialog fctl.Dialog, _ string, _ fctl.Profile) (*fctl.StackClients, error) { + dialog.Info("normal authentication path") + return &fctl.StackClients{URI: "https://stack.example", HTTPClient: &http.Client{}}, nil + }, + }) + + client, err := factory(&cobra.Command{}) + if err != nil { + t.Fatalf("factory error = %v", err) + } + if client == nil { + t.Fatal("client = nil, want Connectivity client") + } + if !strings.Contains(terminal.String(), "normal authentication path") { + t.Fatalf("normal dialog output = %q, want interactive message", terminal.String()) + } +} + +func testProfileLoader() func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) { + return func(*cobra.Command) (*fctl.Config, *fctl.Profile, string, oidcclient.RelyingParty, error) { + return &fctl.Config{}, &fctl.Profile{}, "profile", nil, nil + } +} + +func accessTokenExpiringAt(expiration time.Time) *fctl.AccessToken { + return &fctl.AccessToken{TokenWithClaims: fctl.TokenWithClaims[fctl.AccessTokenClaims]{ + Claims: fctl.AccessTokenClaims{TokenClaims: oidc.TokenClaims{Expiration: oidc.Time(expiration.Unix())}}, + }} +} diff --git a/cmd/connectivity/internal/filtercompletion.go b/cmd/connectivity/internal/filtercompletion.go new file mode 100644 index 00000000..0436daf2 --- /dev/null +++ b/cmd/connectivity/internal/filtercompletion.go @@ -0,0 +1,204 @@ +package internal + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +const ( + CompletionTimeout = 2 * time.Second + CompletionPageSize = int32(100) + CompletionMaxPages = 5 +) + +// CompletionClient builds a non-interactive, deadline-bounded client for +// shell completion callbacks, on a copy of the command so the original +// context is left untouched. +func CompletionClient(cmd *cobra.Command, factory ClientFactory) (connectivityclient.Client, *cobra.Command, context.CancelFunc, bool) { + if factory == nil { + return nil, nil, func() {}, false + } + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, CompletionTimeout) + completionCommand := *cmd + completionCommand.SetContext(WithNonInteractive(ctx)) + client, err := factory(&completionCommand) + if err != nil || client == nil { + cancel() + return nil, nil, func() {}, false + } + return client, &completionCommand, cancel, true +} + +// CompleteFilterExpressions completes `--filter` expressions from the +// server's query allowlist: keys and operators come from +// `GET /_query/capabilities`, values from the capability enum, from +// `GET /connectors/_facets` for connector tags, and from the published +// connector names for the instance `connector` key. +func CompleteFilterExpressions(factory ClientFactory, resource string) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + ctx := completionCommand.Context() + + capabilities, err := client.GetQueryCapabilities(ctx) + if err != nil || capabilities == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + fields, ok := capabilities.Resources[resource] + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + key, valuePrefix, operator := splitFilter(toComplete) + if operator == "" { + return completeFilterKeys(fields, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + + capability, ok := fields[key] + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + values := filterValueCandidates(ctx, client, resource, key, capability) + candidates := make([]string, 0, len(values)) + for _, value := range values { + if !strings.HasPrefix(value.value, valuePrefix) { + continue + } + candidate := key + operator + value.value + if value.description != "" { + candidate += "\t" + value.description + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +// CompleteChannels serves the maturity channels from the server's capability +// enum for the instance `channel` key, so the CLI never hardcodes them. +func CompleteChannels(factory ClientFactory) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, completionCommand, cancel, ok := CompletionClient(cmd, factory) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer cancel() + + capabilities, err := client.GetQueryCapabilities(completionCommand.Context()) + if err != nil || capabilities == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + channels := capabilities.Resources[connectivityclient.ResourceConnectorInstances]["channel"].Enum + candidates := make([]string, 0, len(channels)) + for _, channel := range channels { + if strings.HasPrefix(channel, toComplete) { + candidates = append(candidates, channel) + } + } + sort.Strings(candidates) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} + +func completeFilterKeys(fields map[string]connectivityclient.QueryFieldCapability, prefix string) []string { + candidates := make([]string, 0, len(fields)) + for key, capability := range fields { + if !strings.HasPrefix(key, prefix) { + continue + } + candidate := key + "=" + if len(capability.Operators) > 0 { + candidate += "\toperators: " + strings.Join(capability.Operators, ", ") + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates +} + +type filterValue struct { + value string + description string +} + +func filterValueCandidates(ctx context.Context, client connectivityclient.Client, resource, key string, capability connectivityclient.QueryFieldCapability) []filterValue { + if len(capability.Enum) > 0 { + values := make([]filterValue, 0, len(capability.Enum)) + for _, value := range capability.Enum { + values = append(values, filterValue{value: value}) + } + return values + } + switch { + case resource == connectivityclient.ResourceConnectors && key == "tags": + return facetFilterValues(ctx, client) + case resource == connectivityclient.ResourceConnectorInstances && key == "connector": + return connectorFilterValues(ctx, client) + } + return nil +} + +func facetFilterValues(ctx context.Context, client connectivityclient.Client) []filterValue { + facets, err := client.GetConnectorFacets(ctx, "") + if err != nil || facets == nil { + return nil + } + values := make([]filterValue, 0) + for facet, counts := range facets.Facets { + for value, count := range counts { + plural := "s" + if count == 1 { + plural = "" + } + values = append(values, filterValue{ + value: facet + ":" + value, + description: fmt.Sprintf("%d connector%s", count, plural), + }) + } + } + return values +} + +func connectorFilterValues(ctx context.Context, client connectivityclient.Client) []filterValue { + connectors, err := CollectPagesBounded(CompletionPageSize, CompletionMaxPages, + func(options connectivityclient.ListOptions) ([]connectivityclient.Connector, bool, string, error) { + page, err := client.ListConnectors(ctx, options) + if err != nil { + return nil, false, "", err + } + if page == nil { + return nil, false, "", fmt.Errorf("empty connector completion response") + } + return page.Items, page.HasMore, page.Next, nil + }) + if err != nil { + return nil + } + values := make([]filterValue, 0, len(connectors)) + for _, connector := range connectors { + if connector.Metadata.Name == nil || *connector.Metadata.Name == "" { + continue + } + value := filterValue{value: *connector.Metadata.Name} + if connector.Spec.DisplayName != nil { + value.description = *connector.Spec.DisplayName + } + values = append(values, value) + } + return values +} diff --git a/cmd/connectivity/internal/filtercompletion_test.go b/cmd/connectivity/internal/filtercompletion_test.go new file mode 100644 index 00000000..86054004 --- /dev/null +++ b/cmd/connectivity/internal/filtercompletion_test.go @@ -0,0 +1,308 @@ +package internal + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/spf13/cobra" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +type filterCompletionClientMock struct { + connectivityclient.Client + capabilities func(context.Context) (*connectivityclient.QueryCapabilities, error) + facets func(context.Context, string) (*connectivityclient.FacetDistribution, error) + listConnectors func(context.Context, connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) +} + +func (m filterCompletionClientMock) GetQueryCapabilities(ctx context.Context) (*connectivityclient.QueryCapabilities, error) { + return m.capabilities(ctx) +} + +func (m filterCompletionClientMock) GetConnectorFacets(ctx context.Context, query string) (*connectivityclient.FacetDistribution, error) { + return m.facets(ctx, query) +} + +func (m filterCompletionClientMock) ListConnectors(ctx context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + return m.listConnectors(ctx, options) +} + +func capabilitiesFixture() *connectivityclient.QueryCapabilities { + return &connectivityclient.QueryCapabilities{Resources: map[string]map[string]connectivityclient.QueryFieldCapability{ + connectivityclient.ResourceConnectors: { + "catalog": {Operators: []string{"$match", "$in", "$exists"}}, + "name": {Operators: []string{"$match", "$in", "$like"}}, + "tags": {Operators: []string{"$match", "$in", "$exists"}}, + }, + connectivityclient.ResourceConnectorInstances: { + "channel": {Operators: []string{"$match", "$in", "$exists"}, Enum: []string{"stable", "rc", "beta", "alpha"}}, + "connector": {Operators: []string{"$match", "$in"}}, + }, + }} +} + +func filterFactory(t *testing.T, client connectivityclient.Client) ClientFactory { + t.Helper() + return func(cmd *cobra.Command) (connectivityclient.Client, error) { + if !IsNonInteractive(cmd.Context()) { + t.Fatal("filter completion factory context is interactive") + } + if _, ok := cmd.Context().Deadline(); !ok { + t.Fatal("filter completion context has no deadline") + } + return client, nil + } +} + +func TestCompleteFilterExpressionsOffersCapabilityKeysWithOperators(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + candidates, directive := completion(&cobra.Command{}, nil, "") + + want := []string{ + "catalog=\toperators: $match, $in, $exists", + "name=\toperators: $match, $in, $like", + "tags=\toperators: $match, $in, $exists", + } + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace { + t.Fatalf("directive = %v, want NoFileComp|NoSpace", directive) + } +} + +func TestCompleteFilterExpressionsFiltersKeysByPrefix(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + candidates, _ := completion(&cobra.Command{}, nil, "na") + + want := []string{"name=\toperators: $match, $in, $like"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestCompleteFilterExpressionsCompletesEnumValuesForTypedOperator(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances) + + equals, directive := completion(&cobra.Command{}, nil, "channel=st") + negated, _ := completion(&cobra.Command{}, nil, "channel!=r") + + if want := []string{"channel=stable"}; !reflect.DeepEqual(equals, want) { + t.Fatalf("equality candidates = %#v, want %#v", equals, want) + } + if want := []string{"channel!=rc"}; !reflect.DeepEqual(negated, want) { + t.Fatalf("negation candidates = %#v, want %#v", negated, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFilterExpressionsCompletesTagValuesFromFacetDistribution(t *testing.T) { + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return &connectivityclient.FacetDistribution{Total: 8, Facets: map[string]map[string]int64{ + "provider": {"psp": 6, "bank": 2}, + "domain": {"payouts": 3}, + }}, nil + }, + } + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectors) + + all, _ := completion(&cobra.Command{}, nil, "tags=") + scoped, _ := completion(&cobra.Command{}, nil, "tags=provider:") + + wantAll := []string{ + "tags=domain:payouts\t3 connectors", + "tags=provider:bank\t2 connectors", + "tags=provider:psp\t6 connectors", + } + if !reflect.DeepEqual(all, wantAll) { + t.Fatalf("candidates = %#v, want %#v", all, wantAll) + } + wantScoped := []string{ + "tags=provider:bank\t2 connectors", + "tags=provider:psp\t6 connectors", + } + if !reflect.DeepEqual(scoped, wantScoped) { + t.Fatalf("scoped candidates = %#v, want %#v", scoped, wantScoped) + } +} + +func TestCompleteFilterExpressionsCompletesConnectorNamesForInstanceConnectorKey(t *testing.T) { + displayName := "Stripe" + stripe := "stripe" + wise := "wise" + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + if options.PageSize != 100 { + t.Fatalf("ListConnectors page size = %d, want 100", options.PageSize) + } + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{ + {Metadata: connectivityclient.ObjectMeta{Name: &wise}}, + {Metadata: connectivityclient.ObjectMeta{Name: &stripe}, Spec: connectivityclient.ConnectorSpec{DisplayName: &displayName}}, + }}, nil + }, + } + completion := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances) + + candidates, _ := completion(&cobra.Command{}, nil, "connector=") + + want := []string{"connector=stripe\tStripe", "connector=wise"} + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestCompleteFilterExpressionsDrainsBoundedConnectorPages(t *testing.T) { + first, second := "adyen", "stripe" + client := filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + listConnectors: func(_ context.Context, options connectivityclient.ListOptions) (*connectivityclient.ConnectorList, error) { + switch options.Cursor { + case "": + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{{Metadata: connectivityclient.ObjectMeta{Name: &first}}}, HasMore: true, Next: "second"}, nil + case "second": + return &connectivityclient.ConnectorList{Items: []connectivityclient.Connector{{Metadata: connectivityclient.ObjectMeta{Name: &second}}}}, nil + default: + t.Fatalf("unexpected connector cursor %q", options.Cursor) + return nil, nil + } + }, + } + + candidates, _ := CompleteFilterExpressions(filterFactory(t, client), connectivityclient.ResourceConnectorInstances)(&cobra.Command{}, nil, "connector=") + + if want := []string{"connector=adyen", "connector=stripe"}; !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestCompleteChannelsServesTheCapabilityEnum(t *testing.T) { + client := filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }} + completion := CompleteChannels(filterFactory(t, client)) + + all, directive := completion(&cobra.Command{}, nil, "") + prefixed, _ := completion(&cobra.Command{}, nil, "s") + + if want := []string{"alpha", "beta", "rc", "stable"}; !reflect.DeepEqual(all, want) { + t.Fatalf("candidates = %#v, want %#v", all, want) + } + if want := []string{"stable"}; !reflect.DeepEqual(prefixed, want) { + t.Fatalf("prefixed candidates = %#v, want %#v", prefixed, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteChannelsReturnsSilentlyWhenCapabilitiesFail(t *testing.T) { + completion := CompleteChannels(func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return nil, errors.New("unsupported deployment") + }}, nil + }) + + candidates, directive := completion(&cobra.Command{}, nil, "") + + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } +} + +func TestCompleteFilterExpressionsReturnsSilentlyWhenLookupsFail(t *testing.T) { + tests := map[string]struct { + factory ClientFactory + resource string + prefix string + }{ + "factory error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { return nil, errors.New("not authenticated") }, + resource: connectivityclient.ResourceConnectors, + }, + "nil factory": { + resource: connectivityclient.ResourceConnectors, + }, + "capabilities error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return nil, errors.New("unsupported deployment") + }}, nil + }, + resource: connectivityclient.ResourceConnectors, + }, + "unknown resource": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }}, nil + }, + resource: "unknown", + }, + "value for unknown key": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }}, nil + }, + resource: connectivityclient.ResourceConnectors, + prefix: "nope=", + }, + "facets error": { + factory: func(*cobra.Command) (connectivityclient.Client, error) { + return filterCompletionClientMock{ + capabilities: func(context.Context) (*connectivityclient.QueryCapabilities, error) { + return capabilitiesFixture(), nil + }, + facets: func(context.Context, string) (*connectivityclient.FacetDistribution, error) { + return nil, errors.New("unsupported deployment") + }, + }, nil + }, + resource: connectivityclient.ResourceConnectors, + prefix: "tags=", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + completion := CompleteFilterExpressions(test.factory, test.resource) + + candidates, directive := completion(&cobra.Command{}, nil, test.prefix) + + if len(candidates) != 0 { + t.Fatalf("candidates = %#v, want none", candidates) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want NoFileComp", directive) + } + }) + } +} diff --git a/cmd/connectivity/internal/paging.go b/cmd/connectivity/internal/paging.go new file mode 100644 index 00000000..217498f1 --- /dev/null +++ b/cmd/connectivity/internal/paging.go @@ -0,0 +1,56 @@ +package internal + +import ( + "fmt" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +// CollectPages drains a cursor-paginated list endpoint, following +// `cursor.next` until the server reports the last page. A user-facing read +// must never render an unmarked prefix, so malformed and truncated cursors are +// reported as errors. +func CollectPages[T any](pageSize int32, maxPages int, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + return collectPages(pageSize, maxPages, false, fetch) +} + +// CollectPagesBounded is for best-effort shell completion. It returns the +// useful prefix at the intentional page bound, while retaining validation of +// malformed cursors before that bound. +func CollectPagesBounded[T any](pageSize int32, maxPages int, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + return collectPages(pageSize, maxPages, true, fetch) +} + +func collectPages[T any](pageSize int32, maxPages int, allowTruncation bool, fetch func(connectivityclient.ListOptions) ([]T, bool, string, error)) ([]T, error) { + if maxPages <= 0 { + return nil, fmt.Errorf("pagination maximum pages must be positive") + } + options := connectivityclient.ListOptions{PageSize: pageSize} + items := make([]T, 0) + seen := make(map[string]struct{}) + for page := 0; page < maxPages; page++ { + pageItems, hasMore, next, err := fetch(options) + if err != nil { + return nil, err + } + items = append(items, pageItems...) + if !hasMore { + return items, nil + } + if next == "" { + return nil, fmt.Errorf("pagination cursor hasMore is true without a next cursor") + } + if _, repeated := seen[next]; repeated { + return nil, fmt.Errorf("pagination cursor repeated %q", next) + } + seen[next] = struct{}{} + if page+1 == maxPages { + if allowTruncation { + return items, nil + } + return nil, fmt.Errorf("pagination exceeded maximum of %d pages", maxPages) + } + options.Cursor = next + } + return items, nil // unreachable while maxPages is positive +} diff --git a/cmd/connectivity/internal/paging_test.go b/cmd/connectivity/internal/paging_test.go new file mode 100644 index 00000000..c8b1b110 --- /dev/null +++ b/cmd/connectivity/internal/paging_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient" +) + +func TestCollectPagesRejectsDishonestCursorEnvelopes(t *testing.T) { + tests := map[string]struct { + maxPages int + fetch func(connectivityclient.ListOptions) ([]string, bool, string, error) + want string + }{ + "has more without next cursor": { + maxPages: 2, + fetch: func(connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{"one"}, true, "", nil + }, + want: "hasMore", + }, + "repeated cursor": { + maxPages: 3, + fetch: func(options connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{options.Cursor}, true, "again", nil + }, + want: "repeated", + }, + "bounded before final page": { + maxPages: 1, + fetch: func(connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{"one"}, true, "next", nil + }, + want: "maximum", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + _, err := CollectPages(100, tt.maxPages, tt.fetch) + + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), tt.want), "error = %v", err) + }) + } +} + +func TestCollectPagesBoundedReturnsUsefulCompletionPrefix(t *testing.T) { + items, err := CollectPagesBounded(100, 1, func(options connectivityclient.ListOptions) ([]string, bool, string, error) { + return []string{options.Cursor + "candidate"}, true, "later", nil + }) + + require.NoError(t, err) + require.Equal(t, []string{"candidate"}, items) +} diff --git a/cmd/connectivity/internal/query.go b/cmd/connectivity/internal/query.go new file mode 100644 index 00000000..08427124 --- /dev/null +++ b/cmd/connectivity/internal/query.go @@ -0,0 +1,113 @@ +package internal + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +const ( + FilterFlag = "filter" + QueryFlag = "query" +) + +func WithListQueryFlags() fctl.CommandOptionFn { + return func(cmd *cobra.Command) { + cmd.Flags().StringArray(FilterFlag, nil, "Filter expression key=value, key!=value or key~pattern (repeatable)") + cmd.Flags().String(QueryFlag, "", "Raw filter in the go-libs query dialect (JSON); see /_query/capabilities") + } +} + +// GetListQuery resolves the --filter/--query flags into the query string the +// connectivity API accepts. +func GetListQuery(cmd *cobra.Command) (string, error) { + filters, err := cmd.Flags().GetStringArray(FilterFlag) + if err != nil { + return "", err + } + return BuildListQuery(fctl.GetString(cmd, QueryFlag), filters) +} + +// BuildListQuery turns --filter expressions into the go-libs query dialect the +// connectivity API accepts on `?query=`, or passes a raw --query through +// untouched. Supported expressions: `key=value` ($match), `key!=value` +// ($exists conjoined with a negated $match, because a bare $not also selects +// objects missing the key) and `key~pattern` ($like, SQL wildcards). +func BuildListQuery(rawQuery string, filters []string) (string, error) { + if rawQuery != "" { + if len(filters) > 0 { + return "", fmt.Errorf("--query cannot be combined with --filter") + } + var root any + if err := json.Unmarshal([]byte(rawQuery), &root); err != nil { + return "", fmt.Errorf("parse --query: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return "", fmt.Errorf("parse --query: root must be an object") + } + return rawQuery, nil + } + if len(filters) == 0 { + return "", nil + } + + clauses := make([]any, 0, len(filters)) + for _, filter := range filters { + clause, err := filterClause(filter) + if err != nil { + return "", err + } + clauses = append(clauses, clause) + } + + var query any = clauses[0] + if len(clauses) > 1 { + query = map[string]any{"$and": clauses} + } + encoded, err := json.Marshal(query) + if err != nil { + return "", fmt.Errorf("encode query: %w", err) + } + return string(encoded), nil +} + +func filterClause(filter string) (any, error) { + key, value, operator := splitFilter(filter) + if operator == "" { + return nil, fmt.Errorf("parse --filter %q: expected key=value, key!=value or key~pattern", filter) + } + if key == "" { + return nil, fmt.Errorf("parse --filter %q: key is required", filter) + } + if value == "" { + return nil, fmt.Errorf("parse --filter %q: value is required", filter) + } + switch operator { + case "!=": + return map[string]any{"$and": []any{ + map[string]any{"$exists": map[string]any{key: true}}, + map[string]any{"$not": map[string]any{"$match": map[string]any{key: value}}}, + }}, nil + case "~": + return map[string]any{"$like": map[string]any{key: value}}, nil + default: + return map[string]any{"$match": map[string]any{key: value}}, nil + } +} + +func splitFilter(filter string) (key, value, operator string) { + for index := 0; index < len(filter); index++ { + switch filter[index] { + case '~', '=': + return filter[:index], filter[index+1:], string(filter[index]) + case '!': + if index+1 < len(filter) && filter[index+1] == '=' { + return filter[:index], filter[index+2:], "!=" + } + } + } + return "", "", "" +} diff --git a/cmd/connectivity/internal/query_test.go b/cmd/connectivity/internal/query_test.go new file mode 100644 index 00000000..4ad07b6d --- /dev/null +++ b/cmd/connectivity/internal/query_test.go @@ -0,0 +1,83 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildListQueryReturnsEmptyWithoutInput(t *testing.T) { + query, err := BuildListQuery("", nil) + + require.NoError(t, err) + require.Empty(t, query) +} + +func TestBuildListQueryBuildsMatchLeafFromEquality(t *testing.T) { + query, err := BuildListQuery("", []string{"catalog=ee"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$match":{"catalog":"ee"}}`, query) +} + +func TestBuildListQueryConjoinsMultipleFilters(t *testing.T) { + query, err := BuildListQuery("", []string{"catalog=ee", "phase=Ready"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$and":[{"$match":{"catalog":"ee"}},{"$match":{"phase":"Ready"}}]}`, query) +} + +// A bare $not also selects objects missing the key, so the builder conjoins +// $exists with the negation (the openapi documents this trap). +func TestBuildListQueryBuildsGuardedNegation(t *testing.T) { + query, err := BuildListQuery("", []string{"phase!=Rejected"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$and":[{"$exists":{"phase":true}},{"$not":{"$match":{"phase":"Rejected"}}}]}`, query) +} + +func TestBuildListQueryBuildsLikeLeaf(t *testing.T) { + query, err := BuildListQuery("", []string{"name~stripe%"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$like":{"name":"stripe%"}}`, query) +} + +func TestBuildListQueryChoosesTheEarliestOperatorWhenValuesContainDelimiters(t *testing.T) { + query, err := BuildListQuery("", []string{"name~stripe=us"}) + + require.NoError(t, err) + require.JSONEq(t, `{"$like":{"name":"stripe=us"}}`, query) +} + +func TestBuildListQueryPassesRawQueryThrough(t *testing.T) { + raw := `{"$or":[{"$match":{"catalog":"ee"}},{"$exists":{"catalog":false}}]}` + + query, err := BuildListQuery(raw, nil) + + require.NoError(t, err) + require.JSONEq(t, raw, query) +} + +func TestBuildListQueryRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + raw string + filters []string + }{ + {name: "raw query combined with filters", raw: `{"$match":{"catalog":"ee"}}`, filters: []string{"phase=Ready"}}, + {name: "raw query is not JSON", raw: `{$match:`}, + {name: "raw query is not an object", raw: `["$match"]`}, + {name: "filter without operator", filters: []string{"catalog"}}, + {name: "filter without key", filters: []string{"=ee"}}, + {name: "filter without value", filters: []string{"catalog="}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildListQuery(tt.raw, tt.filters) + + require.Error(t, err) + }) + } +} diff --git a/cmd/connectivity/root.go b/cmd/connectivity/root.go new file mode 100644 index 00000000..2c30403e --- /dev/null +++ b/cmd/connectivity/root.go @@ -0,0 +1,21 @@ +package connectivity + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v3/cmd/connectivity/connectorinstances" + "github.com/formancehq/fctl/v3/cmd/connectivity/connectors" + connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand() *cobra.Command { + factory := connectivityinternal.NewClientFactory() + return fctl.NewStackCommand("connectivity", + fctl.WithShortDescription("Manage Connectivity connectors and connector instances"), + fctl.WithChildCommands( + connectors.NewCommand(factory), + connectorinstances.NewCommand(factory, fctl.ReadFile, connectorinstances.OSPathCompleter), + ), + ) +} diff --git a/cmd/connectivity/root_test.go b/cmd/connectivity/root_test.go new file mode 100644 index 00000000..418f7bb4 --- /dev/null +++ b/cmd/connectivity/root_test.go @@ -0,0 +1,29 @@ +package connectivity + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommandTreeMatchesConnectivityUX(t *testing.T) { + cmd := NewCommand() + require.Equal(t, "connectivity", cmd.Name()) + + for _, path := range [][]string{ + {"connectors", "list"}, + {"connectors", "show"}, + {"connectorinstances", "list"}, + {"connectorinstances", "show"}, + {"connectorinstances", "install"}, + {"connectorinstances", "configure"}, + {"connectorinstances", "uninstall"}, + } { + found, _, err := cmd.Find(path) + require.NoError(t, err) + require.Equal(t, path[len(path)-1], found.Name()) + } + + require.NotNil(t, cmd.PersistentFlags().Lookup("stack")) + require.NotNil(t, cmd.PersistentFlags().Lookup("organization")) +} diff --git a/cmd/root.go b/cmd/root.go index 48d89984..a955a2af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/formancehq/fctl/v3/cmd/auth" "github.com/formancehq/fctl/v3/cmd/cloud" + "github.com/formancehq/fctl/v3/cmd/connectivity" "github.com/formancehq/fctl/v3/cmd/ledger" "github.com/formancehq/fctl/v3/cmd/login" "github.com/formancehq/fctl/v3/cmd/orchestration" @@ -61,6 +62,7 @@ func NewRootCommand() *cobra.Command { webhooks.NewCommand(), wallets.NewCommand(), orchestration.NewCommand(), + connectivity.NewCommand(), ), fctl.WithPersistentStringPFlag(fctl.ProfileFlag, "p", "", "Configuration profile to use"), fctl.WithPersistentStringPFlag(fctl.ConfigDir, "c", fmt.Sprintf("%s/.config/formance/fctl", homedir), "Path to configuration dir"), diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..7c6b8dea --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRootCommandRegistersConnectivity(t *testing.T) { + found, _, err := NewRootCommand().Find([]string{"connectivity"}) + require.NoError(t, err) + require.Equal(t, "connectivity", found.Name()) +} diff --git a/docs/connectivity.md b/docs/connectivity.md new file mode 100644 index 00000000..32a73c29 --- /dev/null +++ b/docs/connectivity.md @@ -0,0 +1,86 @@ +# Connectivity commands + +`fctl connectivity` manages the published Connector catalogue and the +ConnectorInstances installed in the selected stack. + +## Command tree + +```text +fctl connectivity connectors list [--filter KEY=VALUE] [--query JSON] +fctl connectivity connectors show +fctl connectivity connectors facets [--filter KEY=VALUE] [--query JSON] + +fctl connectivity connectorinstances list [--connector NAME] [--filter KEY=VALUE] +fctl connectivity connectorinstances show +fctl connectivity connectorinstances install --ledger +fctl connectivity connectorinstances configure +fctl connectivity connectorinstances suspend --confirm +fctl connectivity connectorinstances unsuspend --confirm +fctl connectivity connectorinstances uninstall +``` + +Short aliases are available for the common list and catalogue commands: +`connectors ls`, `connectors l`, `connectors facets` (`facet`, `f`), and +`connectorinstances ls` (`l`). `install` also accepts `create` and `in`; +`configure` accepts `config`, `update`, and `c`. + +## Suspension and resumption + +`connectorinstances suspend` and `connectorinstances unsuspend` request the +desired ingestion state through the Connectivity API. Both commands require +confirmation, either interactively or with `--confirm`: + +```bash +fctl connectivity connectorinstances suspend stripe-prod --confirm +fctl connectivity connectorinstances unsuspend stripe-prod --confirm +``` + +A successful command means the request was accepted; reconciliation may still +be in progress. Use `connectorinstances show ` to compare the desired +`Suspend` and `Replicas` values with the observed phase, state, and +`Suspended by` provenance. Suspension can also remain effective because the +desired replica count is zero or a matching Policy contributes a suspension +source. + +## Filtering and pagination + +List and facets commands accept repeatable `--filter` expressions: + +```bash +fctl connectivity connectors list --filter catalog=ee --filter tags~provider:% +fctl connectivity connectorinstances list --filter channel=beta +``` + +`KEY=VALUE` is an exact match, `KEY!=VALUE` excludes a present matching key, +and `KEY~PATTERN` uses SQL `%` and `_` wildcards. Use `--query ''` for +the full API query dialect; it cannot be combined with `--filter`. The CLI +also accepts `--page-size` and the opaque `--cursor` from a previous response. +Shell completion obtains the currently supported keys, values, and channels +from the API. + +## Versions and channels + +An install without `--version` or `--channel` resolves its configuration schema +from the **stable** channel, matching the API's persisted default. `--version` +pins an exact release. `--channel stable|rc|beta|alpha` tracks that channel; +when both are present the explicit version wins. + +During `configure`, `--channel` removes a prior pin unless `--version` is also +provided. The CLI resolves the channel across every catalogue page using the +same rules as Connectivity: it never downgrades and stays within the currently +resolved major version. `connectors show` and schema-driven configuration use +the API's `latest` alias only where that is the documented catalogue view. + +## Configuration inputs + +`install` and `configure` accept three configuration forms, in precedence +order: `--config` (YAML or JSON), repeatable `--env-file`, then repeatable +`--set KEY=VALUE`. Values may be inline, `@path`/`@-`, +`secret://name/key`, or `configmap://name/key`. Structured config supports +separate `env` and `files` sections. + +The CLI derives known keys and definitely-required fields from the Connector +Version JSON Schema, including bounded local `$ref`, `allOf`, `anyOf`, and +`oneOf` composition. Open or pattern-based keys, and constraints that cannot +be proven from CLI input, are sent to Connectivity for authoritative +validation. diff --git a/internal/connectivityclient/client.go b/internal/connectivityclient/client.go new file mode 100644 index 00000000..6dd614d2 --- /dev/null +++ b/internal/connectivityclient/client.go @@ -0,0 +1,361 @@ +package connectivityclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const connectivityPath = "/api/connectivity" + +const ( + connectorsResource = "connectors" + connectorVersionsResource = "versions" + connectorInstancesResource = "connectorinstances" + facetsSegment = "_facets" + querySegment = "_query" + capabilitiesSegment = "capabilities" +) + +type client struct { + stackURI string + httpClient *http.Client +} + +func (e *APIError) Error() string { + return fmt.Sprintf("connectivity API error: status %d, code %s: %s", e.StatusCode, e.Code, e.Message) +} + +type page[T any] struct { + Cursor *struct { + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next"` + Data []T `json:"data"` + } `json:"cursor"` +} + +func listPage[T any](ctx context.Context, c *client, path []string, options ListOptions, validate func(*T) error) (*page[T], error) { + envelope := &page[T]{} + if err := c.requestJSON(ctx, http.MethodGet, path, listQuery(options), nil, "", http.StatusOK, envelope, true); err != nil { + return nil, err + } + if envelope.Cursor == nil { + return nil, fmt.Errorf("decode connectivity response: cursor must be a non-null object") + } + if envelope.Cursor.Data == nil { + return nil, fmt.Errorf("decode connectivity response: cursor.data must be a non-null array") + } + for i := range envelope.Cursor.Data { + if err := validate(&envelope.Cursor.Data[i]); err != nil { + return nil, fmt.Errorf("decode connectivity response: cursor.data[%d]: %w", i, err) + } + } + return envelope, nil +} + +func (c *client) ListConnectors(ctx context.Context, options ListOptions) (*ConnectorList, error) { + result, err := listPage(ctx, c, []string{connectorsResource}, options, validateConnector) + if err != nil { + return nil, err + } + return &ConnectorList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil +} + +func (c *client) GetConnectorFacets(ctx context.Context, query string) (*FacetDistribution, error) { + result := &FacetDistribution{} + values := url.Values{} + if query != "" { + values.Set("query", query) + } + path := []string{connectorsResource, facetsSegment} + if err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetQueryCapabilities(ctx context.Context) (*QueryCapabilities, error) { + result := &QueryCapabilities{} + path := []string{querySegment, capabilitiesSegment} + if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetConnector(ctx context.Context, name string) (*Connector, error) { + result := &Connector{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorsResource, name}, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) ListConnectorVersions(ctx context.Context, connector string, options ListOptions) (*ConnectorVersionList, error) { + path := []string{connectorsResource, connector, connectorVersionsResource} + result, err := listPage(ctx, c, path, options, validateConnectorVersionSummary) + if err != nil { + return nil, err + } + return &ConnectorVersionList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil +} + +func (c *client) GetConnectorVersion(ctx context.Context, connector, version string) (*ConnectorVersion, error) { + result := &ConnectorVersion{} + path := []string{connectorsResource, connector, connectorVersionsResource, version} + if err := c.requestJSON(ctx, http.MethodGet, path, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) ListConnectorInstances(ctx context.Context, options ListOptions) (*ConnectorInstanceList, error) { + result, err := listPage(ctx, c, []string{connectorInstancesResource}, options, validateConnectorInstance) + if err != nil { + return nil, err + } + return &ConnectorInstanceList{ + Items: result.Cursor.Data, + PageSize: result.Cursor.PageSize, + HasMore: result.Cursor.HasMore, + Next: result.Cursor.Next, + }, nil +} + +func (c *client) CreateConnectorInstance(ctx context.Context, instance ConnectorInstanceCreate) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + if err := c.requestJSON(ctx, http.MethodPost, []string{connectorInstancesResource}, nil, instance, "application/json", http.StatusCreated, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) GetConnectorInstance(ctx context.Context, name string) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + if err := c.requestJSON(ctx, http.MethodGet, []string{connectorInstancesResource, name}, nil, nil, "", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) PatchConnectorInstance(ctx context.Context, name string, patch ConnectorInstancePatch) (*ConnectorInstance, error) { + result := &ConnectorInstance{} + path := []string{connectorInstancesResource, name} + if err := c.requestJSON(ctx, http.MethodPatch, path, nil, patch, "application/merge-patch+json", http.StatusOK, result, true); err != nil { + return nil, err + } + return result, nil +} + +func (c *client) DeleteConnectorInstance(ctx context.Context, name string) error { + return c.requestJSON(ctx, http.MethodDelete, []string{connectorInstancesResource, name}, nil, nil, "", http.StatusNoContent, nil, false) +} + +func listQuery(options ListOptions) url.Values { + query := url.Values{} + if options.PageSize != 0 { + query.Set("pageSize", strconv.FormatInt(int64(options.PageSize), 10)) + } + if options.Cursor != "" { + query.Set("cursor", options.Cursor) + } + if options.Query != "" { + query.Set("query", options.Query) + } + return query +} + +func (c *client) requestJSON(ctx context.Context, method string, path []string, query url.Values, body any, contentType string, expectedStatus int, destination any, requireObject bool) error { + endpoint, err := endpointURL(c.stackURI, path) + if err != nil { + return err + } + endpoint.RawQuery = query.Encode() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal connectivity request: %w", err) + } + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return fmt.Errorf("create connectivity request: %w", err) + } + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + httpClient := c.httpClient + if httpClient == nil { + httpClient = http.DefaultClient + } + response, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("send connectivity request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != expectedStatus { + return decodeAPIError(response) + } + if destination == nil { + return nil + } + return decodeResponse(response.Body, destination, requireObject) +} + +func endpointURL(stackURI string, segments []string) (*url.URL, error) { + endpoint, err := url.Parse(stackURI) + if err != nil { + return nil, fmt.Errorf("parse stack URI: %w", err) + } + if endpoint.Scheme == "" || endpoint.Host == "" { + return nil, fmt.Errorf("parse stack URI: expected absolute URI") + } + + basePath := strings.TrimSuffix(endpoint.Path, "/") + baseRawPath := strings.TrimSuffix(endpoint.EscapedPath(), "/") + if baseRawPath == "" { + baseRawPath = basePath + } + path := connectivityPath + rawPath := connectivityPath + for _, segment := range segments { + if segment == "" { + continue + } + path += "/" + segment + rawPath += "/" + url.PathEscape(segment) + } + + endpoint.Path = basePath + path + endpoint.RawPath = baseRawPath + rawPath + endpoint.RawQuery = "" + endpoint.Fragment = "" + return endpoint, nil +} + +func decodeResponse(body io.Reader, destination any, requireObject bool) error { + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("read connectivity response: %w", err) + } + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return fmt.Errorf("decode connectivity response: empty response body") + } + if requireObject { + var root any + if err := json.Unmarshal(trimmed, &root); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return fmt.Errorf("decode connectivity response: root must be an object") + } + } + if err := json.Unmarshal(trimmed, destination); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + if err := validateResponse(destination); err != nil { + return fmt.Errorf("decode connectivity response: %w", err) + } + return nil +} + +func validateResponse(destination any) error { + switch value := destination.(type) { + case *Connector: + return validateConnector(value) + case *ConnectorVersion: + return validateConnectorVersion(value.Version, value.Image) + case *ConnectorInstance: + return validateConnectorInstance(value) + case *FacetDistribution: + if value.Facets == nil { + return fmt.Errorf("facets must be a non-null object") + } + case *QueryCapabilities: + if value.Resources == nil { + return fmt.Errorf("resources must be a non-null object") + } + } + return nil +} + +func validateConnector(connector *Connector) error { + if connector == nil || connector.Metadata.Name == nil || strings.TrimSpace(*connector.Metadata.Name) == "" { + return fmt.Errorf("connector metadata.name is required") + } + return nil +} + +func validateConnectorVersion(version, image string) error { + if strings.TrimSpace(version) == "" { + return fmt.Errorf("connector version version is required") + } + if strings.TrimSpace(image) == "" { + return fmt.Errorf("connector version image is required") + } + return nil +} + +func validateConnectorVersionSummary(summary *ConnectorVersionSummary) error { + return validateConnectorVersion(summary.Version, summary.Image) +} + +// validateConnectorInstance guards decoding, not creation: both call sites are +// response paths. Only the name is required, because it is what identifies the +// row and Kubernetes always sets it. +// +// spec.connector deliberately is not. An instance may pin spec.image on the CR +// instead of referencing a Connector -- the two are mutually exclusive -- and +// the API returns it with an empty connector. Requiring it here rejected such +// an instance, and since one bad item fails the page, a single image-pinned +// connector made `connectorinstances list` unusable against a response the +// server had answered successfully. spec.ledger goes with it: enforcing a +// write-side invariant while reading is the same mistake. +func validateConnectorInstance(instance *ConnectorInstance) error { + if instance == nil || instance.Metadata.Name == nil || strings.TrimSpace(*instance.Metadata.Name) == "" { + return fmt.Errorf("connector instance metadata.name is required") + } + return nil +} + +func decodeAPIError(response *http.Response) error { + payload := struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + }{} + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return &APIError{StatusCode: response.StatusCode, Message: response.Status} + } + return &APIError{ + StatusCode: response.StatusCode, + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + } +} diff --git a/internal/connectivityclient/client_test.go b/internal/connectivityclient/client_test.go new file mode 100644 index 00000000..3ce7e663 --- /dev/null +++ b/internal/connectivityclient/client_test.go @@ -0,0 +1,677 @@ +package connectivityclient + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestListConnectorsBuildsStackConnectivityRequest(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"cursor":{"pageSize":25,"hasMore":true,"next":"next-cursor","data":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}]}}`), nil + })} + + got, err := New("https://stack.example/base", httpClient).ListConnectors(context.Background(), ListOptions{ + PageSize: 25, + Cursor: "opaque", + Query: `{"$match":{"catalog":"ee"}}`, + }) + + require.NoError(t, err) + require.Equal(t, "/base/api/connectivity/connectors", seen.URL.Path) + require.Equal(t, "25", seen.URL.Query().Get("pageSize")) + require.Equal(t, "opaque", seen.URL.Query().Get("cursor")) + require.Equal(t, `{"$match":{"catalog":"ee"}}`, seen.URL.Query().Get("query")) + require.Equal(t, "stripe", *got.Items[0].Metadata.Name) + require.Equal(t, int32(25), got.PageSize) + require.True(t, got.HasMore) + require.Equal(t, "next-cursor", got.Next) +} + +func TestListConnectorsDecodesCatalogueIdentity(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(200, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{ + "metadata":{"name":"stripe"}, + "spec":{ + "displayName":"Stripe", + "tagline":"Payments for the internet", + "latestVersion":"v2.1.0", + "tags":["provider:psp"], + "branding":{"displayName":"Stripe, Inc.","accentColor":"#635bff"} + } + }]}}`), nil + })} + + got, err := New("https://stack.example", httpClient).ListConnectors(context.Background(), ListOptions{}) + + require.NoError(t, err) + spec := got.Items[0].Spec + require.Equal(t, "Payments for the internet", *spec.Tagline) + require.Equal(t, "v2.1.0", *spec.LatestVersion) + require.Equal(t, []string{"provider:psp"}, spec.Tags) + require.Equal(t, "Stripe, Inc.", *spec.Branding.DisplayName) + require.Equal(t, "#635bff", *spec.Branding.AccentColor) +} + +func TestClientMethodsRespectHTTPContracts(t *testing.T) { + connectorName := "stripe/primary" + instanceName := "worker/one" + + tests := []struct { + name string + wantMethod string + wantPath string + wantQuery map[string]string + wantContentType string + wantBody string + responseStatus int + responseBody string + call func(Client) error + }{ + { + name: "lists connectors", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors", + wantQuery: map[string]string{"query": `{"$match":{"catalog":"ee"}}`, "pageSize": "5", "cursor": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"cursor":{"pageSize":5,"hasMore":false,"data":[]}}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{Query: `{"$match":{"catalog":"ee"}}`, PageSize: 5, Cursor: "after"}) + return err + }, + }, + { + name: "gets the connector facet distribution", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/_facets", + wantQuery: map[string]string{"query": `{"$match":{"catalog":"ee"}}`}, + responseStatus: http.StatusOK, + responseBody: `{"total":6,"facets":{"provider":{"psp":6}}}`, + call: func(client Client) error { + _, err := client.GetConnectorFacets(context.Background(), `{"$match":{"catalog":"ee"}}`) + return err + }, + }, + { + name: "gets the query capabilities", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/_query/capabilities", + responseStatus: http.StatusOK, + responseBody: `{"resources":{"connectors":{"name":{"operators":["$match"]}}}}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) + return err + }, + }, + { + name: "gets a connector using an escaped name", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary", + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"stripe/primary"},"spec":{"displayName":"Stripe"}}`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), connectorName) + return err + }, + }, + { + name: "lists connector versions with pagination", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions", + wantQuery: map[string]string{"pageSize": "100", "cursor": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"cursor":{"pageSize":100,"hasMore":false,"data":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}}`, + call: func(client Client) error { + _, err := client.ListConnectorVersions(context.Background(), connectorName, ListOptions{PageSize: 100, Cursor: "after"}) + return err + }, + }, + { + name: "gets a connector version using escaped segments", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions/v1.0.0-rc.1", + responseStatus: http.StatusOK, + responseBody: `{"version":"v1.0.0-rc.1","image":"registry/stripe:v1.0.0"}`, + call: func(client Client) error { + _, err := client.GetConnectorVersion(context.Background(), connectorName, "v1.0.0-rc.1") + return err + }, + }, + { + name: "resolves a version alias in the version slot", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectors/stripe%2Fprimary/versions/latest", + responseStatus: http.StatusOK, + responseBody: `{"version":"v2.0.0","image":"registry/stripe:v2.0.0"}`, + call: func(client Client) error { + _, err := client.GetConnectorVersion(context.Background(), connectorName, VersionAliasLatest) + return err + }, + }, + { + name: "lists connector instances", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectorinstances", + wantQuery: map[string]string{"query": `{"$match":{"connector":"stripe"}}`, "pageSize": "5", "cursor": "after"}, + responseStatus: http.StatusOK, + responseBody: `{"cursor":{"pageSize":5,"hasMore":false,"data":[]}}`, + call: func(client Client) error { + _, err := client.ListConnectorInstances(context.Background(), ListOptions{Query: `{"$match":{"connector":"stripe"}}`, PageSize: 5, Cursor: "after"}) + return err + }, + }, + { + name: "creates a connector instance", + wantMethod: http.MethodPost, + wantPath: "/base/api/connectivity/connectorinstances", + wantContentType: "application/json", + wantBody: `{"name":"worker","spec":{"connector":"stripe","ledger":"ledger"}}`, + responseStatus: http.StatusCreated, + responseBody: `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.CreateConnectorInstance(context.Background(), ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "ledger"}}) + return err + }, + }, + { + name: "gets a connector instance using an escaped name", + wantMethod: http.MethodGet, + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.GetConnectorInstance(context.Background(), instanceName) + return err + }, + }, + { + name: "patches a connector instance", + wantMethod: http.MethodPatch, + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", + wantContentType: "application/merge-patch+json", + wantBody: `{"spec":{"pollInterval":"1m"}}`, + responseStatus: http.StatusOK, + responseBody: `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"ledger"}}`, + call: func(client Client) error { + _, err := client.PatchConnectorInstance(context.Background(), instanceName, ConnectorInstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + return err + }, + }, + { + name: "deletes a connector instance", + wantMethod: http.MethodDelete, + wantPath: "/base/api/connectivity/connectorinstances/worker%2Fone", + responseStatus: http.StatusNoContent, + call: func(client Client) error { + return client.DeleteConnectorInstance(context.Background(), instanceName) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(tt.responseStatus, tt.responseBody), nil + })} + + err := tt.call(New("https://stack.example/base", httpClient)) + + require.NoError(t, err) + require.Equal(t, tt.wantMethod, seen.Method) + require.Equal(t, tt.wantPath, seen.URL.EscapedPath()) + require.Equal(t, "application/json", seen.Header.Get("Accept")) + for key, want := range tt.wantQuery { + require.Equal(t, want, seen.URL.Query().Get(key), "query %q", key) + } + if tt.wantContentType != "" { + require.Equal(t, tt.wantContentType, seen.Header.Get("Content-Type")) + } + if tt.wantBody != "" { + body, readErr := io.ReadAll(seen.Body) + require.NoError(t, readErr) + require.JSONEq(t, tt.wantBody, string(body)) + } + }) + } +} + +func TestListRequestsOmitUnsetOptions(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`), nil + })} + + _, err := New("https://stack.example", httpClient).ListConnectors(context.Background(), ListOptions{}) + + require.NoError(t, err) + require.Empty(t, seen.URL.RawQuery) +} + +func TestGetConnectorFacetsOmitsEmptyQuery(t *testing.T) { + var seen *http.Request + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req.Clone(req.Context()) + return jsonResponse(200, `{"total":0,"facets":{}}`), nil + })} + + got, err := New("https://stack.example", httpClient).GetConnectorFacets(context.Background(), "") + + require.NoError(t, err) + require.Empty(t, seen.URL.RawQuery) + require.Zero(t, got.Total) + require.Empty(t, got.Facets) +} + +func TestGetQueryCapabilitiesDecodesResources(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(200, `{"resources":{ + "connectors":{"tags":{"operators":["$match","$in","$exists"]}}, + "connectorinstances":{"channel":{"operators":["$match","$in","$exists"],"enum":["stable","rc","beta","alpha"]}} + }}`), nil + })} + + got, err := New("https://stack.example", httpClient).GetQueryCapabilities(context.Background()) + + require.NoError(t, err) + require.Equal(t, []string{"$match", "$in", "$exists"}, got.Resources[ResourceConnectors]["tags"].Operators) + require.Equal(t, []string{"stable", "rc", "beta", "alpha"}, got.Resources[ResourceConnectorInstances]["channel"].Enum) +} + +func TestClientReturnsStructuredAPIError(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadRequest, `{"code":"invalid_query","message":"key not allowed","details":{"key":"nope"}}`), nil + })} + + _, err := New("https://stack.example", httpClient).GetConnector(context.Background(), "missing") + + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + require.Equal(t, "invalid_query", apiErr.Code) + require.Equal(t, "key not allowed", apiErr.Message) + require.Equal(t, map[string]any{"key": "nope"}, apiErr.Details) +} + +// The API owns the CRD reshaping (spec.config -> spec.env/spec.files), so the +// client must put the patch on the wire in the documented API shape. +func TestPatchConnectorInstanceSendsAPIShapedConfig(t *testing.T) { + var seenBody []byte + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var err error + seenBody, err = io.ReadAll(req.Body) + require.NoError(t, err) + return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`), nil + })} + password := "env-password" + privateKey := "file-password" + config := &ConnectorInstanceConfig{ + Env: map[string]EnvValue{ + "API_PASSWORD": {Value: &password}, + }, + Files: []FileMount{ + {Path: "/etc/plugin/key.pem", Value: &privateKey}, + {Path: "/etc/plugin/config.json", ConfigMapRef: &KeyRef{Name: "connector-config", Key: "config.json"}}, + }, + } + + _, err := New("https://stack.example", httpClient).PatchConnectorInstance(context.Background(), "worker", ConnectorInstancePatch{ + "spec": map[string]any{ + "config": config, + "ledger": "main", + "pollInterval": "15s", + }, + }) + + require.NoError(t, err) + require.JSONEq(t, `{ + "spec": { + "config": { + "env": {"API_PASSWORD": {"value": "env-password"}}, + "files": [ + {"path": "/etc/plugin/key.pem", "value": "file-password"}, + {"path": "/etc/plugin/config.json", "configMapRef": {"name": "connector-config", "key": "config.json"}} + ] + }, + "ledger": "main", + "pollInterval": "15s" + } + }`, string(seenBody)) +} + +func TestPatchConnectorInstanceSendsExactSuspensionBoolean(t *testing.T) { + tests := []struct { + name string + suspend bool + want string + }{ + {name: "suspend", suspend: true, want: `{"spec":{"suspend":true}}`}, + {name: "explicit resume", suspend: false, want: `{"spec":{"suspend":false}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var seenMethod, seenPath, seenContentType string + var seenBody []byte + httpClient := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenMethod = req.Method + seenPath = req.URL.EscapedPath() + seenContentType = req.Header.Get("Content-Type") + var err error + seenBody, err = io.ReadAll(req.Body) + require.NoError(t, err) + return jsonResponse(http.StatusOK, `{"metadata":{"name":"worker/one"},"spec":{"connector":"stripe","ledger":"main"}}`), nil + })} + + _, err := New("https://stack.example/base", httpClient).PatchConnectorInstance( + context.Background(), + "worker/one", + ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, + ) + + require.NoError(t, err) + require.Equal(t, http.MethodPatch, seenMethod) + require.Equal(t, "/base/api/connectivity/connectorinstances/worker%2Fone", seenPath) + require.Equal(t, "application/merge-patch+json", seenContentType) + require.Equal(t, test.want, string(seenBody)) + }) + } +} + +// An instance pinned to spec.image on the CR references no Connector, so the +// API returns it with an empty spec.connector. One such row used to fail the +// decode of the whole page -- `connectorinstances list` reported +// "cursor.data[N]: connector instance spec.connector is required" on a +// response the server had answered 200. +func TestListConnectorInstancesDecodesAnInstanceWithNoConnector(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"cursor":{"pageSize":15,"hasMore":false,"data":[ + {"metadata":{"name":"with-connector"},"spec":{"connector":"stripe","ledger":"main"}}, + {"metadata":{"name":"image-pinned"},"spec":{"ledger":"ops"}} + ]}}`), nil + })} + + list, err := New("https://stack.example", httpClient).ListConnectorInstances(context.Background(), ListOptions{}) + + require.NoError(t, err) + require.Len(t, list.Items, 2) + require.Equal(t, "stripe", list.Items[0].Spec.Connector) + require.Empty(t, list.Items[1].Spec.Connector) + require.Equal(t, "ops", list.Items[1].Spec.Ledger) +} + +func TestGetConnectorInstanceDecodesDesiredSuspensionAndObservedProvenance(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{ + "metadata":{"name":"worker"}, + "spec":{"connector":"stripe","ledger":"main","suspend":false,"replicas":0}, + "status":{"suspendedBy":[ + {"kind":"ConnectorInstance","name":"worker","field":"spec.replicas"}, + {"kind":"Policy","name":"maintenance","field":"spec.mutate.suspend"} + ]} + }`), nil + })} + + instance, err := New("https://stack.example", httpClient).GetConnectorInstance(context.Background(), "worker") + + require.NoError(t, err) + require.NotNil(t, instance.Spec.Suspend) + require.False(t, *instance.Spec.Suspend) + require.NotNil(t, instance.Spec.Replicas) + require.Equal(t, int32(0), *instance.Spec.Replicas) + require.Equal(t, []SuspensionSource{ + {Kind: "ConnectorInstance", Name: "worker", Field: "spec.replicas"}, + {Kind: "Policy", Name: "maintenance", Field: "spec.mutate.suspend"}, + }, instance.Status.SuspendedBy) +} + +func TestClientRejectsMalformedAndEmptyObjectResponses(t *testing.T) { + tests := []struct { + name string + body string + call func(Client) error + }{ + { + name: "malformed JSON", + body: `{`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "empty connector object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "empty connector list object", + body: `{}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "empty connector version list object", + body: `{}`, + call: func(client Client) error { + _, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) + return err + }, + }, + { + name: "empty connector instance list object", + body: "null", + call: func(client Client) error { + _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "empty facet distribution object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetConnectorFacets(context.Background(), "") + return err + }, + }, + { + name: "empty query capabilities object", + body: `{}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, tt.body), nil + })} + + err := tt.call(New("https://stack.example", httpClient)) + + require.Error(t, err) + }) + } +} + +func TestClientRejectsStructurallyInvalidSuccessResponses(t *testing.T) { + tests := []struct { + name string + status int + body string + call func(Client) error + }{ + { + name: "connector root is not an object", + status: http.StatusOK, + body: `[]`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "whitespace-only connector object", + status: http.StatusOK, + body: "{ \n\t }", + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "connector list missing cursor", + status: http.StatusOK, + body: `{"data":[]}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "connector list has null data", + status: http.StatusOK, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":null}}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "connector instance list has null data", + status: http.StatusOK, + body: `{"cursor":{"pageSize":15,"hasMore":false}}`, + call: func(client Client) error { + _, err := client.ListConnectorInstances(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "connector missing metadata name", + status: http.StatusOK, + body: `{"metadata":{},"spec":{"displayName":"Stripe"}}`, + call: func(client Client) error { + _, err := client.GetConnector(context.Background(), "stripe") + return err + }, + }, + { + name: "connector list item missing metadata name", + status: http.StatusOK, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{},"spec":{}}]}}`, + call: func(client Client) error { + _, err := client.ListConnectors(context.Background(), ListOptions{}) + return err + }, + }, + { + name: "connector version missing image", + status: http.StatusOK, + body: `{"version":"v1.0.0"}`, + call: func(client Client) error { + _, err := client.GetConnectorVersion(context.Background(), "stripe", "v1.0.0") + return err + }, + }, + { + name: "connector version list item missing version", + status: http.StatusOK, + body: `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"image":"registry/stripe:v1.0.0"}]}}`, + call: func(client Client) error { + _, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) + return err + }, + }, + { + name: "connector instance missing metadata name", + status: http.StatusOK, + body: `{"metadata":{},"spec":{"connector":"stripe","ledger":"main"}}`, + call: func(client Client) error { + _, err := client.GetConnectorInstance(context.Background(), "worker") + return err + }, + }, + // A missing spec.connector or spec.ledger is deliberately absent from + // this table: neither is structurally invalid on a response. See + // TestListConnectorInstancesDecodesAnInstanceWithNoConnector. + { + name: "facet distribution missing facets", + status: http.StatusOK, + body: `{"total":3}`, + call: func(client Client) error { + _, err := client.GetConnectorFacets(context.Background(), "") + return err + }, + }, + { + name: "query capabilities missing resources", + status: http.StatusOK, + body: `{"resources":null}`, + call: func(client Client) error { + _, err := client.GetQueryCapabilities(context.Background()) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(tt.status, tt.body), nil + })} + + err := tt.call(New("https://stack.example", httpClient)) + + require.Error(t, err) + }) + } +} + +func TestClientAcceptsValidEmptyLists(t *testing.T) { + httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`), nil + })} + client := New("https://stack.example", httpClient) + + connectors, err := client.ListConnectors(context.Background(), ListOptions{}) + require.NoError(t, err) + require.Empty(t, connectors.Items) + require.False(t, connectors.HasMore) + require.Empty(t, connectors.Next) + versions, err := client.ListConnectorVersions(context.Background(), "stripe", ListOptions{}) + require.NoError(t, err) + require.Empty(t, versions.Items) + instances, err := client.ListConnectorInstances(context.Background(), ListOptions{}) + require.NoError(t, err) + require.Empty(t, instances.Items) +} diff --git a/internal/connectivityclient/integration_test.go b/internal/connectivityclient/integration_test.go new file mode 100644 index 00000000..cc59e1c9 --- /dev/null +++ b/internal/connectivityclient/integration_test.go @@ -0,0 +1,185 @@ +package connectivityclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGivenConnectivityServer_WhenLifecycleMethodsRun_ThenContractIsRespected(t *testing.T) { + // Given a connectivity server with the public lifecycle routes. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors": + require.Equal(t, "application/json", req.Header.Get("Accept")) + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe","latestVersion":"v1.0.0"}}]}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/_facets": + _, _ = io.WriteString(w, `{"total":1,"facets":{"provider":{"psp":1}}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/_query/capabilities": + _, _ = io.WriteString(w, `{"resources":{"connectors":{"name":{"operators":["$match","$in","$like"]}}}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe": + _, _ = io.WriteString(w, `{"metadata":{"name":"stripe"},"spec":{"displayName":"Stripe"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions": + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[{"version":"v1.0.0","image":"registry/stripe:v1.0.0"}]}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectors/stripe/versions/latest": + _, _ = io.WriteString(w, `{"version":"v1.0.0","image":"registry/stripe:v1.0.0","configSchema":{"env":{"properties":{}}}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectorinstances": + _, _ = io.WriteString(w, `{"cursor":{"pageSize":15,"hasMore":false,"data":[]}}`) + case req.Method == http.MethodPost && req.URL.Path == "/api/connectivity/connectorinstances": + require.Equal(t, "application/json", req.Header.Get("Content-Type")) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodGet && req.URL.Path == "/api/connectivity/connectorinstances/worker": + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodPatch && req.URL.Path == "/api/connectivity/connectorinstances/worker": + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"ledger"}}`) + case req.Method == http.MethodDelete && req.URL.Path == "/api/connectivity/connectorinstances/worker": + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, req) + } + })) + defer server.Close() + + client := New(server.URL, server.Client()) + ctx := context.Background() + + // When lifecycle methods use the server. + connectors, err := client.ListConnectors(ctx, ListOptions{}) + require.NoError(t, err) + facets, err := client.GetConnectorFacets(ctx, "") + require.NoError(t, err) + capabilities, err := client.GetQueryCapabilities(ctx) + require.NoError(t, err) + connector, err := client.GetConnector(ctx, "stripe") + require.NoError(t, err) + versions, err := client.ListConnectorVersions(ctx, "stripe", ListOptions{}) + require.NoError(t, err) + version, err := client.GetConnectorVersion(ctx, "stripe", VersionAliasLatest) + require.NoError(t, err) + instances, err := client.ListConnectorInstances(ctx, ListOptions{}) + require.NoError(t, err) + created, err := client.CreateConnectorInstance(ctx, ConnectorInstanceCreate{Name: "worker", Spec: ConnectorInstanceSpec{Connector: "stripe", Ledger: "ledger"}}) + require.NoError(t, err) + fetched, err := client.GetConnectorInstance(ctx, "worker") + require.NoError(t, err) + patched, err := client.PatchConnectorInstance(ctx, "worker", ConnectorInstancePatch{"spec": map[string]any{"pollInterval": "1m"}}) + require.NoError(t, err) + err = client.DeleteConnectorInstance(ctx, "worker") + require.NoError(t, err) + + // Then each response is decoded from the API contract. + require.Len(t, connectors.Items, 1) + require.Equal(t, "stripe", *connectors.Items[0].Metadata.Name) + require.Equal(t, "v1.0.0", *connectors.Items[0].Spec.LatestVersion) + require.Equal(t, int64(1), facets.Total) + require.Equal(t, int64(1), facets.Facets["provider"]["psp"]) + require.Equal(t, []string{"$match", "$in", "$like"}, capabilities.Resources[ResourceConnectors]["name"].Operators) + require.Equal(t, "stripe", *connector.Metadata.Name) + require.Len(t, versions.Items, 1) + require.Equal(t, "v1.0.0", versions.Items[0].Version) + require.Equal(t, "v1.0.0", version.Version) + require.NotEmpty(t, version.ConfigSchema) + require.Empty(t, instances.Items) + require.Equal(t, "worker", *created.Metadata.Name) + require.Equal(t, "worker", *fetched.Metadata.Name) + require.Equal(t, "worker", *patched.Metadata.Name) +} + +func TestGivenConnectivityAPI_WhenConfigIsPatched_ThenAPIShapeIsSent(t *testing.T) { + // Given a server asserting the documented ConnectorInstancePatch shape. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + require.Equal(t, http.MethodPatch, req.Method) + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + + var patch map[string]any + require.NoError(t, json.NewDecoder(req.Body).Decode(&patch)) + spec, ok := patch["spec"].(map[string]any) + require.True(t, ok) + require.NotContains(t, spec, "env") + require.NotContains(t, spec, "files") + require.Equal(t, "30s", spec["pollInterval"]) + config, ok := spec["config"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "API_PASSWORD": map[string]any{"value": "env-password"}, + }, config["env"]) + require.Equal(t, []any{ + map[string]any{"path": "/etc/plugin/key.pem", "value": "file-password"}, + map[string]any{"path": "/etc/plugin/config.json", "value": "complete-file-array"}, + }, config["files"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`) + })) + defer server.Close() + + client := New(server.URL, server.Client()) + envPassword := "env-password" + filePassword := "file-password" + otherFile := "complete-file-array" + + // When an API-shaped config patch contains password-compatible inline values. + _, err := client.PatchConnectorInstance(context.Background(), "worker", ConnectorInstancePatch{ + "spec": map[string]any{ + "config": &ConnectorInstanceConfig{ + Env: map[string]EnvValue{ + "API_PASSWORD": {Value: &envPassword}, + }, + Files: []FileMount{ + {Path: "/etc/plugin/key.pem", Value: &filePassword}, + {Path: "/etc/plugin/config.json", Value: &otherFile}, + }, + }, + "pollInterval": "30s", + }, + }) + + // Then the API receives spec.config verbatim and preserves the full files array. + require.NoError(t, err) +} + +func TestGivenConnectivityAPI_WhenSuspensionIsPatched_ThenExactBooleanBodyIsSent(t *testing.T) { + tests := []struct { + name string + suspend bool + want string + }{ + {name: "suspension", suspend: true, want: `{"spec":{"suspend":true}}`}, + {name: "resumption", suspend: false, want: `{"spec":{"suspend":false}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Given a Connectivity server accepting ConnectorInstance merge patches. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + require.Equal(t, http.MethodPatch, req.Method) + require.Equal(t, "/api/connectivity/connectorinstances/worker", req.URL.EscapedPath()) + require.Equal(t, "application/merge-patch+json", req.Header.Get("Content-Type")) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, test.want, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"metadata":{"name":"worker"},"spec":{"connector":"stripe","ledger":"main"}}`) + })) + defer server.Close() + + // When the client requests suspension or resumption. + _, err := New(server.URL, server.Client()).PatchConnectorInstance( + context.Background(), + "worker", + ConnectorInstancePatch{"spec": map[string]any{"suspend": test.suspend}}, + ) + + // Then the exact boolean, including false, reaches the HTTP route. + require.NoError(t, err) + }) + } +} diff --git a/internal/connectivityclient/models.go b/internal/connectivityclient/models.go new file mode 100644 index 00000000..e864ac62 --- /dev/null +++ b/internal/connectivityclient/models.go @@ -0,0 +1,214 @@ +package connectivityclient + +import ( + "context" + "net/http" + "time" +) + +const ( + ResourceConnectors = "connectors" + ResourceConnectorVersions = "connectorversions" + ResourceConnectorInstances = "connectorinstances" +) + +// VersionAliasLatest resolves to the newest Validated version across all +// channels; the channel aliases resolve to that channel's head with exactly +// the rules installation uses. Reserved by the API: never valid semver. +const VersionAliasLatest = "latest" + +var VersionAliases = []string{VersionAliasLatest, "stable", "rc", "beta", "alpha"} + +type ObjectMeta struct { + Name *string `json:"name,omitempty"` + Namespace *string `json:"namespace,omitempty"` + ResourceVersion *string `json:"resourceVersion,omitempty"` + UID *string `json:"uid,omitempty"` + CreationTimestamp *time.Time `json:"creationTimestamp,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type KeyRef struct { + Name string `json:"name"` + Key string `json:"key"` +} + +type EnvValue struct { + Value *string `json:"value,omitempty"` + SecretRef *KeyRef `json:"secretRef,omitempty"` + ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` +} + +type FileMount struct { + Path string `json:"path"` + Value *string `json:"value,omitempty"` + SecretRef *KeyRef `json:"secretRef,omitempty"` + ConfigMapRef *KeyRef `json:"configMapRef,omitempty"` + Mode *int32 `json:"mode,omitempty"` +} + +type ConnectorBranding struct { + DisplayName *string `json:"displayName,omitempty"` + AccentColor *string `json:"accentColor,omitempty"` + LogoSvg *string `json:"logoSvg,omitempty"` + LogoSvgDark *string `json:"logoSvgDark,omitempty"` +} + +type ConnectorSpec struct { + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` + Catalog *string `json:"catalog,omitempty"` + Tags []string `json:"tags,omitempty"` + Tagline *string `json:"tagline,omitempty"` + Branding *ConnectorBranding `json:"branding,omitempty"` + LatestVersion *string `json:"latestVersion,omitempty"` +} + +type ConnectorStatus struct { + Phase *string `json:"phase,omitempty"` + Message *string `json:"message,omitempty"` +} + +type Connector struct { + Metadata ObjectMeta `json:"metadata"` + Spec ConnectorSpec `json:"spec"` + Status *ConnectorStatus `json:"status,omitempty"` +} + +type ConnectorList struct { + Items []Connector `json:"items"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` +} + +type ConnectorVersionSummary struct { + Version string `json:"version"` + Image string `json:"image"` + Digest *string `json:"digest,omitempty"` + ReleaseDate *time.Time `json:"releaseDate,omitempty"` +} + +type ConnectorVersion struct { + Version string `json:"version"` + Image string `json:"image"` + Digest *string `json:"digest,omitempty"` + ReleaseDate *time.Time `json:"releaseDate,omitempty"` + ConfigSchema map[string]any `json:"configSchema,omitempty"` + AdditionalMetadata map[string]any `json:"additionalMetadata,omitempty"` +} + +type ConnectorVersionList struct { + Items []ConnectorVersionSummary `json:"items"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` +} + +type FacetDistribution struct { + Total int64 `json:"total"` + Facets map[string]map[string]int64 `json:"facets"` +} + +type QueryFieldCapability struct { + Operators []string `json:"operators"` + Enum []string `json:"enum,omitempty"` +} + +type QueryCapabilities struct { + Resources map[string]map[string]QueryFieldCapability `json:"resources"` +} + +type ConnectorInstanceConfig struct { + Env map[string]EnvValue `json:"env,omitempty"` + Files []FileMount `json:"files,omitempty"` +} + +type ConnectorInstanceSpec struct { + Connector string `json:"connector"` + Version *string `json:"version,omitempty"` + Channel *string `json:"channel,omitempty"` + ConnectivityRef *string `json:"connectivityRef,omitempty"` + Ledger string `json:"ledger"` + StartSequence *int64 `json:"startSequence,omitempty"` + PollInterval *string `json:"pollInterval,omitempty"` + Suspend *bool `json:"suspend,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` + Config *ConnectorInstanceConfig `json:"config,omitempty"` +} + +type SuspensionSource struct { + Kind string `json:"kind"` + Name string `json:"name"` + Field string `json:"field,omitempty"` +} + +type ConnectorInstanceStatus struct { + Phase *string `json:"phase,omitempty"` + State *string `json:"state,omitempty"` + ConnectorAddress *string `json:"connectorAddress,omitempty"` + ResolvedImage *string `json:"resolvedImage,omitempty"` + ResolvedConnectorRef *string `json:"resolvedConnectorRef,omitempty"` + ResolvedVersion *string `json:"resolvedVersion,omitempty"` + ResolvedDigest *string `json:"resolvedDigest,omitempty"` + CurrentSequence *int64 `json:"currentSequence,omitempty"` + SourceTipSequence *int64 `json:"sourceTipSequence,omitempty"` + LastError *string `json:"lastError,omitempty"` + Message *string `json:"message,omitempty"` + SuspendedBy []SuspensionSource `json:"suspendedBy,omitempty"` +} + +type ConnectorInstance struct { + Metadata ObjectMeta `json:"metadata"` + Spec ConnectorInstanceSpec `json:"spec"` + Status *ConnectorInstanceStatus `json:"status,omitempty"` +} + +type ConnectorInstanceList struct { + Items []ConnectorInstance `json:"items"` + PageSize int32 `json:"pageSize"` + HasMore bool `json:"hasMore"` + Next string `json:"next,omitempty"` +} + +type ConnectorInstanceCreate struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Spec ConnectorInstanceSpec `json:"spec"` +} + +type ConnectorInstancePatch map[string]any + +type ListOptions struct { + PageSize int32 + Cursor string + Query string +} + +type APIError struct { + StatusCode int + Code string + Message string + Details map[string]any +} + +type Client interface { + ListConnectors(context.Context, ListOptions) (*ConnectorList, error) + GetConnectorFacets(context.Context, string) (*FacetDistribution, error) + GetQueryCapabilities(context.Context) (*QueryCapabilities, error) + GetConnector(context.Context, string) (*Connector, error) + ListConnectorVersions(context.Context, string, ListOptions) (*ConnectorVersionList, error) + GetConnectorVersion(context.Context, string, string) (*ConnectorVersion, error) + ListConnectorInstances(context.Context, ListOptions) (*ConnectorInstanceList, error) + CreateConnectorInstance(context.Context, ConnectorInstanceCreate) (*ConnectorInstance, error) + GetConnectorInstance(context.Context, string) (*ConnectorInstance, error) + PatchConnectorInstance(context.Context, string, ConnectorInstancePatch) (*ConnectorInstance, error) + DeleteConnectorInstance(context.Context, string) error +} + +func New(stackURI string, httpClient *http.Client) Client { + return &client{stackURI: stackURI, httpClient: httpClient} +} diff --git a/pkg/clients.go b/pkg/clients.go index a7f1a798..9e2950a2 100644 --- a/pkg/clients.go +++ b/pkg/clients.go @@ -633,7 +633,8 @@ func NewAppDeployClientFromFlags( } type stackTokenSource struct { - mu sync.Mutex + mu sync.Mutex + ctx context.Context // Membership token stackToken AccessToken @@ -671,7 +672,7 @@ func (t *stackTokenSource) Token() (*oauth2.Token, error) { } if t.stackToken.Expired() { - newStackToken, err := Refresh(context.Background(), t.relyingParty, t.stackToken) + newStackToken, err := Refresh(t.ctx, t.relyingParty, t.stackToken) if err != nil { oidcErr := &oidc.Error{} if errors.As(err, &oidcErr) && (oidcErr.ErrorType == oidc.InvalidToken || oidcErr.ErrorType == oidc.InvalidRequest) { @@ -685,7 +686,7 @@ func (t *stackTokenSource) Token() (*oauth2.Token, error) { } } - token, err := FetchStackToken(context.Background(), t.relyingParty.HttpClient(), t.stackAccess.URI, t.stackToken.Token) + token, err := FetchStackToken(t.ctx, t.relyingParty.HttpClient(), t.stackAccess.URI, t.stackToken.Token) if err != nil { return nil, err } @@ -717,7 +718,12 @@ func NewStackTokenSource( organizationID string, stackID string, ) oauth2.TokenSource { + tokenContext := context.Background() + if cmd != nil && cmd.Context() != nil { + tokenContext = cmd.Context() + } return &stackTokenSource{ + ctx: tokenContext, stackToken: stackToken, stackAccess: stackAccess, relyingParty: relyingParty, diff --git a/pkg/clients_test.go b/pkg/clients_test.go new file mode 100644 index 00000000..12f84790 --- /dev/null +++ b/pkg/clients_test.go @@ -0,0 +1,170 @@ +package fctl + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/formancehq/go-libs/v4/oidc" + "github.com/formancehq/go-libs/v4/oidc/client" +) + +type tokenTestRelyingParty struct { + client.RelyingParty + httpClient *http.Client + oauthConfig *oauth2.Config +} + +func (r tokenTestRelyingParty) HttpClient() *http.Client { + return r.httpClient +} + +func (r tokenTestRelyingParty) OAuthConfig() *oauth2.Config { + return r.oauthConfig +} + +func TestStackTokenSourceDeadlineBoundsStackAPITokenFetch(t *testing.T) { + tokenStarted := make(chan struct{}) + releaseToken := make(chan struct{}) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"token_endpoint":%q}`, server.URL+"/token") + case "/token": + close(tokenStarted) + select { + case <-req.Context().Done(): + case <-releaseToken: + } + default: + http.NotFound(w, req) + } + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond) + defer cancel() + cmd := tokenSourceTestCommand(t, ctx) + stackToken := accessTokenExpiringAt(time.Now().Add(time.Hour)) + source := NewStackTokenSource( + stackToken, + &StackAccess{URI: server.URL}, + tokenTestRelyingParty{httpClient: server.Client()}, + func(AccessToken) error { return nil }, + cmd, + "profile", + "organization", + "stack", + ) + + result := make(chan error, 1) + go func() { + _, err := source.Token() + result <- err + }() + select { + case <-tokenStarted: + case <-time.After(time.Second): + close(releaseToken) + t.Fatal("stack API token endpoint was not called") + } + + select { + case err := <-result: + require.ErrorIs(t, err, context.DeadlineExceeded) + close(releaseToken) + case <-time.After(300 * time.Millisecond): + close(releaseToken) + <-result + t.Fatal("stack API token acquisition outlived the command deadline") + } +} + +func TestStackTokenSourceCancellationBoundsStackTokenRefresh(t *testing.T) { + tokenStarted := make(chan struct{}) + releaseToken := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/token" { + http.NotFound(w, req) + return + } + close(tokenStarted) + select { + case <-req.Context().Done(): + case <-releaseToken: + } + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := tokenSourceTestCommand(t, ctx) + source := NewStackTokenSource( + accessTokenExpiringAt(time.Now().Add(-time.Hour)), + &StackAccess{URI: server.URL}, + tokenTestRelyingParty{ + httpClient: server.Client(), + oauthConfig: &oauth2.Config{ + ClientID: "fctl", + Endpoint: oauth2.Endpoint{TokenURL: server.URL + "/token"}, + }, + }, + func(AccessToken) error { return nil }, + cmd, + "profile", + "organization", + "stack", + ) + + result := make(chan error, 1) + go func() { + _, err := source.Token() + result <- err + }() + select { + case <-tokenStarted: + case <-time.After(time.Second): + close(releaseToken) + t.Fatal("stack refresh token endpoint was not called") + } + cancel() + + select { + case err := <-result: + require.ErrorIs(t, err, context.Canceled) + close(releaseToken) + case <-time.After(300 * time.Millisecond): + close(releaseToken) + <-result + t.Fatal("stack token refresh outlived command cancellation") + } +} + +func tokenSourceTestCommand(t *testing.T, ctx context.Context) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.Flags().String(ConfigDir, t.TempDir(), "") + return cmd +} + +func accessTokenExpiringAt(expiry time.Time) AccessToken { + return AccessToken{ + TokenWithClaims: TokenWithClaims[AccessTokenClaims]{ + Token: "stack-token", + Claims: AccessTokenClaims{ + TokenClaims: oidc.TokenClaims{Expiration: oidc.Time(expiry.Unix())}, + }, + }, + Refresh: "refresh-token", + } +} diff --git a/pkg/http.go b/pkg/http.go index 044550c0..99c139f7 100644 --- a/pkg/http.go +++ b/pkg/http.go @@ -25,6 +25,7 @@ func printBody(data []byte) { if len(data) == 0 { return } + data = redactDebugBody(data) raw := make(map[string]any) if err := json.Unmarshal(data, &raw); err == nil { f := colorjson.NewFormatter() @@ -39,6 +40,45 @@ func printBody(data []byte) { } } +func redactDebugBody(data []byte) []byte { + var body any + if err := json.Unmarshal(data, &body); err != nil { + return append([]byte(nil), data...) + } + redactInlineConfigValues(body, false) + redacted, err := json.Marshal(body) + if err != nil { + return append([]byte(nil), data...) + } + return redacted +} + +func redactInlineConfigValues(value any, inConfig bool) { + switch value := value.(type) { + case map[string]any: + for key, child := range value { + if inConfig && key == "value" { + value[key] = "[REDACTED]" + continue + } + redactInlineConfigValues(child, inConfig || isConfigContainer(key)) + } + case []any: + for _, child := range value { + redactInlineConfigValues(child, inConfig) + } + } +} + +func isConfigContainer(key string) bool { + switch key { + case "config", "defaults", "env", "files": + return true + default: + return false + } +} + func debugRoundTripper(rt http.RoundTripper) RoundTripperFn { return func(req *http.Request) (*http.Response, error) { data, err := httputil.DumpRequest(req, false) diff --git a/pkg/http_test.go b/pkg/http_test.go index b39e9a11..0f0cae12 100644 --- a/pkg/http_test.go +++ b/pkg/http_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stretchr/testify/require" ) func TestNewHTTPTransportUsesProxyFromEnvironment(t *testing.T) { @@ -31,3 +32,88 @@ func TestNewHTTPTransportUsesProxyFromEnvironment(t *testing.T) { t.Fatal("expected HTTP transport proxy to be http.ProxyFromEnvironment") } } + +func TestRedactDebugBodyRemovesInlineConfigurationValues(t *testing.T) { + tests := []struct { + name string + body string + want string + leaks []string + }{ + { + name: "API-shaped request config", + body: `{ + "name":"worker", + "spec":{ + "value":"public-spec-value", + "config":{ + "env":{"PASSWORD":{"value":"request-env-secret"}}, + "files":[{"path":"/secret","value":"request-file-secret"}] + } + } + }`, + want: `{ + "name":"worker", + "spec":{ + "value":"public-spec-value", + "config":{ + "env":{"PASSWORD":{"value":"[REDACTED]"}}, + "files":[{"path":"/secret","value":"[REDACTED]"}] + } + } + }`, + leaks: []string{"request-env-secret", "request-file-secret"}, + }, + { + name: "pinned CRD-shaped patch", + body: `{ + "spec":{ + "env":{"PASSWORD":{"value":"patch-env-secret"}}, + "files":[{"path":"/secret","value":"patch-file-secret"}], + "other":{"value":"public-other-value"} + } + }`, + want: `{ + "spec":{ + "env":{"PASSWORD":{"value":"[REDACTED]"}}, + "files":[{"path":"/secret","value":"[REDACTED]"}], + "other":{"value":"public-other-value"} + } + }`, + leaks: []string{"patch-env-secret", "patch-file-secret"}, + }, + { + name: "response config and plugin defaults", + body: `{ + "items":[ + {"spec":{"config":{"env":{"TOKEN":{"value":"response-secret"}}}}}, + {"spec":{"defaults":{"files":[{"path":"/key","value":"default-secret"}]}}} + ] + }`, + want: `{ + "items":[ + {"spec":{"config":{"env":{"TOKEN":{"value":"[REDACTED]"}}}}}, + {"spec":{"defaults":{"files":[{"path":"/key","value":"[REDACTED]"}]}}} + ] + }`, + leaks: []string{"response-secret", "default-secret"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + redacted := redactDebugBody([]byte(tt.body)) + + require.JSONEq(t, tt.want, string(redacted)) + for _, leak := range tt.leaks { + require.NotContains(t, string(redacted), leak) + } + }) + } +} + +func TestRedactDebugBodyLeavesNonJSONUnchanged(t *testing.T) { + body := []byte("not-json") + + require.Equal(t, body, redactDebugBody(body)) +}