diff --git a/cmd/workflow/get/get.go b/cmd/workflow/get/get.go new file mode 100644 index 00000000..777fda36 --- /dev/null +++ b/cmd/workflow/get/get.go @@ -0,0 +1,149 @@ +package get + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +// Handler resolves a single workflow by name via the platform search API and +// prints the matching rows. It filters to the workflow's configured +// deployment-registry by default; that filter can be disabled with --all-registries. +type Handler struct { + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + settings *settings.Settings + resolvedRegistry settings.ResolvedRegistry + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler backed by a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + settings: ctx.Settings, + resolvedRegistry: ctx.ResolvedRegistry, + wdc: wdc, + } +} + +// NewHandlerWithClient builds a Handler with a pre-built WorkflowDataClient +// (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + settings: ctx.Settings, + resolvedRegistry: ctx.ResolvedRegistry, + wdc: wdc, + } +} + +// Execute searches the platform for workflows whose name matches the value in +// the target's user-workflow settings. When allRegistries is false (default), +// results are filtered to the workflow's configured deployment-registry. +func (h *Handler) Execute(ctx context.Context, allRegistries bool) error { + if h.tenantCtx == nil { + return fmt.Errorf("user context not available — run `cre login` and retry") + } + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + if h.settings == nil { + return fmt.Errorf("workflow settings not loaded; ensure workflow.yaml is valid") + } + + workflowName := strings.TrimSpace(h.settings.Workflow.UserWorkflowSettings.WorkflowName) + if workflowName == "" { + return fmt.Errorf("workflow-name is not set for target %q in workflow.yaml", h.settings.User.TargetName) + } + + // Resolve which registry to filter by (if any). When --all-registries is + // set we skip the filter entirely. Otherwise we use the deployment-registry + // from workflow.yaml; the registry-resolution pass that runs earlier + // already validated the value against the tenant context, so we look it + // up by ID here for a plain *tenantctx.Registry handle to pass to the + // filter. + var registryFilter *tenantctx.Registry + if !allRegistries { + filterID := strings.TrimSpace(h.settings.Workflow.UserWorkflowSettings.DeploymentRegistry) + if filterID == "" && h.resolvedRegistry != nil { + filterID = h.resolvedRegistry.ID() + } + if filterID != "" { + registryFilter = workflowrender.FindRegistry(h.tenantCtx.Registries, filterID) + if registryFilter == nil { + return fmt.Errorf("deployment-registry %q not found in user context; available: [%s]", + filterID, workflowrender.AvailableRegistryIDs(h.tenantCtx.Registries)) + } + } + } + + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Fetching workflow %q...", workflowName)) + rows, err := h.wdc.SearchByName(ctx, workflowName, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return err + } + + // The platform search uses a contains-style match; narrow to an exact + // (case-insensitive) name match so `get foo` does not surface `foo-staging`. + rows = filterByExactName(rows, workflowName) + + if registryFilter != nil { + rows = workflowrender.FilterRowsByRegistry(rows, registryFilter, h.tenantCtx.Registries) + } + + workflowrender.PrintWorkflowTable(rows, h.tenantCtx.Registries, workflowrender.TableOptions{}) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var allRegistries bool + + cmd := &cobra.Command{ + Use: "get ", + Short: "Shows metadata for the workflow configured in workflow.yaml", + Long: `Looks up the workflow whose name is configured for the selected --target in ` + + `workflow.yaml and prints its metadata from the CRE platform. By default results ` + + `are filtered to the workflow's configured deployment-registry; pass --all-registries ` + + `to show matches from every registry.`, + Example: `cre workflow get ./my-workflow --target staging + cre workflow get ./my-workflow --target staging --all-registries`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return NewHandler(runtimeContext).Execute(cmd.Context(), allRegistries) + }, + } + + cmd.Flags().BoolVar(&allRegistries, "all-registries", false, + "Do not filter results by the workflow's deployment-registry") + + return cmd +} + +func filterByExactName(rows []workflowrender.Workflow, name string) []workflowrender.Workflow { + out := make([]workflowrender.Workflow, 0, len(rows)) + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Name), name) { + out = append(out, r) + } + } + return out +} diff --git a/cmd/workflow/get/get_test.go b/cmd/workflow/get/get_test.go new file mode 100644 index 00000000..95943192 --- /dev/null +++ b/cmd/workflow/get/get_test.go @@ -0,0 +1,401 @@ +package get_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/rs/zerolog" + + cmdget "github.com/smartcontractkit/cre-cli/cmd/workflow/get" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func strPtr(s string) *string { return &s } + +// workflowServer starts an httptest.Server that responds to ListWorkflows +// with the provided pages (each call advances through pages) and records the +// raw request bodies so tests can assert the GQL variables that were sent. +type workflowServer struct { + *httptest.Server + requests []string +} + +func newWorkflowServer(t *testing.T, pages [][]map[string]string, totalCount int) *workflowServer { + t.Helper() + ws := &workflowServer{} + var call atomic.Int32 + ws.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + ws.requests = append(ws.requests, string(body)) + idx := int(call.Add(1)) - 1 + w.Header().Set("Content-Type", "application/json") + var data []map[string]string + if idx < len(pages) { + data = pages[idx] + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": totalCount, + "data": data, + }, + }, + }) + })) + return ws +} + +// buildSettings returns a minimal *settings.Settings populated with the +// workflow-name and deployment-registry under the "staging" target. +func buildSettings(workflowName, deploymentRegistry string) *settings.Settings { + s := &settings.Settings{ + User: settings.UserSettings{TargetName: "staging"}, + } + s.Workflow.UserWorkflowSettings.WorkflowName = workflowName + s.Workflow.UserWorkflowSettings.DeploymentRegistry = deploymentRegistry + return s +} + +func newHandlerWithServer(t *testing.T, rtCtx *runtime.Context, srv *workflowServer) *cmdget.Handler { + t.Helper() + logger := zerolog.Nop() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + envSet := &environments.EnvironmentSet{GraphQLURL: srv.URL} + if rtCtx.Credentials == nil { + rtCtx.Credentials = creds + } + if rtCtx.EnvironmentSet == nil { + rtCtx.EnvironmentSet = envSet + } + gql := graphqlclient.New(creds, envSet, &logger) + wdc := workflowdataclient.New(gql, &logger) + return cmdget.NewHandlerWithClient(rtCtx, wdc) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + fn() + w.Close() + os.Stderr = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestExecute_NoTenantContext(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + Settings: buildSettings("alpha", "private"), + } + + h := cmdget.NewHandlerWithClient(rtCtx, nil) + err := h.Execute(context.Background(), false) + if err == nil || !strings.Contains(err.Error(), "user context not available") { + t.Fatalf("expected tenant-context error, got %v", err) + } +} + +func TestExecute_NoCredentials(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{{ID: "private"}}}, + Settings: buildSettings("alpha", "private"), + } + + h := cmdget.NewHandlerWithClient(rtCtx, nil) + err := h.Execute(context.Background(), false) + if err == nil || !strings.Contains(err.Error(), "credentials not available") { + t.Fatalf("expected credentials error, got %v", err) + } +} + +func TestExecute_MissingWorkflowName(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{{ID: "private"}}}, + Settings: buildSettings("", "private"), + } + + h := cmdget.NewHandlerWithClient(rtCtx, nil) + err := h.Execute(context.Background(), false) + if err == nil || !strings.Contains(err.Error(), "workflow-name is not set") { + t.Fatalf("expected missing workflow-name error, got %v", err) + } +} + +func TestExecute_UnknownDeploymentRegistry(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettings("alpha", "does-not-exist"), + } + + h := cmdget.NewHandlerWithClient(rtCtx, nil) + err := h.Execute(context.Background(), false) + if err == nil || !strings.Contains(err.Error(), "not found in user context") { + t.Fatalf("expected unknown registry error, got %v", err) + } +} + +func TestExecute_FiltersByDeploymentRegistry(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:testnet", + ChainSelector: strPtr("12345678901234567890"), + Address: strPtr("0xcafebabe00000000000000000000000000feed"), + }, + {ID: "private", Type: "off-chain"}, + }, + }, + Settings: buildSettings("alpha", "private"), + } + + // Server returns two rows with the same name, on two different registries. + page := []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "alpha", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + } + srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + // Only the row whose source resolves to the "private" deployment-registry + // should be printed. + if got := strings.Count(out, "1. alpha"); got != 1 { + t.Errorf("expected exactly one workflow row, got %d:\n%s", got, out) + } + wantID := "1010101010101010101010101010101010101010101010101010101010101010" + if !strings.Contains(out, wantID) { + t.Errorf("expected private-registry workflow id in output:\n%s", out) + } + if strings.Contains(out, "3030303030303030303030303030303030303030303030303030303030303030") { + t.Errorf("on-chain row should have been filtered out:\n%s", out) + } + + // The GQL call should have forwarded the workflow name in the search arg. + if len(srv.requests) == 0 || !strings.Contains(srv.requests[0], `"search":"alpha"`) { + t.Errorf("expected search variable to be set to workflow name; requests=%v", srv.requests) + } +} + +func TestExecute_AllRegistriesSkipsFilter(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:testnet", + ChainSelector: strPtr("12345678901234567890"), + Address: strPtr("0xcafebabe00000000000000000000000000feed"), + }, + {ID: "private", Type: "off-chain"}, + }, + }, + Settings: buildSettings("alpha", "private"), + } + + page := []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "alpha", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + } + srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if got := strings.Count(out, "alpha"); got < 2 { + t.Errorf("expected both rows when --all-registries is set:\n%s", out) + } + if !strings.Contains(out, "0xcafebabe00000000000000000000000000feed") { + t.Errorf("expected on-chain row to appear with --all-registries:\n%s", out) + } +} + +func TestExecute_ExactNameMatchNarrowsSearch(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettings("alpha", "private"), + } + + // The platform search matches substrings, so the server returns both + // "alpha" and "alpha-staging" — only the exact match should be printed. + page := []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "alpha-staging", + "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", + "ownerAddress": "6060606060606060606060606060606060606060", + "status": "ACTIVE", + "workflowSource": "private", + }, + } + srv := newWorkflowServer(t, [][]map[string]string{page}, len(page)) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "1. alpha") { + t.Errorf("expected exact-match row in output:\n%s", out) + } + if strings.Contains(out, "alpha-staging") { + t.Errorf("did not expect substring-only match alpha-staging:\n%s", out) + } +} + +func TestExecute_NoMatchPrintsEmptyState(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Type: "off-chain"}}, + }, + Settings: buildSettings("alpha", "private"), + } + + srv := newWorkflowServer(t, [][]map[string]string{{}}, 0) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + var errOut string + captureStdout(t, func() { + errOut = captureStderr(t, func() { + if err := h.Execute(context.Background(), false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + }) + + if !strings.Contains(errOut, "No workflows found") { + t.Errorf("expected empty-state warning on stderr; got:\n%s", errOut) + } +} + +func TestNew_RequiresOneArg(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{}, + TenantContext: &tenantctx.EnvironmentContext{}, + Settings: buildSettings("alpha", "private"), + } + + cmd := cmdget.New(rtCtx) + cmd.SetArgs([]string{}) // no args + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when no workflow folder path is provided") + } +} diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index 5c876c29..eed93d49 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -12,12 +12,9 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" ) -// Workflow is a type alias so that print.go and registry.go in this package -// can use the name without importing workflowdataclient directly. -type Workflow = workflowdataclient.Workflow - const outputFormatJSON = "json" // Inputs holds the resolved and validated flag values for the list command. @@ -83,9 +80,9 @@ func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { } if inputs.RegistryFilter != "" { - if findRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) == nil { + if workflowrender.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) == nil { return fmt.Errorf("registry %q not found in user context; available: [%s]", - inputs.RegistryFilter, availableRegistryIDs(h.tenantCtx.Registries)) + inputs.RegistryFilter, workflowrender.AvailableRegistryIDs(h.tenantCtx.Registries)) } } @@ -98,20 +95,23 @@ func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { } if inputs.RegistryFilter != "" { - reg := findRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) - rows = filterRowsByRegistry(rows, reg, h.tenantCtx.Registries) + reg := workflowrender.FindRegistry(h.tenantCtx.Registries, inputs.RegistryFilter) + rows = workflowrender.FilterRowsByRegistry(rows, reg, h.tenantCtx.Registries) } afterRegistryFilter := len(rows) if !inputs.IncludeDeleted { - rows = omitDeleted(rows) + rows = workflowrender.OmitDeleted(rows) } if inputs.OutputFormat == outputFormatJSON { - return printWorkflowsJSON(rows, h.tenantCtx.Registries) + return workflowrender.PrintWorkflowsJSON(rows, h.tenantCtx.Registries) } - printWorkflowTable(rows, h.tenantCtx.Registries, afterRegistryFilter, inputs.IncludeDeleted) + workflowrender.PrintWorkflowTable(rows, h.tenantCtx.Registries, workflowrender.TableOptions{ + CountBeforeDeletedFilter: afterRegistryFilter, + IncludeDeleted: inputs.IncludeDeleted, + }) return nil } diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index cc39b70c..f0e45bcc 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -8,6 +8,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/convert" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" + workflowget "github.com/smartcontractkit/cre-cli/cmd/workflow/get" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" @@ -35,6 +36,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(simulate.New(runtimeContext)) workflowCmd.AddCommand(limits.New()) workflowCmd.AddCommand(workflowlist.New(runtimeContext)) + workflowCmd.AddCommand(workflowget.New(runtimeContext)) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index d30f9b39..44e6b8cd 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -35,6 +35,7 @@ cre workflow [optional flags] * [cre workflow custom-build](cre_workflow_custom-build.md) - Converts an existing workflow to a custom (self-compiled) build * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract +* [cre workflow get](cre_workflow_get.md) - Shows metadata for the workflow configured in workflow.yaml * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes * [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits * [cre workflow list](cre_workflow_list.md) - Lists workflows deployed for your organization diff --git a/docs/cre_workflow_get.md b/docs/cre_workflow_get.md new file mode 100644 index 00000000..c60cc49a --- /dev/null +++ b/docs/cre_workflow_get.md @@ -0,0 +1,41 @@ +## cre workflow get + +Shows metadata for the workflow configured in workflow.yaml + +### Synopsis + +Looks up the workflow whose name is configured for the selected --target in workflow.yaml and prints its metadata from the CRE platform. By default results are filtered to the workflow's configured deployment-registry; pass --all-registries to show matches from every registry. + +``` +cre workflow get [optional flags] +``` + +### Examples + +``` +cre workflow get ./my-workflow --target staging + cre workflow get ./my-workflow --target staging --all-registries +``` + +### Options + +``` + --all-registries Do not filter results by the workflow's deployment-registry + -h, --help help for get +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows + diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go index 1af3f272..275b16c0 100644 --- a/internal/client/workflowdataclient/workflowdataclient.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -64,6 +64,16 @@ type listWorkflowsEnvelope struct { // ListAll pages through the ListWorkflows query and returns all workflows. func (c *Client) ListAll(ctx context.Context, pageSize int) ([]Workflow, error) { + return c.list(ctx, pageSize, "") +} + +// SearchByName pages through the ListWorkflows query with the given search +// filter (server-side contains match on workflow name). +func (c *Client) SearchByName(ctx context.Context, name string, pageSize int) ([]Workflow, error) { + return c.list(ctx, pageSize, name) +} + +func (c *Client) list(ctx context.Context, pageSize int, search string) ([]Workflow, error) { if pageSize <= 0 { pageSize = DefaultPageSize } @@ -73,12 +83,16 @@ func (c *Client) ListAll(ctx context.Context, pageSize int) ([]Workflow, error) for pageNum := 0; ; pageNum++ { req := graphql.NewRequest(listWorkflowsQuery) - req.Var("input", map[string]any{ + input := map[string]any{ "page": map[string]any{ "number": pageNum, "size": pageSize, }, - }) + } + if search != "" { + input["search"] = search + } + req.Var("input", input) var env listWorkflowsEnvelope if err := c.graphql.Execute(ctx, req, &env); err != nil { @@ -99,6 +113,6 @@ func (c *Client) ListAll(ctx context.Context, pageSize int) ([]Workflow, error) } } - c.log.Debug().Int("count", len(all)).Msg("Listed workflows from platform") + c.log.Debug().Int("count", len(all)).Str("search", search).Msg("Listed workflows from platform") return all, nil } diff --git a/cmd/workflow/list/print.go b/internal/workflowrender/print.go similarity index 60% rename from cmd/workflow/list/print.go rename to internal/workflowrender/print.go index 046fc7e6..5a8c15e7 100644 --- a/cmd/workflow/list/print.go +++ b/internal/workflowrender/print.go @@ -1,4 +1,4 @@ -package list +package workflowrender import ( "encoding/json" @@ -22,17 +22,17 @@ type workflowJSON struct { func buildWorkflowJSON(rows []Workflow, registries []*tenantctx.Registry) []workflowJSON { out := make([]workflowJSON, 0, len(rows)) for _, r := range rows { - matched := resolveWorkflowRegistry(r.WorkflowSource, registries) + matched := ResolveWorkflowRegistry(r.WorkflowSource, registries) entry := workflowJSON{ Name: r.Name, WorkflowID: r.WorkflowID, OwnerAddress: r.OwnerAddress, Status: r.Status, - Registry: formatRegistryIDFromResolved(r.WorkflowSource, matched), + Registry: RegistryIDOrSource(r.WorkflowSource, matched), } - if matched != nil && registryEligibleForContractRows(matched) && matched.Address != nil { + if matched != nil && RegistryEligibleForContractRows(matched) && matched.Address != nil { entry.ContractAddress = strings.TrimSpace(*matched.Address) - } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + } else if _, addr, ok := ParseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { entry.ContractAddress = strings.TrimSpace(addr) } out = append(out, entry) @@ -40,8 +40,8 @@ func buildWorkflowJSON(rows []Workflow, registries []*tenantctx.Registry) []work return out } -// printWorkflowsJSON marshals workflows as an indented JSON array and writes it to stdout. -func printWorkflowsJSON(rows []Workflow, registries []*tenantctx.Registry) error { +// PrintWorkflowsJSON marshals workflows as an indented JSON array and writes it to stdout. +func PrintWorkflowsJSON(rows []Workflow, registries []*tenantctx.Registry) error { data, err := json.MarshalIndent(buildWorkflowJSON(rows, registries), "", " ") if err != nil { return err @@ -50,7 +50,8 @@ func printWorkflowsJSON(rows []Workflow, registries []*tenantctx.Registry) error return nil } -func omitDeleted(rows []Workflow) []Workflow { +// OmitDeleted returns rows whose status is not "DELETED" (case-insensitive). +func OmitDeleted(rows []Workflow) []Workflow { out := make([]Workflow, 0, len(rows)) for _, r := range rows { if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { @@ -61,10 +62,22 @@ func omitDeleted(rows []Workflow) []Workflow { return out } -func printWorkflowTable(rows []Workflow, registries []*tenantctx.Registry, afterRegistryFilter int, includeDeleted bool) { +// TableOptions controls the empty-state hint printed by PrintWorkflowTable. +type TableOptions struct { + // CountBeforeDeletedFilter is the number of rows after any registry/search + // filtering but before DELETED rows were removed. When it is > 0 and + // IncludeDeleted is false, the empty-state message hints at --include-deleted. + CountBeforeDeletedFilter int + // IncludeDeleted indicates whether the caller is already showing DELETED rows. + IncludeDeleted bool +} + +// PrintWorkflowTable renders the workflow rows as a bulleted table using the +// shared UI helpers. +func PrintWorkflowTable(rows []Workflow, registries []*tenantctx.Registry, opts TableOptions) { ui.Line() if len(rows) == 0 { - if afterRegistryFilter > 0 && !includeDeleted { + if opts.CountBeforeDeletedFilter > 0 && !opts.IncludeDeleted { ui.Warning("No workflows found (excluding deleted). Use --include-deleted to list them.") } else { ui.Warning("No workflows found") @@ -77,16 +90,16 @@ func printWorkflowTable(rows []Workflow, registries []*tenantctx.Registry, after ui.Line() for i, r := range rows { - matchedReg := resolveWorkflowRegistry(r.WorkflowSource, registries) - regIDCol := formatRegistryIDFromResolved(r.WorkflowSource, matchedReg) + matchedReg := ResolveWorkflowRegistry(r.WorkflowSource, registries) + regIDCol := RegistryIDOrSource(r.WorkflowSource, matchedReg) ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) ui.Dim(fmt.Sprintf(" Registry: %s", regIDCol)) - if matchedReg != nil && registryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { + if matchedReg != nil && RegistryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matchedReg.Address))) - } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + } else if _, addr, ok := ParseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) } ui.Line() diff --git a/cmd/workflow/list/registry.go b/internal/workflowrender/registry.go similarity index 68% rename from cmd/workflow/list/registry.go rename to internal/workflowrender/registry.go index 847f52c4..6a9f9e19 100644 --- a/cmd/workflow/list/registry.go +++ b/internal/workflowrender/registry.go @@ -1,17 +1,28 @@ -package list +// Package workflowrender contains helpers for matching platform workflow +// rows to registries in the tenant context and rendering them as a table. +// It is shared by the workflow list and get commands. +// +// The list API returns workflowSource as either the raw registry id (e.g. +// "private"), a "contract::<0x…>" tuple for on-chain rows, or +// a "grpc:<…>" string for off-chain rows — so direct equality with the +// context registry id only works in the first case. +package workflowrender import ( "strings" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" "github.com/smartcontractkit/cre-cli/internal/tenantctx" ) -// Registry matching: user context stores registry id -// plus chain_selector and address, while the list API returns workflowSource as -// contract::<0x…> or grpc:… — not the manifest id string. Direct equality with -// reg.ID therefore only applies when the API echoes the same id (e.g. "private"). +// Workflow is a type alias so callers can refer to the row type without +// importing the data client directly. +type Workflow = workflowdataclient.Workflow -func filterRowsByRegistry(rows []Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []Workflow { +// FilterRowsByRegistry returns only the rows that resolve to the given +// registry in the provided tenant context. A nil registry is treated as +// "no filter" and rows is returned unchanged. +func FilterRowsByRegistry(rows []Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []Workflow { if reg == nil { return rows } @@ -24,6 +35,83 @@ func filterRowsByRegistry(rows []Workflow, reg *tenantctx.Registry, all []*tenan return out } +// FindRegistry returns the registry entry with the matching ID, or nil. +func FindRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { + for _, r := range registries { + if r != nil && r.ID == id { + return r + } + } + return nil +} + +// AvailableRegistryIDs returns a comma-separated list of registry IDs for +// use in error messages. +func AvailableRegistryIDs(registries []*tenantctx.Registry) string { + ids := make([]string, 0, len(registries)) + for _, r := range registries { + if r != nil { + ids = append(ids, r.ID) + } + } + return strings.Join(ids, ", ") +} + +// ResolveWorkflowRegistry returns the registry in the tenant context that +// best matches the given workflowSource, or nil if none match. +func ResolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { + byID := registryByWorkflowSource(registries) + if reg, ok := byID[workflowSource]; ok { + return reg + } + + if cr := findContractRegistry(workflowSource, registries); cr != nil { + return cr + } + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } + + if strings.HasPrefix(workflowSource, "grpc:") { + return resolveGrpcSourceRegistry(workflowSource, registries) + } + + return nil +} + +// RegistryIDOrSource returns the matched registry's ID, falling back to the +// raw workflowSource when no registry resolves cleanly. +func RegistryIDOrSource(workflowSource string, matched *tenantctx.Registry) string { + if matched != nil { + return matched.ID + } + return workflowSource +} + +// RegistryEligibleForContractRows reports whether a registry can legitimately +// own on-chain ("contract:…") workflow sources. +func RegistryEligibleForContractRows(reg *tenantctx.Registry) bool { + if reg == nil || !hasContractAddress(reg) { + return false + } + if registryTypeOffChain(reg) { + return false + } + return true +} + +// ParseContractWorkflowSource splits a "contract::" +// workflow source. ok is false when the prefix is not present. +func ParseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return "", "", false + } + rest := strings.TrimPrefix(workflowSource, contractPrefix) + return strings.Cut(rest, ":") +} + func workflowSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { if workflowSource == reg.ID { return true @@ -57,16 +145,6 @@ func hasContractAddress(reg *tenantctx.Registry) bool { return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" } -func registryEligibleForContractRows(reg *tenantctx.Registry) bool { - if reg == nil || !hasContractAddress(reg) { - return false - } - if registryTypeOffChain(reg) { - return false - } - return true -} - func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { if reg == nil { return false @@ -96,7 +174,7 @@ func findContractRegistry(workflowSource string, registries []*tenantctx.Registr return nil } for _, r := range registries { - if !registryEligibleForContractRows(r) { + if !RegistryEligibleForContractRows(r) { continue } if !addressesEqual(addr, *r.Address) { @@ -111,15 +189,6 @@ func findContractRegistry(workflowSource string, registries []*tenantctx.Registr return nil } -func parseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { - const contractPrefix = "contract:" - if !strings.HasPrefix(workflowSource, contractPrefix) { - return "", "", false - } - rest := strings.TrimPrefix(workflowSource, contractPrefix) - return strings.Cut(rest, ":") -} - func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { if !strings.HasPrefix(workflowSource, "grpc:") { return nil @@ -149,34 +218,6 @@ func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) return match } -func resolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { - byID := registryByWorkflowSource(registries) - if reg, ok := byID[workflowSource]; ok { - return reg - } - - if cr := findContractRegistry(workflowSource, registries); cr != nil { - return cr - } - const contractPrefix = "contract:" - if strings.HasPrefix(workflowSource, contractPrefix) { - return nil - } - - if strings.HasPrefix(workflowSource, "grpc:") { - return resolveGrpcSourceRegistry(workflowSource, registries) - } - - return nil -} - -func formatRegistryIDFromResolved(workflowSource string, matched *tenantctx.Registry) string { - if matched != nil { - return matched.ID - } - return workflowSource -} - func addressesEqual(a, b string) bool { return strings.EqualFold( strings.TrimPrefix(strings.TrimSpace(a), "0x"), @@ -193,22 +234,3 @@ func registryByWorkflowSource(registries []*tenantctx.Registry) map[string]*tena } return m } - -func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { - for _, r := range registries { - if r != nil && r.ID == id { - return r - } - } - return nil -} - -func availableRegistryIDs(registries []*tenantctx.Registry) string { - ids := make([]string, 0, len(registries)) - for _, r := range registries { - if r != nil { - ids = append(ids, r.ID) - } - } - return strings.Join(ids, ", ") -} diff --git a/cmd/workflow/list/registry_test.go b/internal/workflowrender/registry_test.go similarity index 99% rename from cmd/workflow/list/registry_test.go rename to internal/workflowrender/registry_test.go index d469c10b..4bad568c 100644 --- a/cmd/workflow/list/registry_test.go +++ b/internal/workflowrender/registry_test.go @@ -1,4 +1,4 @@ -package list +package workflowrender import ( "testing"