diff --git a/internal/cmd/policy/flags.go b/internal/cmd/policy/flags.go new file mode 100644 index 0000000..4da4d60 --- /dev/null +++ b/internal/cmd/policy/flags.go @@ -0,0 +1,21 @@ +package policy + +import "github.com/urfave/cli/v2" + +var flagRequiredPolicyID = &cli.StringFlag{ + Name: "id", + Usage: "[Required] `ID` of the policy", + Required: true, +} + +var flagRequiredSampleKey = &cli.StringFlag{ + Name: "key", + Usage: "[Required] `Key` of the policy sample", + Required: true, +} + +var flagSimulationInput = &cli.StringFlag{ + Name: "input", + Usage: "[Required] JSON Input of the data provided for policy simlation. Will Attempt to detect if a file is provided", + Required: true, +} diff --git a/internal/cmd/policy/list.go b/internal/cmd/policy/list.go new file mode 100644 index 0000000..312480f --- /dev/null +++ b/internal/cmd/policy/list.go @@ -0,0 +1,206 @@ +package policy + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/client/structs" + "github.com/spacelift-io/spacectl/internal" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +type listCommand struct{} + +func (c *listCommand) list(cliCtx *cli.Context) error { + outputFormat, err := cmd.GetOutputFormat(cliCtx) + if err != nil { + return err + } + + var limit *uint + if cliCtx.IsSet(cmd.FlagLimit.Name) { + limit = internal.Ptr(cliCtx.Uint(cmd.FlagLimit.Name)) + } + + var search *string + if cliCtx.IsSet(cmd.FlagSearch.Name) { + search = internal.Ptr(cliCtx.String(cmd.FlagSearch.Name)) + } + + switch outputFormat { + case cmd.OutputFormatTable: + return c.listTable(cliCtx, search, limit) + case cmd.OutputFormatJSON: + return c.listJSON(cliCtx, search, limit) + } + + return fmt.Errorf("unknown output format: %v", outputFormat) +} + +func (c *listCommand) listTable(ctx *cli.Context, search *string, limit *uint) error { + var first *graphql.Int + if limit != nil { + first = graphql.NewInt(graphql.Int(*limit)) //nolint: gosec + } + + var fullTextSearch *graphql.String + if search != nil { + fullTextSearch = graphql.NewString(graphql.String(*search)) + } + + input := structs.SearchInput{ + First: first, + FullTextSearch: fullTextSearch, + OrderBy: &structs.QueryOrder{ + Field: "name", + Direction: "DESC", + }, + } + + policies, err := c.searchAllPolicies(ctx.Context, input) + if err != nil { + return err + } + + columns := []string{"Name", "ID", "Description", "Type", "Space", "Updated At", "Labels"} + tableData := [][]string{columns} + + for _, b := range policies { + row := []string{ + b.Name, + b.ID, + b.Description, + b.Type, + b.Space.ID, + cmd.HumanizeUnixSeconds(b.UpdatedAt), + strings.Join(b.Labels, ", "), + } + if ctx.Bool(cmd.FlagShowLabels.Name) { + row = append(row, strings.Join(b.Labels, ", ")) + } + + tableData = append(tableData, row) + } + + return cmd.OutputTable(tableData, true) +} + +func (c *listCommand) listJSON(ctx *cli.Context, search *string, limit *uint) error { + var first *graphql.Int + if limit != nil { + first = graphql.NewInt(graphql.Int(*limit)) //nolint: gosec + } + + var fullTextSearch *graphql.String + if search != nil { + fullTextSearch = graphql.NewString(graphql.String(*search)) + } + + policies, err := c.searchAllPolicies(ctx.Context, structs.SearchInput{ + First: first, + FullTextSearch: fullTextSearch, + }) + if err != nil { + return err + } + + return cmd.OutputJSON(policies) +} + +func (c *listCommand) searchAllPolicies(ctx context.Context, input structs.SearchInput) ([]policyNode, error) { + const maxPageSize = 50 + + var limit int + if input.First != nil { + limit = int(*input.First) + } + fetchAll := limit == 0 + + out := []policyNode{} + pageInput := structs.SearchInput{ + First: graphql.NewInt(maxPageSize), + FullTextSearch: input.FullTextSearch, + } + for { + if !fetchAll { + // Fetch exactly the number of items requested + pageInput.First = graphql.NewInt( + //nolint: gosec + graphql.Int( + slices.Min([]int{maxPageSize, limit - len(out)}), + ), + ) + } + + result, err := searchPolicies(ctx, pageInput) + if err != nil { + return nil, err + } + + out = append(out, result.Policies...) + + if result.PageInfo.HasNextPage && (fetchAll || limit > len(out)) { + pageInput.After = graphql.NewString(graphql.String(result.PageInfo.EndCursor)) + } else { + break + } + } + + return out, nil +} + +type policyNode struct { + ID string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + Description string `graphql:"description" json:"description"` + Body string `graphql:"body" json:"body"` + Space struct { + ID string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + AccessLevel string `graphql:"accessLevel" json:"accessLevel"` + } `graphql:"spaceDetails" json:"spaceDetails"` + CreatedAt int `graphql:"createdAt" json:"createdAt"` + UpdatedAt int `graphql:"updatedAt" json:"updatedAt"` + Type string `graphql:"type" json:"type"` + Labels []string `graphql:"labels" json:"labels"` +} + +type searchPoliciesResult struct { + Policies []policyNode + PageInfo structs.PageInfo +} + +func searchPolicies(ctx context.Context, input structs.SearchInput) (searchPoliciesResult, error) { + var query struct { + SearchPoliciesOutput struct { + Edges []struct { + Node policyNode `graphql:"node"` + } `graphql:"edges"` + PageInfo structs.PageInfo `graphql:"pageInfo"` + } `graphql:"searchPolicies(input: $input)"` + } + + if err := authenticated.Client.Query( + ctx, + &query, + map[string]interface{}{"input": input}, + ); err != nil { + return searchPoliciesResult{}, errors.Wrap(err, "failed search for policies") + } + + nodes := make([]policyNode, 0) + for _, q := range query.SearchPoliciesOutput.Edges { + nodes = append(nodes, q.Node) + } + + return searchPoliciesResult{ + Policies: nodes, + PageInfo: query.SearchPoliciesOutput.PageInfo, + }, nil +} diff --git a/internal/cmd/policy/policy.go b/internal/cmd/policy/policy.go new file mode 100644 index 0000000..382b73f --- /dev/null +++ b/internal/cmd/policy/policy.go @@ -0,0 +1,79 @@ +package policy + +import ( + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +// Command encapsulates the policyNode command subtree. +func Command() *cli.Command { + return &cli.Command{ + Name: "policy", + Usage: "Manage Spacelift policies", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List the policies you have access to", + Flags: []cli.Flag{ + cmd.FlagOutputFormat, + cmd.FlagLimit, + cmd.FlagSearch, + }, + Action: (&listCommand{}).list, + Before: cmd.PerformAllBefore( + cmd.HandleNoColor, + authenticated.Ensure, + ), + ArgsUsage: cmd.EmptyArgsUsage, + }, + { + Name: "show", + Usage: "Shows detailed information about a specific policy", + Flags: []cli.Flag{ + cmd.FlagOutputFormat, + flagRequiredPolicyID, + }, + Action: (&showCommand{}).show, + Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure), + ArgsUsage: cmd.EmptyArgsUsage, + }, + { + Name: "samples", + Usage: "List all policy samples", + Flags: []cli.Flag{ + cmd.FlagOutputFormat, + cmd.FlagNoColor, + flagRequiredPolicyID, + }, + Action: (&samplesCommand{}).list, + Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure), + ArgsUsage: cmd.EmptyArgsUsage, + }, + { + Name: "sample", + Usage: "Inspect one policy sample", + Flags: []cli.Flag{ + cmd.FlagNoColor, + flagRequiredPolicyID, + flagRequiredSampleKey, + }, + Action: (&sampleCommand{}).show, + Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure), + ArgsUsage: cmd.EmptyArgsUsage, + }, + { + Name: "simulate", + Usage: "Simulate a policy using a sample", + Flags: []cli.Flag{ + cmd.FlagNoColor, + flagRequiredPolicyID, + flagSimulationInput, + }, + Action: (&simulateCommand{}).simulate, + Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure), + ArgsUsage: cmd.EmptyArgsUsage, + }, + }, + } +} diff --git a/internal/cmd/policy/sample.go b/internal/cmd/policy/sample.go new file mode 100644 index 0000000..9cfca8a --- /dev/null +++ b/internal/cmd/policy/sample.go @@ -0,0 +1,49 @@ +package policy + +import ( + "context" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +type policyEvaluationSample struct { + Input string `graphql:"input" json:"input"` + Body string `graphql:"body" json:"body"` +} + +type sampleCommand struct{} + +func (c *sampleCommand) show(cliCtx *cli.Context) error { + policyID := cliCtx.String(flagRequiredPolicyID.Name) + key := cliCtx.String(flagRequiredSampleKey.Name) + + b, err := c.getSamplesPolicyByID(cliCtx.Context, policyID, key) + if err != nil { + return errors.Wrapf(err, "failed to query for policyEvaluation ID %q", policyID) + } + + return cmd.OutputJSON(b) +} + +func (c *sampleCommand) getSamplesPolicyByID(ctx context.Context, policyID, key string) (policyEvaluationSample, error) { + var query struct { + Policy struct { + Sample policyEvaluationSample `graphql:"evaluationSample(key: $key)"` + } `graphql:"policy(id: $policyId)"` + } + + variables := map[string]interface{}{ + "policyId": graphql.ID(policyID), + "key": graphql.String(key), + } + + if err := authenticated.Client.Query(ctx, &query, variables); err != nil { + return policyEvaluationSample{}, errors.Wrapf(err, "failed to query for policyEvaluation ID %q", policyID) + } + + return query.Policy.Sample, nil +} diff --git a/internal/cmd/policy/samples.go b/internal/cmd/policy/samples.go new file mode 100644 index 0000000..cb7d009 --- /dev/null +++ b/internal/cmd/policy/samples.go @@ -0,0 +1,90 @@ +package policy + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +type policyEvaluation struct { + ID string `graphql:"id" json:"id"` + EvaluationRecords []struct { + Key string `graphql:"key" json:"key"` + Outcome string `graphql:"outcome" json:"outcome"` + Timestamp int `graphql:"timestamp" json:"timestamp"` + } +} + +type samplesCommand struct{} + +func (c *samplesCommand) list(cliCtx *cli.Context) error { + policyID := cliCtx.String(flagRequiredPolicyID.Name) + + outputFormat, err := cmd.GetOutputFormat(cliCtx) + if err != nil { + return err + } + + b, found, err := c.getSamplesPolicyByID(cliCtx.Context, policyID) + if err != nil { + return errors.Wrapf(err, "failed to query for policyEvaluation ID %q", policyID) + } + + if !found { + return fmt.Errorf("policyEvaluation with ID %q not found", policyID) + } + + switch outputFormat { + case cmd.OutputFormatTable: + return c.samplesPolicyTable(b) + case cmd.OutputFormatJSON: + return cmd.OutputJSON(b) + } + + return fmt.Errorf("unknown output format: %v", outputFormat) +} + +func (c *samplesCommand) getSamplesPolicyByID(ctx context.Context, policyID string) (policyEvaluation, bool, error) { + var query struct { + Policy *policyEvaluation `graphql:"policy(id: $policyId)" json:"policy,omitempty"` + } + + variables := map[string]interface{}{ + "policyId": graphql.ID(policyID), + } + + if err := authenticated.Client.Query(ctx, &query, variables); err != nil { + return policyEvaluation{}, false, errors.Wrapf(err, "failed to query for policyEvaluation ID %q", policyID) + } + + if query.Policy == nil { + return policyEvaluation{}, false, nil + } + + return *query.Policy, true, nil +} + +func (c *samplesCommand) samplesPolicyTable(input policyEvaluation) error { + tableData := [][]string{ + {"Key", "Outcome", "Timestamp"}, + } + + for _, record := range input.EvaluationRecords { + tableData = append(tableData, []string{ + record.Key, + record.Outcome, + cmd.HumanizeUnixSeconds(record.Timestamp), + }) + } + + if err := cmd.OutputTable(tableData, false); err != nil { + return err + } + + return nil +} diff --git a/internal/cmd/policy/show.go b/internal/cmd/policy/show.go new file mode 100644 index 0000000..8228761 --- /dev/null +++ b/internal/cmd/policy/show.go @@ -0,0 +1,104 @@ +package policy + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" + "github.com/pterm/pterm" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +type PolicyType string + +type policy struct { + ID string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + Description string `graphql:"description" json:"description"` + Body string `graphql:"body" json:"body"` + Space struct { + ID string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + AccessLevel string `graphql:"accessLevel" json:"accessLevel"` + } `graphql:"spaceDetails" json:"spaceDetails"` + CreatedAt int `graphql:"createdAt" json:"createdAt"` + UpdatedAt int `graphql:"updatedAt" json:"updatedAt"` + Type PolicyType `graphql:"type" json:"type"` + Labels []string `graphql:"labels" json:"labels"` +} + +type showCommand struct{} + +func (c *showCommand) show(cliCtx *cli.Context) error { + policyID := cliCtx.String(flagRequiredPolicyID.Name) + + outputFormat, err := cmd.GetOutputFormat(cliCtx) + if err != nil { + return err + } + + b, found, err := getPolicyByID(cliCtx.Context, policyID) + if err != nil { + return errors.Wrapf(err, "failed to query for policy ID %q", policyID) + } + + if !found { + return fmt.Errorf("policy with ID %q not found", policyID) + } + + switch outputFormat { + case cmd.OutputFormatTable: + return c.showPolicyTable(b) + case cmd.OutputFormatJSON: + return cmd.OutputJSON(b) + } + + return fmt.Errorf("unknown output format: %v", outputFormat) +} + +func getPolicyByID(ctx context.Context, policyID string) (policy, bool, error) { + var query struct { + Policy *policy `graphql:"policy(id: $policyId)" json:"policy,omitempty"` + } + + variables := map[string]interface{}{ + "policyId": graphql.ID(policyID), + } + + if err := authenticated.Client.Query(ctx, &query, variables); err != nil { + return policy{}, false, errors.Wrapf(err, "failed to query for policy ID %q", policyID) + } + + if query.Policy == nil { + return policy{}, false, nil + } + + return *query.Policy, true, nil +} + +func (c *showCommand) showPolicyTable(input policy) error { + pterm.DefaultSection.WithLevel(2).Println("Policy") + + tableData := [][]string{ + {"Name", input.Name}, + {"ID", input.ID}, + {"Description", input.Description}, + {"Type", string(input.Type)}, + {"Space", input.Space.ID}, + {"Labels", strings.Join(input.Labels, ", ")}, + } + + if err := cmd.OutputTable(tableData, false); err != nil { + return err + } + + pterm.DefaultSection.WithLevel(2).Println("Body") + + pterm.Println(input.Body) + + return nil +} diff --git a/internal/cmd/policy/simulate.go b/internal/cmd/policy/simulate.go new file mode 100644 index 0000000..a731156 --- /dev/null +++ b/internal/cmd/policy/simulate.go @@ -0,0 +1,71 @@ +package policy + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +type simulateCommand struct{} + +func (c *simulateCommand) simulate(cliCtx *cli.Context) error { + policyID := cliCtx.String(flagRequiredPolicyID.Name) + input := cliCtx.String(flagSimulationInput.Name) + + parsedInput, err := parseInput(input) + if err != nil { + return err + } + + b, found, err := getPolicyByID(cliCtx.Context, policyID) + if err != nil { + return errors.Wrapf(err, "failed to query for policy ID %q", policyID) + } + + if !found { + return fmt.Errorf("policy with ID %q not found", policyID) + } + + var mutation struct { + PolicySimulate string `graphql:"policySimulate(body: $body, input: $input, type: $type)"` + } + + variables := map[string]interface{}{ + "body": graphql.String(b.Body), + "input": graphql.String(parsedInput), + "type": b.Type, + } + + if err := authenticated.Client.Mutate(cliCtx.Context, &mutation, variables); err != nil { + return err + } + + return cmd.OutputJSON(mutation.PolicySimulate) +} + +func parseInput(input string) (string, error) { + if _, err := os.Stat(input); err == nil { + fileContent, err := os.ReadFile(input) + if err != nil { + return "", fmt.Errorf("failed to read file: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(fileContent, &result); err == nil { + return string(fileContent), nil + } + } else { + var result map[string]interface{} + if err := json.Unmarshal([]byte(input), &result); err == nil { + return input, nil + } + } + + return "", fmt.Errorf("input is neither a valid JSON nor a file path") +} diff --git a/main.go b/main.go index 68d9099..ceeb38c 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/spacelift-io/spacectl/internal/cmd/blueprint" "github.com/spacelift-io/spacectl/internal/cmd/completion" "github.com/spacelift-io/spacectl/internal/cmd/module" + "github.com/spacelift-io/spacectl/internal/cmd/policy" "github.com/spacelift-io/spacectl/internal/cmd/profile" "github.com/spacelift-io/spacectl/internal/cmd/provider" runexternaldependency "github.com/spacelift-io/spacectl/internal/cmd/run_external_dependency" @@ -43,6 +44,7 @@ func main() { workerpools.Command(), completion.Command(), blueprint.Command(), + policy.Command(), }, }