diff --git a/changelog/unreleased/Inventory-ADDED-20260630-125338.yaml b/changelog/unreleased/Inventory-ADDED-20260630-125338.yaml new file mode 100644 index 000000000..6f1f93236 --- /dev/null +++ b/changelog/unreleased/Inventory-ADDED-20260630-125338.yaml @@ -0,0 +1,8 @@ +component: Inventory +kind: ADDED +body: New `cy inventory` commands to browse Terraform state inventory. `cy inventory outputs list` and `cy inventory resources list` support project/environment/component scoping, curated filter flags, and a generic repeatable `--filter 'attribute[condition]=value'`. `cy inventory outputs get ` prints the raw output value (scriptable), or the full object with `-o json`. +time: 2026-06-30T12:53:38Z +custom: + TYPE: CLI + PR: 469 + DETAILS: "" diff --git a/cmd/cycloid/inventory/cmd.go b/cmd/cycloid/inventory/cmd.go new file mode 100644 index 000000000..c5e3973c7 --- /dev/null +++ b/cmd/cycloid/inventory/cmd.go @@ -0,0 +1,22 @@ +package inventory + +import ( + "github.com/spf13/cobra" +) + +// NewCommands returns the root "inventory" command with all subcommands attached. +func NewCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "inventory", + Aliases: []string{"inv"}, + Short: "Manage the inventory", + Long: "Browse Terraform state outputs and resources tracked by the Cycloid inventory.", + } + + cmd.AddCommand( + newOutputsCmd(), + newResourcesCmd(), + ) + + return cmd +} diff --git a/cmd/cycloid/inventory/common.go b/cmd/cycloid/inventory/common.go new file mode 100644 index 000000000..54d0211cf --- /dev/null +++ b/cmd/cycloid/inventory/common.go @@ -0,0 +1,44 @@ +package inventory + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" +) + +// addScopingFlags registers the shared project/environment/component flags +// (from cyargs) used to scope inventory queries. +func addScopingFlags(cmd *cobra.Command) { + cyargs.AddProjectFlag(cmd) + cyargs.AddEnvFlag(cmd) + cyargs.AddComponentFlag(cmd) +} + +// appendScopingFilters reads the optional --project / --env / --component flags +// and appends the matching LHS filters when they are set. +func appendScopingFilters(cmd *cobra.Command, filters []middleware.LHSFilter) ([]middleware.LHSFilter, error) { + project, err := cyargs.GetProjectOrEmpty(cmd) + if err != nil { + return nil, err + } + env, err := cyargs.GetEnvOrEmpty(cmd) + if err != nil { + return nil, err + } + component, err := cyargs.GetComponentOrEmpty(cmd) + if err != nil { + return nil, err + } + + if project != "" { + filters = append(filters, middleware.LHSFilter{Attribute: "project_canonical", Condition: "eq", Value: project}) + } + if env != "" { + filters = append(filters, middleware.LHSFilter{Attribute: "environment_canonical", Condition: "eq", Value: env}) + } + if component != "" { + filters = append(filters, middleware.LHSFilter{Attribute: "component_canonical", Condition: "eq", Value: component}) + } + return filters, nil +} diff --git a/cmd/cycloid/inventory/outputs.go b/cmd/cycloid/inventory/outputs.go new file mode 100644 index 000000000..4fe63b94c --- /dev/null +++ b/cmd/cycloid/inventory/outputs.go @@ -0,0 +1,30 @@ +package inventory + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/printer" +) + +// outputsTableOptions is the shared table view for inventory outputs. +var outputsTableOptions = printer.Options{ + Columns: []string{"Key", "Type", "Sensitive", "Pinned", "Description"}, + Identifier: "Key", +} + +func newOutputsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "outputs", + Aliases: []string{"output", "out"}, + Short: "Manage inventory outputs", + Long: "List and fetch Terraform state outputs tracked by the Cycloid inventory.", + Args: cobra.NoArgs, + } + + cmd.AddCommand( + newOutputsListCmd(), + newOutputsGetCmd(), + ) + + return cmd +} diff --git a/cmd/cycloid/inventory/outputs_get.go b/cmd/cycloid/inventory/outputs_get.go new file mode 100644 index 000000000..b56954c5d --- /dev/null +++ b/cmd/cycloid/inventory/outputs_get.go @@ -0,0 +1,96 @@ +package inventory + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" +) + +func newOutputsGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Args: cobra.ExactArgs(1), + Short: "Get the value of a single inventory output", + Long: `Get the value of a named Terraform state output. + +By default this prints only the raw value (via the "value" field output), +which is convenient for scripting: + + IP=$(cy --org my-org inventory outputs get instance_ip --project p --env e) + +Output keys are scoped to a project/environment/component; if a key matches +more than one output, narrow the result with --project / --env / --component. + +Override the default with -o json (full object), -o yaml, or -o jq=.`, + Example: ` + # Print the raw value (default — scriptable) + cy --org my-org inventory outputs get instance_ip --project my-project --env prod + + # Get the full object as JSON + cy --org my-org inventory outputs get instance_ip -o json +`, + RunE: outputsGet, + } + + addScopingFlags(cmd) + cyargs.AddLHSFilterFlag(cmd) + cyout.RegisterModel(cmd, middleware.InventoryOutput{}) + + return cmd +} + +func outputsGet(cmd *cobra.Command, args []string) error { + key := args[0] + + // Step 1: all flags first. + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + filters, err := cyargs.GetLHSFilters(cmd) + if err != nil { + return err + } + filters, err = appendScopingFilters(cmd, filters) + if err != nil { + return err + } + filters = append(filters, middleware.LHSFilter{Attribute: "output_key", Condition: "eq", Value: key}) + + // Step 2: API + middleware. + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + outputs, _, err := m.ListInventoryOutputs(org, filters...) + if err != nil { + return cyout.PrintWithOptions(cmd, nil, err, "unable to fetch inventory output", outputsTableOptions) + } + + switch len(outputs) { + case 0: + return fmt.Errorf("output %q not found in org %q (try --project / --env / --component to scope the search)", key, org) + + case 1: + // Default to the raw value (field printer) unless the user asked for a + // specific format via --output / --jq / CY_OUTPUT / config. + effective, err := cyargs.GetOutput(cmd) + if err != nil { + return err + } + if effective == "table" && !cmd.Flags().Changed("output") && !cmd.Flags().Changed("jq") { + if err := cmd.Flags().Set("output", "value"); err != nil { + return err + } + } + return cyout.PrintWithOptions(cmd, outputs[0], nil, "", outputsTableOptions) + + default: + return fmt.Errorf("output %q matched %d results across multiple scopes; narrow with --project / --env / --component", key, len(outputs)) + } +} diff --git a/cmd/cycloid/inventory/outputs_list.go b/cmd/cycloid/inventory/outputs_list.go new file mode 100644 index 000000000..83e8c1448 --- /dev/null +++ b/cmd/cycloid/inventory/outputs_list.go @@ -0,0 +1,81 @@ +package inventory + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" +) + +func newOutputsListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Args: cobra.NoArgs, + Short: "List inventory outputs", + Example: ` + # List all outputs for an org + cy --org my-org inventory outputs list + + # Filter by project and environment + cy --org my-org inventory outputs list --project my-project --env prod + + # Show only pinned outputs as JSON + cy --org my-org inventory outputs list --pinned -o json + + # Use a raw LHS filter + cy --org my-org inventory outputs list --filter 'output_type[eq]=string' +`, + RunE: outputsList, + } + + addScopingFlags(cmd) + cyargs.AddLHSFilterFlag(cmd) + cmd.Flags().String("key", "", "Filter by output key name") + cmd.Flags().String("type", "", "Filter by output type (e.g. string, number, bool, list, map, object)") + cmd.Flags().Bool("pinned", false, "Show only pinned outputs") + cyout.RegisterModel(cmd, middleware.InventoryOutput{}) + + return cmd +} + +func outputsList(cmd *cobra.Command, args []string) error { + // Step 1: all flags first. + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + filters, err := cyargs.GetLHSFilters(cmd) + if err != nil { + return err + } + filters, err = appendScopingFilters(cmd, filters) + if err != nil { + return err + } + + if key, _ := cmd.Flags().GetString("key"); key != "" { + filters = append(filters, middleware.LHSFilter{Attribute: "output_key", Condition: "eq", Value: key}) + } + if outputType, _ := cmd.Flags().GetString("type"); outputType != "" { + filters = append(filters, middleware.LHSFilter{Attribute: "output_type", Condition: "eq", Value: outputType}) + } + if cmd.Flags().Changed("pinned") { + pinned, _ := cmd.Flags().GetBool("pinned") + val := "false" + if pinned { + val = "true" + } + filters = append(filters, middleware.LHSFilter{Attribute: "output_is_pinned", Condition: "eq", Value: val}) + } + + // Step 2: API + middleware. + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + // Step 3: call + print. + outputs, _, err := m.ListInventoryOutputs(org, filters...) + return cyout.PrintWithOptions(cmd, outputs, err, "unable to list inventory outputs", outputsTableOptions) +} diff --git a/cmd/cycloid/inventory/resources.go b/cmd/cycloid/inventory/resources.go new file mode 100644 index 000000000..b7a1a2d03 --- /dev/null +++ b/cmd/cycloid/inventory/resources.go @@ -0,0 +1,29 @@ +package inventory + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/printer" +) + +// resourcesTableOptions is the shared table view for inventory resources. +var resourcesTableOptions = printer.Options{ + Columns: []string{"Name", "Provider", "Type", "Module", "Mode", "Label"}, + Identifier: "Name", +} + +func newResourcesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resources", + Aliases: []string{"resource", "res"}, + Short: "Manage inventory resources", + Long: "List Terraform resources tracked by the Cycloid inventory.", + Args: cobra.NoArgs, + } + + cmd.AddCommand( + newResourcesListCmd(), + ) + + return cmd +} diff --git a/cmd/cycloid/inventory/resources_list.go b/cmd/cycloid/inventory/resources_list.go new file mode 100644 index 000000000..faccbec88 --- /dev/null +++ b/cmd/cycloid/inventory/resources_list.go @@ -0,0 +1,87 @@ +package inventory + +import ( + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/client/models" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/common" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" + "github.com/cycloidio/cycloid-cli/internal/cyout" +) + +// resourceNamedFilters maps inline list flags to their LHS attribute names. +var resourceNamedFilters = []struct { + flag string + attr string + desc string +}{ + {"provider", "resources_provider", "Filter by resource provider (e.g. aws, google, azurerm)"}, + {"type", "resources_type", "Filter by resource type (e.g. aws_instance)"}, + {"name", "resources_name", "Filter by resource name (display name)"}, + {"module", "resources_module", "Filter by module name"}, + {"mode", "resources_mode", "Filter by resource mode (managed or data)"}, + {"label", "resources_label", "Filter by resource label"}, +} + +func newResourcesListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Args: cobra.NoArgs, + Short: "List inventory resources", + Example: ` + # List all resources for an org + cy --org my-org inventory resources list + + # Filter by provider and type + cy --org my-org inventory resources list --provider aws --type aws_instance + + # Filter by project and environment as JSON + cy --org my-org inventory resources list --project my-project --env prod -o json + + # Use a raw LHS filter + cy --org my-org inventory resources list --filter 'resources_label[eq]=compute' +`, + RunE: resourcesList, + } + + addScopingFlags(cmd) + cyargs.AddLHSFilterFlag(cmd) + for _, f := range resourceNamedFilters { + cmd.Flags().String(f.flag, "", f.desc) + } + cyout.RegisterModel(cmd, models.InventoryResource{}) + + return cmd +} + +func resourcesList(cmd *cobra.Command, args []string) error { + // Step 1: all flags first. + org, err := cyargs.GetOrg(cmd) + if err != nil { + return err + } + + filters, err := cyargs.GetLHSFilters(cmd) + if err != nil { + return err + } + filters, err = appendScopingFilters(cmd, filters) + if err != nil { + return err + } + + for _, f := range resourceNamedFilters { + if val, _ := cmd.Flags().GetString(f.flag); val != "" { + filters = append(filters, middleware.LHSFilter{Attribute: f.attr, Condition: "eq", Value: val}) + } + } + + // Step 2: API + middleware. + api := common.NewAPI() + m := middleware.NewMiddleware(api) + + // Step 3: call + print. + resources, _, err := m.ListInventoryResources(org, filters...) + return cyout.PrintWithOptions(cmd, resources, err, "unable to list inventory resources", resourcesTableOptions) +} diff --git a/cmd/root.go b/cmd/root.go index 057342bef..125c263cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/cycloidio/cycloid-cli/cmd/cycloid/environmenttypes" "github.com/cycloidio/cycloid-cli/cmd/cycloid/events" "github.com/cycloidio/cycloid-cli/cmd/cycloid/externalbackends" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/inventory" "github.com/cycloidio/cycloid-cli/cmd/cycloid/kpis" "github.com/cycloidio/cycloid-cli/cmd/cycloid/login" "github.com/cycloidio/cycloid-cli/cmd/cycloid/members" @@ -164,6 +165,7 @@ func AttachCommands(cmd *cobra.Command) { credentials.NewCommands(), events.NewCommands(), externalbackends.NewCommands(), + inventory.NewCommands(), members.NewCommands(), organizations.NewCommands(), pipelines.NewCommands(), diff --git a/e2e/inventory_test.go b/e2e/inventory_test.go new file mode 100644 index 000000000..1cc287c6d --- /dev/null +++ b/e2e/inventory_test.go @@ -0,0 +1,71 @@ +package e2e_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestInventory exercises the `cy inventory` command tree end-to-end. +// +// Inventory outputs and resources originate from Terraform states and cannot be +// created directly via the API, so (like the middleware LHS filter discovery +// test) these checks validate that the commands wire up correctly and that the +// filter flags are accepted by the route without error — not specific data. +func TestInventory(t *testing.T) { + t.Run("OutputsList", func(t *testing.T) { + out, err := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "inventory", "outputs", "list", + }) + require.NoError(t, err) + + var outputs []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &outputs)) + }) + + t.Run("OutputsListWithFilters", func(t *testing.T) { + _, err := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "inventory", "outputs", "list", + "--type", "string", + "--filter", "output_is_pinned[eq]=false", + }) + require.NoError(t, err) + }) + + t.Run("OutputsGetNotFound", func(t *testing.T) { + _, err := executeCommand([]string{ + "--org", config.Org, + "inventory", "outputs", "get", "definitely-nonexistent-output-key", + }) + assert.Error(t, err, "getting a nonexistent output key must error") + }) + + t.Run("ResourcesList", func(t *testing.T) { + out, err := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "inventory", "resources", "list", + }) + require.NoError(t, err) + + var resources []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &resources)) + }) + + t.Run("ResourcesListWithFilters", func(t *testing.T) { + _, err := executeCommand([]string{ + "--output", "json", + "--org", config.Org, + "inventory", "resources", "list", + "--provider", "aws", + "--filter", "resources_mode[eq]=managed", + }) + require.NoError(t, err) + }) +} diff --git a/internal/cyargs/common.go b/internal/cyargs/common.go index 030ed2918..3de4e5ae8 100644 --- a/internal/cyargs/common.go +++ b/internal/cyargs/common.go @@ -288,6 +288,20 @@ func GetComponent(cmd *cobra.Command) (string, error) { return component, nil } +// GetComponentOrEmpty returns the component canonical, or an empty string when +// neither the --component flag nor the CY_COMPONENT env var is set. Use this for +// optional component scoping (e.g. filters) where absence is not an error. +func GetComponentOrEmpty(cmd *cobra.Command) (string, error) { + component, err := cmd.Flags().GetString("component") + if err != nil { + return "", err + } + if component != "" { + return component, nil + } + return v.GetString("component"), nil +} + func AddColorFlag(cmd *cobra.Command) string { flagName := "color" cmd.Flags().String(flagName, DefaultColor, "set the color.") diff --git a/internal/cyargs/filters.go b/internal/cyargs/filters.go new file mode 100644 index 000000000..19a80ef91 --- /dev/null +++ b/internal/cyargs/filters.go @@ -0,0 +1,71 @@ +package cyargs + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" +) + +const lhsFilterFlagName = "filter" + +// AddLHSFilterFlag registers a repeatable --filter flag for LHS bracket filters. +// The expected syntax is: attribute[condition]=value (e.g. output_type[eq]=string). +// Shared across any List command that forwards filters to a middleware method. +func AddLHSFilterFlag(cmd *cobra.Command) string { + cmd.Flags().StringArray(lhsFilterFlagName, nil, + `LHS bracket filter in the form attribute[condition]=value (repeatable). +Conditions: eq, ne, gt, gte, lt, lte, like, rlike. +Example: --filter 'output_type[eq]=string' --filter 'project_canonical[eq]=my-project'`) + _ = cmd.RegisterFlagCompletionFunc(lhsFilterFlagName, cobra.NoFileCompletions) + return lhsFilterFlagName +} + +// GetLHSFilters parses the --filter flag values into middleware.LHSFilter structs. +// Each token must follow the form: attribute[condition]=value +func GetLHSFilters(cmd *cobra.Command) ([]middleware.LHSFilter, error) { + tokens, err := cmd.Flags().GetStringArray(lhsFilterFlagName) + if err != nil { + return nil, err + } + + filters := make([]middleware.LHSFilter, 0, len(tokens)) + for _, tok := range tokens { + f, err := parseLHSFilter(tok) + if err != nil { + return nil, err + } + filters = append(filters, f) + } + return filters, nil +} + +// parseLHSFilter parses a single "attribute[condition]=value" token. +func parseLHSFilter(tok string) (middleware.LHSFilter, error) { + bracketOpen := strings.IndexByte(tok, '[') + if bracketOpen < 1 { + return middleware.LHSFilter{}, fmt.Errorf("invalid --filter %q: expected format attribute[condition]=value", tok) + } + + closeEq := strings.Index(tok[bracketOpen:], "]=") + if closeEq < 0 { + return middleware.LHSFilter{}, fmt.Errorf("invalid --filter %q: expected format attribute[condition]=value", tok) + } + closeEq += bracketOpen // absolute offset of ']' + + attribute := tok[:bracketOpen] + condition := tok[bracketOpen+1 : closeEq] + value := tok[closeEq+2:] // skip "]=" + + if condition == "" { + return middleware.LHSFilter{}, fmt.Errorf("invalid --filter %q: condition must not be empty", tok) + } + + return middleware.LHSFilter{ + Attribute: attribute, + Condition: condition, + Value: value, + }, nil +} diff --git a/internal/cyargs/filters_test.go b/internal/cyargs/filters_test.go new file mode 100644 index 000000000..113b379f2 --- /dev/null +++ b/internal/cyargs/filters_test.go @@ -0,0 +1,81 @@ +package cyargs_test + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware" + "github.com/cycloidio/cycloid-cli/internal/cyargs" +) + +func TestGetLHSFilters(t *testing.T) { + tests := []struct { + name string + args []string + want []middleware.LHSFilter + wantErr bool + }{ + { + name: "no filters", + args: nil, + want: []middleware.LHSFilter{}, + }, + { + name: "single eq filter", + args: []string{"--filter", "output_type[eq]=string"}, + want: []middleware.LHSFilter{{Attribute: "output_type", Condition: "eq", Value: "string"}}, + }, + { + name: "multiple filters", + args: []string{"--filter", "project_canonical[eq]=p", "--filter", "output_is_pinned[eq]=true"}, + want: []middleware.LHSFilter{ + {Attribute: "project_canonical", Condition: "eq", Value: "p"}, + {Attribute: "output_is_pinned", Condition: "eq", Value: "true"}, + }, + }, + { + name: "rlike with regex metachars", + args: []string{"--filter", "resources_name[rlike]=lhs-res-.*"}, + want: []middleware.LHSFilter{{Attribute: "resources_name", Condition: "rlike", Value: "lhs-res-.*"}}, + }, + { + name: "value containing equals", + args: []string{"--filter", "output_key[eq]=a=b"}, + want: []middleware.LHSFilter{{Attribute: "output_key", Condition: "eq", Value: "a=b"}}, + }, + { + name: "missing bracket", + args: []string{"--filter", "output_typeeq=string"}, + wantErr: true, + }, + { + name: "missing closing bracket-eq", + args: []string{"--filter", "output_type[eq string"}, + wantErr: true, + }, + { + name: "empty condition", + args: []string{"--filter", "output_type[]=string"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }} + cyargs.AddLHSFilterFlag(cmd) + require.NoError(t, cmd.ParseFlags(tt.args)) + + got, err := cyargs.GetLHSFilters(cmd) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}