diff --git a/TODO.md b/TODO.md index 0229b8b87..757643af4 100644 --- a/TODO.md +++ b/TODO.md @@ -55,6 +55,16 @@ - [x] Slice 3: Show params in `--show-input` output - [x] Slice 4: Update help text and examples +## kosli create policy-file + +- [x] Slice 1: Policy model + YAML generation (`internal/policy/`) +- [x] Slice 2: Expression builder +- [x] Slice 3: Skeleton Cobra command + huh dependency +- [x] Slice 4: Attestation loop in wizard +- [x] Slice 5: Expression builder wizard +- [x] Slice 6: API lookups for flows and custom attestation types +- [x] Slice 7: Preview screen + polish + ## Fakes & contract tests for GitHub API integration ### Slice 1: FakeGitHubClient + contract tests (`internal/github`) ← active diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index da9ffe9b2..700c920ed 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -20,6 +20,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreateEnvironmentCmd(out), newCreateFlowCmd(out), newCreatePolicyCmd(out), + newCreatePolicyFileCmd(out), newCreateAttestationTypeCmd(out), ) return cmd diff --git a/cmd/kosli/createPolicyFile.go b/cmd/kosli/createPolicyFile.go new file mode 100644 index 000000000..c5143c2c8 --- /dev/null +++ b/cmd/kosli/createPolicyFile.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kosli-dev/cli/internal/policywizard" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +const createPolicyFileShortDesc = `Interactively create a Kosli environment policy YAML file.` + +const createPolicyFileLongDesc = createPolicyFileShortDesc + ` +Launches an interactive wizard that guides you through building a policy file +conforming to the Kosli environment policy schema. The generated YAML is +written to a file you specify at the end of the wizard. + +This command does not upload the policy to Kosli. Use ^kosli create policy^ +to upload the generated file. + +If ^--api-token^ and ^--org^ are set, the wizard will fetch flow names and +custom attestation types from the Kosli API to offer as suggestions. +` + +const createPolicyFileExample = ` +# create a policy file interactively: +kosli create policy-file +` + +func newCreatePolicyFileCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "policy-file", + Short: createPolicyFileShortDesc, + Long: createPolicyFileLongDesc, + Example: createPolicyFileExample, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreatePolicyFile() + }, + } + + return cmd +} + +func runCreatePolicyFile() error { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("this command requires an interactive terminal; write policy YAML manually or use 'kosli create policy' directly") + } + + ctx := &policywizard.Context{} + if global.ApiToken != "" && global.Org != "" { + fmt.Fprint(os.Stderr, "Starting Kosli Policy Builder...\r") + ctx.FetchFunc = func() policywizard.FetchResult { + return policywizard.FetchResult{ + FlowNames: fetchFlowNames(), + CustomAttestTypes: fetchCustomAttestationTypes(), + } + } + } + + m := policywizard.NewModel(ctx) + finalModel, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return fmt.Errorf("wizard error: %w", err) + } + + wm, ok := finalModel.(policywizard.Model) + if !ok { + return fmt.Errorf("unexpected model type from wizard") + } + if wm.Cancelled { + logger.Info("policy file creation cancelled") + return nil + } + + yamlBytes, err := wm.Policy.ToYAML() + if err != nil { + return fmt.Errorf("failed to generate policy YAML: %w", err) + } + + outPath := filepath.Clean(wm.OutputFile) + if err := validateOutputFile(outPath); err != nil { + return err + } + + err = os.WriteFile(outPath, yamlBytes, 0644) + if err != nil { + return fmt.Errorf("failed to write policy file: %w", err) + } + logger.Info("policy file written to %s", outPath) + return nil +} + +func validateOutputFile(path string) error { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + return fmt.Errorf("output file must have a .yaml or .yml extension, got %q", filepath.Base(path)) + } + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("file %q already exists; remove it or choose a different name", path) + } + return nil +} + +func fetchFlowNames() []string { + return fetchNameList("api/v2/flows", nil) +} + +func fetchCustomAttestationTypes() []string { + return fetchNameList("api/v2/custom-attestation-types", func(name string) string { + return "custom:" + name + }) +} + +func fetchNameList(apiPath string, transform func(string) string) []string { + u, err := url.JoinPath(global.Host, apiPath, global.Org) + if err != nil { + logger.Debug("failed to build URL for %s: %v", apiPath, err) + return nil + } + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: u, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + logger.Debug("failed to fetch %s: %v", apiPath, err) + return nil + } + + var items []map[string]any + if err := json.Unmarshal([]byte(response.Body), &items); err != nil { + logger.Debug("failed to parse %s response: %v", apiPath, err) + return nil + } + + names := make([]string, 0, len(items)) + for _, item := range items { + if name, ok := item["name"].(string); ok { + if transform != nil { + name = transform(name) + } + names = append(names, name) + } + } + return names +} diff --git a/cmd/kosli/createPolicyFile_test.go b/cmd/kosli/createPolicyFile_test.go new file mode 100644 index 000000000..2a5fbee64 --- /dev/null +++ b/cmd/kosli/createPolicyFile_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateOutputFile_AcceptsYamlExtension(t *testing.T) { + dir := t.TempDir() + assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.yaml"))) +} + +func TestValidateOutputFile_AcceptsYmlExtension(t *testing.T) { + dir := t.TempDir() + assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.yml"))) +} + +func TestValidateOutputFile_AcceptsUppercaseExtension(t *testing.T) { + dir := t.TempDir() + assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.YAML"))) +} + +func TestValidateOutputFile_RejectsNonYamlExtension(t *testing.T) { + dir := t.TempDir() + err := validateOutputFile(filepath.Join(dir, "policy.json")) + require.Error(t, err) + assert.Contains(t, err.Error(), ".yaml or .yml") +} + +func TestValidateOutputFile_RejectsNoExtension(t *testing.T) { + dir := t.TempDir() + err := validateOutputFile(filepath.Join(dir, "policy")) + require.Error(t, err) + assert.Contains(t, err.Error(), ".yaml or .yml") +} + +func TestValidateOutputFile_RejectsExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "existing.yaml") + require.NoError(t, os.WriteFile(path, []byte("test"), 0644)) + + err := validateOutputFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} diff --git a/go.mod b/go.mod index 8dac1d3ab..8c2c89a19 100644 --- a/go.mod +++ b/go.mod @@ -17,19 +17,23 @@ require ( github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 github.com/aws/smithy-go v1.25.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/containers/image/v5 v5.36.2 - github.com/docker/docker v28.5.2+incompatible + github.com/docker/docker v28.3.2+incompatible github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-git/v5 v5.18.0 github.com/go-playground/validator/v10 v10.30.2 github.com/google/go-github/v42 v42.0.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/joshdk/go-junit v1.0.0 - github.com/mattn/go-shellwords v1.0.13 + github.com/mattn/go-shellwords v1.0.12 github.com/maxcnunes/httpfake v1.2.4 github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 github.com/mitchellh/go-homedir v1.1.0 - github.com/open-policy-agent/opa v1.15.2 + github.com/open-policy-agent/opa v1.15.1 github.com/otiai10/copy v1.14.1 github.com/owenrumney/go-sarif/v2 v2.3.3 github.com/pkg/errors v0.9.1 @@ -64,6 +68,7 @@ require ( github.com/ProtonMail/go-crypto v1.2.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect @@ -77,10 +82,20 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -97,8 +112,10 @@ require ( github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -118,6 +135,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -145,8 +163,12 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect @@ -156,6 +178,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/onsi/ginkgo/v2 v2.27.2 // indirect @@ -174,6 +199,7 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -194,15 +220,16 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/sdk v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index a89fc5f12..a8c2b6ed4 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -46,6 +48,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= @@ -88,16 +92,56 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bytecodealliance/wasmtime-go/v39 v39.0.1 h1:RibaT47yiyCRxMOj/l2cvL8cWiWBSqDXHyqsa9sGcCE= github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -117,6 +161,8 @@ github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACi github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -139,8 +185,8 @@ github.com/docker/cli v28.3.2+incompatible h1:mOt9fcLE7zaACbxW1GeS65RI67wIJrTnqS github.com/docker/cli v28.3.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA= +github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -157,6 +203,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -226,6 +274,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -287,6 +337,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= @@ -316,6 +368,8 @@ github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0 github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -324,8 +378,12 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-shellwords v1.0.13 h1:DC0OMEpGjm6LfNFU4ckYcvbQKyp2vE8atyFGXNtDcf4= -github.com/mattn/go-shellwords v1.0.13/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/maxcnunes/httpfake v1.2.4 h1:l7s/N7zuG6XpzG+5dUolg5SSoR3hANQxqzAkv+lREko= github.com/maxcnunes/httpfake v1.2.4/go.mod h1:rWVxb0bLKtOUM/5hN3UO1VEdEitz1hfcTXs7UyiK6r0= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -336,6 +394,8 @@ github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= @@ -360,6 +420,12 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -368,8 +434,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/open-policy-agent/opa v1.15.2 h1:dS9q+0Yvruq/VNvWJc5qCvCchn715OWc3HLHXn/UCCc= -github.com/open-policy-agent/opa v1.15.2/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4= +github.com/open-policy-agent/opa v1.15.1 h1:ZE4JaXsVUzDiHFSlOMBS3nJohR5BRGB/RNz6gTNugzE= +github.com/open-policy-agent/opa v1.15.1/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -407,6 +473,8 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -485,10 +553,14 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeonx/timeago v1.0.0-rc5 h1:pwcQGpaH3eLfPtXeyPA4DmHWjoQt0Ea7/++FwpxqLxg= github.com/xeonx/timeago v1.0.0-rc5/go.mod h1:qDLrYEFynLO7y5Ho7w3GwgtYgpy5UfhcXIIQvMKVDkA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= @@ -498,22 +570,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= +go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -527,29 +599,43 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -560,6 +646,7 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= @@ -567,8 +654,15 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/internal/policy/expression.go b/internal/policy/expression.go new file mode 100644 index 000000000..38ad3f65c --- /dev/null +++ b/internal/policy/expression.go @@ -0,0 +1,88 @@ +package policy + +import ( + "fmt" + "strings" +) + +// FlowNameExpr returns a policy expression matching a single flow name. +func FlowNameExpr(name string) string { + return fmt.Sprintf(`${{ flow.name == "%s" }}`, name) +} + +// FlowNameInExpr returns a policy expression matching any of the given flow names. +// For a single name, it returns an equality expression instead. +func FlowNameInExpr(names []string) string { + if len(names) == 0 { + return "" + } + if len(names) == 1 { + return FlowNameExpr(names[0]) + } + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = fmt.Sprintf(`"%s"`, n) + } + return fmt.Sprintf(`${{ flow.name in [%s] }}`, strings.Join(quoted, ", ")) +} + +// FlowTagExpr returns a policy expression comparing a flow tag to a value. +func FlowTagExpr(key, op, value string) string { + return fmt.Sprintf(`${{ flow.tags.%s %s "%s" }}`, key, op, value) +} + +// ArtifactNameMatchExpr returns a policy expression matching artifact names by regex. +func ArtifactNameMatchExpr(regex string) string { + return MatchesExpr("artifact.name", regex) +} + +// MatchesExpr returns a policy expression using the matches() function form. +func MatchesExpr(context, regex string) string { + return fmt.Sprintf(`${{ matches(%s, "%s") }}`, context, regex) +} + +// ExistsExpr returns a policy expression checking that a context field is not null. +func ExistsExpr(context string) string { + return fmt.Sprintf(`${{ exists(%s) }}`, context) +} + +// ComparisonExpr returns a policy expression comparing a context field to a value. +// The value is always quoted as a string. For operators like > or <, the policy +// engine must handle string-to-numeric coercion if needed. +func ComparisonExpr(context, op, value string) string { + return fmt.Sprintf(`${{ %s %s "%s" }}`, context, op, value) +} + +// CombineExprs joins inner expressions (without ${{ }} wrappers) with a logical operator. +func CombineExprs(op string, exprs ...string) string { + if len(exprs) == 0 { + return "" + } + if len(exprs) == 1 { + return WrapExpr(exprs[0]) + } + return fmt.Sprintf("${{ %s }}", strings.Join(exprs, " "+op+" ")) +} + +// WrapExpr adds the ${{ }} wrapper if not already present. +func WrapExpr(raw string) string { + if strings.HasPrefix(raw, "${{") && strings.HasSuffix(raw, "}}") { + return raw + } + return fmt.Sprintf("${{ %s }}", raw) +} + +// UnwrapExpr strips the ${{ }} wrapper, returning the inner expression. +// Tolerates varying whitespace inside the delimiters. +func UnwrapExpr(expr string) string { + s := strings.TrimSpace(expr) + s = strings.TrimPrefix(s, "${{") + s = strings.TrimSuffix(s, "}}") + return strings.TrimSpace(s) +} + +// NegateExpr prefixes a raw (unwrapped) expression with the not operator. +// Parentheses ensure correct evaluation regardless of operator precedence. +func NegateExpr(raw string) string { + return fmt.Sprintf("not (%s)", raw) +} diff --git a/internal/policy/expression_test.go b/internal/policy/expression_test.go new file mode 100644 index 000000000..0026a1e44 --- /dev/null +++ b/internal/policy/expression_test.go @@ -0,0 +1,111 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFlowNameExpr(t *testing.T) { + result := FlowNameExpr("prod") + assert.Equal(t, `${{ flow.name == "prod" }}`, result) +} + +func TestFlowNameInExpr(t *testing.T) { + result := FlowNameInExpr([]string{"runner", "saver"}) + assert.Equal(t, `${{ flow.name in ["runner", "saver"] }}`, result) +} + +func TestFlowNameInExpr_Empty(t *testing.T) { + assert.Equal(t, "", FlowNameInExpr(nil)) + assert.Equal(t, "", FlowNameInExpr([]string{})) +} + +func TestFlowNameInExpr_Single(t *testing.T) { + result := FlowNameInExpr([]string{"prod"}) + assert.Equal(t, `${{ flow.name == "prod" }}`, result) +} + +func TestFlowTagExpr(t *testing.T) { + result := FlowTagExpr("risk-level", "==", "high") + assert.Equal(t, `${{ flow.tags.risk-level == "high" }}`, result) +} + +func TestFlowTagExpr_DottedKey(t *testing.T) { + result := FlowTagExpr("key.with.dots", "!=", "bad") + assert.Equal(t, `${{ flow.tags.key.with.dots != "bad" }}`, result) +} + +func TestArtifactNameMatchExpr(t *testing.T) { + result := ArtifactNameMatchExpr("^datadog:.*") + assert.Equal(t, `${{ matches(artifact.name, "^datadog:.*") }}`, result) +} + +func TestMatchesExpr(t *testing.T) { + result := MatchesExpr("flow.name", "^prod") + assert.Equal(t, `${{ matches(flow.name, "^prod") }}`, result) +} + +func TestExistsExpr(t *testing.T) { + result := ExistsExpr("flow") + assert.Equal(t, `${{ exists(flow) }}`, result) +} + +func TestComparisonExpr(t *testing.T) { + result := ComparisonExpr("flow.tags.risk", "==", "high") + assert.Equal(t, `${{ flow.tags.risk == "high" }}`, result) +} + +func TestCombineExprs(t *testing.T) { + e1 := `flow.name == "prod"` + e2 := `artifact.name == "svc"` + result := CombineExprs("and", e1, e2) + assert.Equal(t, `${{ flow.name == "prod" and artifact.name == "svc" }}`, result) +} + +func TestCombineExprs_Single(t *testing.T) { + result := CombineExprs("or", `flow.name == "prod"`) + assert.Equal(t, `${{ flow.name == "prod" }}`, result) +} + +func TestCombineExprs_Empty(t *testing.T) { + result := CombineExprs("and") + assert.Equal(t, "", result) +} + +func TestWrapExpr_AddsWrapper(t *testing.T) { + result := WrapExpr(`flow.name == "prod"`) + assert.Equal(t, `${{ flow.name == "prod" }}`, result) +} + +func TestWrapExpr_Idempotent(t *testing.T) { + result := WrapExpr(`${{ flow.name == "prod" }}`) + assert.Equal(t, `${{ flow.name == "prod" }}`, result) +} + +func TestUnwrapExpr(t *testing.T) { + result := UnwrapExpr(`${{ flow.name == "prod" }}`) + assert.Equal(t, `flow.name == "prod"`, result) +} + +func TestUnwrapExpr_NoInnerSpaces(t *testing.T) { + result := UnwrapExpr(`${{flow.name == "prod"}}`) + assert.Equal(t, `flow.name == "prod"`, result) +} + +func TestUnwrapExpr_AlreadyRaw(t *testing.T) { + result := UnwrapExpr(`flow.name == "prod"`) + assert.Equal(t, `flow.name == "prod"`, result) +} + +func TestNegateExpr(t *testing.T) { + result := NegateExpr(`flow.name == "prod"`) + assert.Equal(t, `not (flow.name == "prod")`, result) +} + +func TestCombineAndNegate(t *testing.T) { + a := UnwrapExpr(FlowNameExpr("prod")) + b := NegateExpr(UnwrapExpr(ArtifactNameMatchExpr("^datadog:.*"))) + result := CombineExprs("and", a, b) + assert.Equal(t, `${{ flow.name == "prod" and not (matches(artifact.name, "^datadog:.*")) }}`, result) +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 000000000..ab7cf277b --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,41 @@ +package policy + +import "gopkg.in/yaml.v3" + +const SchemaURL = "https://docs.kosli.com/schemas/policy/v1" + +type Policy struct { + Schema string `yaml:"_schema"` + Artifacts *ArtifactRules `yaml:"artifacts,omitempty"` +} + +type ArtifactRules struct { + Provenance *BooleanRule `yaml:"provenance,omitempty"` + TrailCompliance *BooleanRule `yaml:"trail-compliance,omitempty"` + Attestations []AttestationRule `yaml:"attestations,omitempty"` +} + +type BooleanRule struct { + Required bool `yaml:"required"` + Exceptions []ExceptionRule `yaml:"exceptions,omitempty"` +} + +type ExceptionRule struct { + If string `yaml:"if"` +} + +type AttestationRule struct { + Type string `yaml:"type"` + Name string `yaml:"name"` + If string `yaml:"if,omitempty"` +} + +func NewPolicy() *Policy { + return &Policy{ + Schema: SchemaURL, + } +} + +func (p *Policy) ToYAML() ([]byte, error) { + return yaml.Marshal(p) +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 000000000..e71680350 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,232 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToYAML_EmptyPolicy(t *testing.T) { + p := NewPolicy() + out, err := p.ToYAML() + require.NoError(t, err) + + expected := "_schema: https://docs.kosli.com/schemas/policy/v1\n" + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_ProvenanceRequired(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Provenance: &BooleanRule{Required: true}, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + provenance: + required: true +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_ProvenanceWithExceptions(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Provenance: &BooleanRule{ + Required: true, + Exceptions: []ExceptionRule{ + {If: `${{ matches(artifact.name, "^datadog:.*") }}`}, + }, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + provenance: + required: true + exceptions: + - if: ${{ matches(artifact.name, "^datadog:.*") }} +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_TrailComplianceWithExceptions(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + TrailCompliance: &BooleanRule{ + Required: true, + Exceptions: []ExceptionRule{ + {If: `${{ flow.name == "legacy" }}`}, + }, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + trail-compliance: + required: true + exceptions: + - if: ${{ flow.name == "legacy" }} +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_SingleAttestation(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Attestations: []AttestationRule{ + {Type: "snyk", Name: "*"}, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + attestations: + - type: snyk + name: '*' +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_AttestationWithNameAndIf(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Attestations: []AttestationRule{ + { + Type: "pull_request", + Name: "pr-check", + If: `${{ flow.tags.risk-level == "high" }}`, + }, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + attestations: + - type: pull_request + name: pr-check + if: ${{ flow.tags.risk-level == "high" }} +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_MultipleAttestations(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Attestations: []AttestationRule{ + {Type: "snyk", Name: "security-scan"}, + {Type: "junit", Name: "*"}, + {Type: "custom:coverage-metrics", Name: "coverage"}, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + attestations: + - type: snyk + name: security-scan + - type: junit + name: '*' + - type: custom:coverage-metrics + name: coverage +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_FullPolicy(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Provenance: &BooleanRule{ + Required: true, + Exceptions: []ExceptionRule{ + {If: `${{ matches(artifact.name, "^datadog:.*") }}`}, + }, + }, + TrailCompliance: &BooleanRule{ + Required: true, + }, + Attestations: []AttestationRule{ + {Type: "snyk", Name: "security-scan"}, + { + Type: "pull_request", + Name: "pull-request", + If: `${{ flow.tags.risk-level == "high" }}`, + }, + {Type: "custom:coverage-metrics", Name: "coverage"}, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + provenance: + required: true + exceptions: + - if: ${{ matches(artifact.name, "^datadog:.*") }} + trail-compliance: + required: true + attestations: + - type: snyk + name: security-scan + - type: pull_request + name: pull-request + if: ${{ flow.tags.risk-level == "high" }} + - type: custom:coverage-metrics + name: coverage +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_WildcardNameExplicit(t *testing.T) { + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Attestations: []AttestationRule{ + {Type: "snyk", Name: "*"}, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + // name: "*" should always be explicit in output + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + attestations: + - type: snyk + name: '*' +` + assert.Equal(t, expected, string(out)) +} + +func TestToYAML_WildcardTypeRequiresNonWildcardName(t *testing.T) { + // When type is "*", name must not be "*" per the schema + p := NewPolicy() + p.Artifacts = &ArtifactRules{ + Attestations: []AttestationRule{ + {Type: "*", Name: "security-scan"}, + }, + } + out, err := p.ToYAML() + require.NoError(t, err) + + expected := `_schema: https://docs.kosli.com/schemas/policy/v1 +artifacts: + attestations: + - type: '*' + name: security-scan +` + assert.Equal(t, expected, string(out)) +} diff --git a/internal/policywizard/context.go b/internal/policywizard/context.go new file mode 100644 index 000000000..cd63028f8 --- /dev/null +++ b/internal/policywizard/context.go @@ -0,0 +1,59 @@ +package policywizard + +import "github.com/charmbracelet/lipgloss" + +// FetchResult holds the data returned by the API fetch. +type FetchResult struct { + FlowNames []string + CustomAttestTypes []string +} + +// Context holds data fetched from the API to populate wizard options. +type Context struct { + FlowNames []string + CustomAttestTypes []string + // FetchFunc is called asynchronously to fetch API data. If nil, no fetch is performed. + FetchFunc func() FetchResult +} + +// Kosli brand colors for terminal UI. +const ( + colorBlue = lipgloss.Color("#1C4BC6") // Blue 600 — primary accent + colorGreen = lipgloss.Color("#45A26D") // Success green + colorRed = lipgloss.Color("#C13D33") // Error red + colorTextDim = lipgloss.Color("#646A71") // Tertiary text +) + +type styles struct { + base lipgloss.Style + title lipgloss.Style + preview lipgloss.Style + previewText lipgloss.Style + footer lipgloss.Style + accent lipgloss.Style + err lipgloss.Style +} + +func newStyles() styles { + return styles{ + base: lipgloss.NewStyle().Padding(1, 2), + title: lipgloss.NewStyle(). + Bold(true). + Foreground(colorBlue). + Padding(0, 1), + preview: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBlue). + Padding(1, 2), + previewText: lipgloss.NewStyle(). + Foreground(colorGreen), + footer: lipgloss.NewStyle(). + Foreground(colorTextDim). + Padding(1, 1, 0, 1), + accent: lipgloss.NewStyle(). + Foreground(colorBlue), + err: lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true), + } +} diff --git a/internal/policywizard/forms.go b/internal/policywizard/forms.go new file mode 100644 index 000000000..fbaaf23bd --- /dev/null +++ b/internal/policywizard/forms.go @@ -0,0 +1,611 @@ +package policywizard + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/charmbracelet/huh" + "github.com/kosli-dev/cli/internal/policy" +) + +// --------------------------------------------------------------------------- +// Step and target enums +// --------------------------------------------------------------------------- + +type wizardStep int + +const ( + stepLoading wizardStep = iota + stepProvConfirm + stepProvExcConfirm + stepTrailConfirm + stepTrailExcConfirm + stepAttConfirm + stepAttDetails + stepAttCondConfirm + stepExprMode + stepExprFlowName + stepExprFlowTag + stepExprFlowTagOp + stepExprArtifactName + stepExprCustomCtx + stepExprCustomTagKey + stepExprCustomOp + stepExprRaw + stepExprNegate + stepExprCombineConfirm + stepExprCombineOp + stepSaveFile + stepDone +) + +type exprTarget int + +const ( + targetProvException exprTarget = iota + targetTrailException + targetAttCondition +) + +// --------------------------------------------------------------------------- +// Form builders +// --------------------------------------------------------------------------- + +var builtInAttestationTypes = []string{ + "generic", "junit", "snyk", "pull_request", "jira", "sonar", "*", +} + +func (m *Model) buildForm() *huh.Form { + var f *huh.Form + switch m.step { + case stepProvConfirm: + f = confirmForm("Require artifact provenance?", + "All artifacts must belong to a Kosli flow") + + case stepTrailConfirm: + f = confirmForm("Require trail compliance?", + "All artifacts must be part of compliant trails") + + case stepProvExcConfirm: + f = confirmForm(m.excConfirmTitle("provenance"), + "Exceptions waive this requirement for matching artifacts") + + case stepTrailExcConfirm: + f = confirmForm(m.excConfirmTitle("trail compliance"), + "Exceptions waive this requirement for matching artifacts") + + case stepAttConfirm: + title := "Add a required attestation?" + if m.Policy.Artifacts != nil && len(m.Policy.Artifacts.Attestations) > 0 { + title = "Add another required attestation?" + } + f = confirmForm(title, "") + + case stepAttDetails: + allTypes := slices.Clone(builtInAttestationTypes) + allTypes = append(allTypes, m.ctx.CustomAttestTypes...) + opts := make([]huh.Option[string], len(allTypes)) + for i, t := range allTypes { + opts[i] = huh.NewOption(t, t) + } + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("type"). + Title("Attestation type"). + Options(opts...), + huh.NewInput().Key("name"). + Title("Attestation name"). + Description("Use * to match any name for this type"). + Placeholder("*"), + )) + + case stepAttCondConfirm: + f = confirmForm("Add a condition for this attestation?", + "Only require when condition is met") + + case stepExprMode: + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("mode"). + Title("How do you want to define this condition?"). + Options( + huh.NewOption("Match by flow name", "flow_name"), + huh.NewOption("Match by flow tag", "flow_tag"), + huh.NewOption("Match by artifact name pattern", "artifact_name"), + huh.NewOption("Custom comparison", "custom"), + huh.NewOption("Write raw expression", "raw"), + ), + )) + + case stepExprFlowName: + if len(m.ctx.FlowNames) > 0 { + opts := make([]huh.Option[string], len(m.ctx.FlowNames)) + for i, n := range m.ctx.FlowNames { + opts[i] = huh.NewOption(n, n) + } + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("value"). + Title("Select a flow").Options(opts...), + )) + } else { + f = inputForm("value", "Flow name", "The flow name to match", "", "flow name") + } + + case stepExprFlowTag: + f = inputForm("value", "Tag key", "e.g. team, risk-level, key.with.dots", "", "tag key") + + case stepExprFlowTagOp: + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("op").Title("Operator"). + Options(huh.NewOptions("==", "!=", ">", "<", ">=", "<=")...), + huh.NewInput().Key("value").Title("Value"). + Description("The value to compare against"). + Validate(notEmpty("value")), + )) + + case stepExprArtifactName: + f = huh.NewForm(huh.NewGroup( + huh.NewInput().Key("value"). + Title("Artifact name regex"). + Description("e.g. ^datadog:.*"). + Placeholder("^datadog:.*"). + Validate(validateRegex), + )) + + case stepExprCustomCtx: + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("value").Title("Context field"). + Options( + huh.NewOption("flow.name", "flow.name"), + huh.NewOption("flow.tags.", "flow.tags."), + huh.NewOption("artifact.name", "artifact.name"), + huh.NewOption("artifact.fingerprint", "artifact.fingerprint"), + ), + )) + + case stepExprCustomTagKey: + f = inputForm("value", "Tag key", "The flow tag key (e.g. team, risk-level)", "", "tag key") + + case stepExprCustomOp: + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("op").Title("Operator"). + Options(huh.NewOptions("==", "!=", ">", "<", ">=", "<=", "matches", "exists")...), + huh.NewInput().Key("value").Title("Value"). + Description("The value to compare against (ignored for exists)"), + )) + + case stepExprRaw: + f = inputForm("value", "Raw expression", + `e.g. flow.name == "prod" and artifact.name == "svc"`, + `flow.name == "prod"`, "expression") + + case stepExprNegate: + f = confirmForm("Negate this condition?", + "Prefix with not — e.g. not flow.name == \"prod\"") + + case stepExprCombineConfirm: + f = confirmForm("Combine with another condition?", + "Chain with and/or — e.g. expr1 and expr2") + + case stepExprCombineOp: + f = huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Key("value").Title("Logical operator"). + Options( + huh.NewOption("and — both must be true", "and"), + huh.NewOption("or — either can be true", "or"), + ), + )) + + case stepSaveFile: + f = huh.NewForm(huh.NewGroup( + huh.NewInput().Key("filename"). + Title("Save policy to file"). + Description("Press enter to accept default"). + Placeholder("policy.yaml"). + Validate(validateYAMLExtension), + )) + + default: + // Every step must have a form. If we reach here, a new step was + // added without a matching case — show an empty form rather than crash. + f = huh.NewForm(huh.NewGroup()) + } + + return f.WithWidth(formWidth).WithShowHelp(true).WithShowErrors(true) +} + +// --------------------------------------------------------------------------- +// Form helpers +// --------------------------------------------------------------------------- + +func confirmForm(title, description string) *huh.Form { + c := huh.NewConfirm().Key("confirm"). + Title(title). + Affirmative("Yes").Negative("No") + if description != "" { + c = c.Description(description) + } + return huh.NewForm(huh.NewGroup(c)) +} + +func inputForm(key, title, description, placeholder, requiredName string) *huh.Form { + inp := huh.NewInput().Key(key).Title(title). + Description(description). + Placeholder(placeholder). + Validate(notEmpty(requiredName)) + return huh.NewForm(huh.NewGroup(inp)) +} + +func notEmpty(field string) func(string) error { + return func(s string) error { + if s == "" { + return fmt.Errorf("%s is required", field) + } + return nil + } +} + +func validateRegex(s string) error { + if s == "" { + return fmt.Errorf("regex is required") + } + if _, err := regexp.Compile(s); err != nil { + return fmt.Errorf("invalid regex: %s", err) + } + return nil +} + +func validateYAMLExtension(s string) error { + if s == "" { + return nil // placeholder "policy.yaml" will be used + } + ext := strings.ToLower(filepath.Ext(s)) + if ext != ".yaml" && ext != ".yml" { + return fmt.Errorf("file must have a .yaml or .yml extension") + } + if _, err := os.Stat(s); err == nil { + return fmt.Errorf("file %q already exists; choose a different name", s) + } + return nil +} + +func (m *Model) excConfirmTitle(rule string) string { + var count int + if rule == "provenance" && m.Policy.Artifacts != nil && m.Policy.Artifacts.Provenance != nil { + count = len(m.Policy.Artifacts.Provenance.Exceptions) + } + if rule == "trail compliance" && m.Policy.Artifacts != nil && m.Policy.Artifacts.TrailCompliance != nil { + count = len(m.Policy.Artifacts.TrailCompliance.Exceptions) + } + if count > 0 { + return fmt.Sprintf("Add another exception to %s?", rule) + } + return fmt.Sprintf("Add an exception to %s?", rule) +} + +// --------------------------------------------------------------------------- +// Form values — extracted from huh form for testability +// --------------------------------------------------------------------------- + +type formValues struct { + confirm bool + str string // generic string: value, filename, mode + attType string + attName string + operator string +} + +func extractFormValues(f *huh.Form) formValues { + return formValues{ + confirm: f.GetBool("confirm"), + str: firstNonEmpty(f.GetString("value"), f.GetString("filename"), f.GetString("mode")), + attType: f.GetString("type"), + attName: f.GetString("name"), + operator: f.GetString("op"), + } +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +// --------------------------------------------------------------------------- +// State transitions: processFormResults +// --------------------------------------------------------------------------- + +func (m *Model) processFormResults() { + m.applyFormValues(extractFormValues(m.form)) +} + +func (m *Model) applyFormValues(fv formValues) { + m.lastConfirm = fv.confirm + switch m.step { + case stepProvConfirm: + m.requireProv = fv.confirm + if m.requireProv { + if m.Policy.Artifacts == nil { + m.Policy.Artifacts = &policy.ArtifactRules{} + } + m.Policy.Artifacts.Provenance = &policy.BooleanRule{Required: true} + } + + case stepTrailConfirm: + m.requireTrail = fv.confirm + if m.requireTrail { + if m.Policy.Artifacts == nil { + m.Policy.Artifacts = &policy.ArtifactRules{} + } + m.Policy.Artifacts.TrailCompliance = &policy.BooleanRule{Required: true} + } + + case stepAttDetails: + name := fv.attName + if name == "" { + name = "*" + } + if fv.attType == "*" && name == "*" { + m.validationErr = "when type is *, name must not be * — please specify a name" + return + } + m.validationErr = "" + m.currentAttRule = policy.AttestationRule{ + Type: fv.attType, + Name: name, + } + + case stepExprMode: + m.exprMode = fv.str + + case stepExprFlowName: + m.storeSubExpr(policy.FlowNameExpr(fv.str)) + + case stepExprFlowTag: + m.exprTagKey = fv.str + + case stepExprFlowTagOp: + m.storeSubExpr(policy.FlowTagExpr(m.exprTagKey, fv.operator, fv.str)) + + case stepExprArtifactName: + m.storeSubExpr(policy.ArtifactNameMatchExpr(fv.str)) + + case stepExprCustomCtx: + m.exprContext = fv.str + + case stepExprCustomTagKey: + m.exprContext = "flow.tags." + fv.str + + case stepExprCustomOp: + m.validationErr = "" + switch fv.operator { + case "exists": + m.storeSubExpr(policy.ExistsExpr(m.exprContext)) + case "matches": + if fv.str == "" { + m.validationErr = "regex pattern is required for matches" + return + } + if err := validateRegex(fv.str); err != nil { + m.validationErr = err.Error() + return + } + m.storeSubExpr(policy.MatchesExpr(m.exprContext, fv.str)) + default: + if fv.str == "" { + m.validationErr = "value is required" + return + } + m.storeSubExpr(policy.ComparisonExpr(m.exprContext, fv.operator, fv.str)) + } + + case stepExprRaw: + m.storeSubExpr(policy.WrapExpr(fv.str)) + + case stepExprNegate: + if fv.confirm && len(m.pendingExprs) > 0 { + last := len(m.pendingExprs) - 1 + m.pendingExprs[last] = policy.NegateExpr(m.pendingExprs[last]) + } + + case stepExprCombineOp: + m.combineOp = fv.str + + case stepExprCombineConfirm: + if !fv.confirm { + m.finalizeExpression() + } + + case stepSaveFile: + m.OutputFile = fv.str + if m.OutputFile == "" { + m.OutputFile = "policy.yaml" + } + } +} + +// storeSubExpr unwraps a full expression and appends it to the pending list. +func (m *Model) storeSubExpr(expr string) { + m.pendingExprs = append(m.pendingExprs, policy.UnwrapExpr(expr)) +} + +// finalizeExpression combines all pending sub-expressions and applies them. +func (m *Model) finalizeExpression() { + if len(m.pendingExprs) == 0 { + return + } + op := m.combineOp + if op == "" { + op = "and" + } + combined := policy.CombineExprs(op, m.pendingExprs...) + m.applyExpression(combined) + m.pendingExprs = nil + m.combineOp = "" +} + +func (m *Model) applyExpression(expr string) { + switch m.exprTarget { + case targetProvException: + if m.Policy.Artifacts == nil || m.Policy.Artifacts.Provenance == nil { + return + } + m.Policy.Artifacts.Provenance.Exceptions = append( + m.Policy.Artifacts.Provenance.Exceptions, + policy.ExceptionRule{If: expr}, + ) + case targetTrailException: + if m.Policy.Artifacts == nil || m.Policy.Artifacts.TrailCompliance == nil { + return + } + m.Policy.Artifacts.TrailCompliance.Exceptions = append( + m.Policy.Artifacts.TrailCompliance.Exceptions, + policy.ExceptionRule{If: expr}, + ) + case targetAttCondition: + m.currentAttRule.If = expr + m.commitAttestation() + } +} + +func (m *Model) commitAttestation() { + if m.Policy.Artifacts == nil { + m.Policy.Artifacts = &policy.ArtifactRules{} + } + m.Policy.Artifacts.Attestations = append(m.Policy.Artifacts.Attestations, m.currentAttRule) + m.currentAttRule = policy.AttestationRule{} +} + +// --------------------------------------------------------------------------- +// State transitions: advanceStep +// --------------------------------------------------------------------------- + +func (m *Model) advanceStep() { + switch m.step { + case stepProvConfirm: + if m.requireProv { + m.exprTarget = targetProvException + m.step = stepProvExcConfirm + } else { + m.step = stepTrailConfirm + } + + case stepProvExcConfirm: + if m.lastConfirm { + m.exprTarget = targetProvException + m.pendingExprs = nil + m.combineOp = "" + m.step = stepExprMode + } else { + m.step = stepTrailConfirm + } + + case stepTrailConfirm: + if m.requireTrail { + m.exprTarget = targetTrailException + m.step = stepTrailExcConfirm + } else { + m.step = stepAttConfirm + } + + case stepTrailExcConfirm: + if m.lastConfirm { + m.exprTarget = targetTrailException + m.pendingExprs = nil + m.combineOp = "" + m.step = stepExprMode + } else { + m.step = stepAttConfirm + } + + case stepAttConfirm: + if m.lastConfirm { + m.step = stepAttDetails + } else { + m.step = stepSaveFile + } + + case stepAttDetails: + m.step = stepAttCondConfirm + + case stepAttCondConfirm: + if m.lastConfirm { + m.exprTarget = targetAttCondition + m.pendingExprs = nil + m.combineOp = "" + m.step = stepExprMode + } else { + m.commitAttestation() + m.step = stepAttConfirm + } + + case stepExprMode: + switch m.exprMode { + case "flow_name": + m.step = stepExprFlowName + case "flow_tag": + m.step = stepExprFlowTag + case "artifact_name": + m.step = stepExprArtifactName + case "custom": + m.step = stepExprCustomCtx + case "raw": + m.step = stepExprRaw + } + + case stepExprFlowName: + m.step = stepExprNegate + case stepExprFlowTag: + m.step = stepExprFlowTagOp + case stepExprFlowTagOp: + m.step = stepExprNegate + case stepExprArtifactName: + m.step = stepExprNegate + + case stepExprCustomCtx: + if m.exprContext == "flow.tags." { + m.step = stepExprCustomTagKey + } else { + m.step = stepExprCustomOp + } + + case stepExprCustomTagKey: + m.step = stepExprCustomOp + case stepExprCustomOp: + m.step = stepExprNegate + case stepExprRaw: + m.step = stepExprNegate + + case stepExprNegate: + m.step = stepExprCombineConfirm + + case stepExprCombineConfirm: + if m.lastConfirm { + m.step = stepExprCombineOp + } else { + m.advanceAfterExpr() + } + + case stepExprCombineOp: + m.step = stepExprMode + + case stepSaveFile: + m.step = stepDone + } +} + +func (m *Model) advanceAfterExpr() { + switch m.exprTarget { + case targetProvException: + m.step = stepProvExcConfirm + case targetTrailException: + m.step = stepTrailExcConfirm + case targetAttCondition: + m.step = stepAttConfirm + } +} diff --git a/internal/policywizard/forms_test.go b/internal/policywizard/forms_test.go new file mode 100644 index 000000000..33308583a --- /dev/null +++ b/internal/policywizard/forms_test.go @@ -0,0 +1,702 @@ +package policywizard + +import ( + "testing" + + "github.com/kosli-dev/cli/internal/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// advanceStep tests +// --------------------------------------------------------------------------- + +func TestAdvance_ProvConfirm_RequiredGoesToExceptions(t *testing.T) { + m := newTestModel() + m.step = stepProvConfirm + m.requireProv = true + + m.advanceStep() + + assert.Equal(t, stepProvExcConfirm, m.step) + assert.Equal(t, targetProvException, m.exprTarget) +} + +func TestAdvance_ProvConfirm_NotRequiredGoesToTrail(t *testing.T) { + m := newTestModel() + m.step = stepProvConfirm + m.requireProv = false + + m.advanceStep() + + assert.Equal(t, stepTrailConfirm, m.step) +} + +func TestAdvance_TrailConfirm_RequiredGoesToExceptions(t *testing.T) { + m := newTestModel() + m.step = stepTrailConfirm + m.requireTrail = true + + m.advanceStep() + + assert.Equal(t, stepTrailExcConfirm, m.step) +} + +func TestAdvance_TrailConfirm_NotRequiredGoesToAtt(t *testing.T) { + m := newTestModel() + m.step = stepTrailConfirm + m.requireTrail = false + + m.advanceStep() + + assert.Equal(t, stepAttConfirm, m.step) +} + +func TestAdvance_ProvExcConfirm_YesGoesToExprMode(t *testing.T) { + m := newTestModel() + m.step = stepProvExcConfirm + m.lastConfirm = true + + m.advanceStep() + + assert.Equal(t, stepExprMode, m.step) + assert.Equal(t, targetProvException, m.exprTarget) +} + +func TestAdvance_ProvExcConfirm_NoGoesToTrail(t *testing.T) { + m := newTestModel() + m.step = stepProvExcConfirm + m.lastConfirm = false + + m.advanceStep() + + assert.Equal(t, stepTrailConfirm, m.step) +} + +func TestAdvance_TrailExcConfirm_YesGoesToExprMode(t *testing.T) { + m := newTestModel() + m.step = stepTrailExcConfirm + m.lastConfirm = true + + m.advanceStep() + + assert.Equal(t, stepExprMode, m.step) + assert.Equal(t, targetTrailException, m.exprTarget) +} + +func TestAdvance_TrailExcConfirm_NoGoesToAtt(t *testing.T) { + m := newTestModel() + m.step = stepTrailExcConfirm + m.lastConfirm = false + + m.advanceStep() + + assert.Equal(t, stepAttConfirm, m.step) +} + +func TestAdvance_AttConfirm_YesGoesToDetails(t *testing.T) { + m := newTestModel() + m.step = stepAttConfirm + m.lastConfirm = true + + m.advanceStep() + + assert.Equal(t, stepAttDetails, m.step) +} + +func TestAdvance_AttConfirm_NoGoesToSaveFile(t *testing.T) { + m := newTestModel() + m.step = stepAttConfirm + m.lastConfirm = false + + m.advanceStep() + + assert.Equal(t, stepSaveFile, m.step) +} + +func TestAdvance_AttCondConfirm_YesGoesToExprMode(t *testing.T) { + m := newTestModel() + m.step = stepAttCondConfirm + m.lastConfirm = true + + m.advanceStep() + + assert.Equal(t, stepExprMode, m.step) + assert.Equal(t, targetAttCondition, m.exprTarget) +} + +func TestAdvance_AttCondConfirm_NoCommitsAndLoops(t *testing.T) { + m := newTestModel() + m.step = stepAttCondConfirm + m.lastConfirm = false + m.currentAttRule = policy.AttestationRule{Type: "snyk", Name: "scan"} + m.Policy.Artifacts = &policy.ArtifactRules{} + + m.advanceStep() + + assert.Equal(t, stepAttConfirm, m.step) + require.Len(t, m.Policy.Artifacts.Attestations, 1) + assert.Equal(t, "snyk", m.Policy.Artifacts.Attestations[0].Type) +} + +func TestAdvance_ExprMode_AllModes(t *testing.T) { + tests := []struct { + mode string + expected wizardStep + }{ + {"flow_name", stepExprFlowName}, + {"flow_tag", stepExprFlowTag}, + {"artifact_name", stepExprArtifactName}, + {"custom", stepExprCustomCtx}, + {"raw", stepExprRaw}, + } + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + m := newTestModel() + m.step = stepExprMode + m.exprMode = tt.mode + m.advanceStep() + assert.Equal(t, tt.expected, m.step) + }) + } +} + +func TestAdvance_ExprCustomCtx_TagKeyGoesToTagKeyStep(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomCtx + m.exprContext = "flow.tags." + + m.advanceStep() + + assert.Equal(t, stepExprCustomTagKey, m.step) +} + +func TestAdvance_ExprCustomCtx_DirectGoesToOp(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomCtx + m.exprContext = "flow.name" + + m.advanceStep() + + assert.Equal(t, stepExprCustomOp, m.step) +} + +func TestAdvanceAfterExpr_AllTargets(t *testing.T) { + tests := []struct { + target exprTarget + expected wizardStep + }{ + {targetProvException, stepProvExcConfirm}, + {targetTrailException, stepTrailExcConfirm}, + {targetAttCondition, stepAttConfirm}, + } + for _, tt := range tests { + m := newTestModel() + m.exprTarget = tt.target + m.advanceAfterExpr() + assert.Equal(t, tt.expected, m.step) + } +} + +func TestAdvance_ExprFlowName_GoesToNegate(t *testing.T) { + m := newTestModel() + m.step = stepExprFlowName + m.advanceStep() + assert.Equal(t, stepExprNegate, m.step) +} + +func TestAdvance_ExprNegate_GoesToCombineConfirm(t *testing.T) { + m := newTestModel() + m.step = stepExprNegate + m.advanceStep() + assert.Equal(t, stepExprCombineConfirm, m.step) +} + +func TestAdvance_ExprCombineConfirm_Yes_GoesToCombineOp(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineConfirm + m.lastConfirm = true + m.advanceStep() + assert.Equal(t, stepExprCombineOp, m.step) +} + +func TestAdvance_ExprCombineConfirm_No_Finalizes(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineConfirm + m.lastConfirm = false + m.exprTarget = targetProvException + m.advanceStep() + assert.Equal(t, stepProvExcConfirm, m.step) +} + +func TestAdvance_ExprCombineOp_GoesToExprMode(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineOp + m.advanceStep() + assert.Equal(t, stepExprMode, m.step) +} + +// --------------------------------------------------------------------------- +// applyFormValues tests +// --------------------------------------------------------------------------- + +func TestApply_ProvConfirm_True_SetsProvenance(t *testing.T) { + m := newTestModel() + m.step = stepProvConfirm + + m.applyFormValues(formValues{confirm: true}) + + assert.True(t, m.requireProv) + require.NotNil(t, m.Policy.Artifacts) + require.NotNil(t, m.Policy.Artifacts.Provenance) + assert.True(t, m.Policy.Artifacts.Provenance.Required) +} + +func TestApply_ProvConfirm_False_NoArtifacts(t *testing.T) { + m := newTestModel() + m.step = stepProvConfirm + + m.applyFormValues(formValues{confirm: false}) + + assert.False(t, m.requireProv) + assert.Nil(t, m.Policy.Artifacts) +} + +func TestApply_TrailConfirm_True_SetsTrailCompliance(t *testing.T) { + m := newTestModel() + m.step = stepTrailConfirm + + m.applyFormValues(formValues{confirm: true}) + + assert.True(t, m.requireTrail) + require.NotNil(t, m.Policy.Artifacts) + require.NotNil(t, m.Policy.Artifacts.TrailCompliance) + assert.True(t, m.Policy.Artifacts.TrailCompliance.Required) +} + +func TestApply_AttDetails_SetsCurrentRule(t *testing.T) { + m := newTestModel() + m.step = stepAttDetails + + m.applyFormValues(formValues{attType: "snyk", attName: "security-scan"}) + + assert.Equal(t, "snyk", m.currentAttRule.Type) + assert.Equal(t, "security-scan", m.currentAttRule.Name) + assert.Empty(t, m.validationErr) +} + +func TestApply_AttDetails_EmptyNameDefaultsToWildcard(t *testing.T) { + m := newTestModel() + m.step = stepAttDetails + + m.applyFormValues(formValues{attType: "snyk", attName: ""}) + + assert.Equal(t, "*", m.currentAttRule.Name) +} + +func TestApply_AttDetails_WildcardTypeAndName_Rejected(t *testing.T) { + m := newTestModel() + m.step = stepAttDetails + + m.applyFormValues(formValues{attType: "*", attName: "*"}) + + assert.Contains(t, m.validationErr, "name must not be *") + assert.Equal(t, policy.AttestationRule{}, m.currentAttRule) +} + +func TestApply_AttDetails_WildcardTypeEmptyName_Rejected(t *testing.T) { + m := newTestModel() + m.step = stepAttDetails + + m.applyFormValues(formValues{attType: "*", attName: ""}) + + assert.Contains(t, m.validationErr, "name must not be *") +} + +func TestApply_ExprMode_StoresMode(t *testing.T) { + m := newTestModel() + m.step = stepExprMode + + m.applyFormValues(formValues{str: "flow_tag"}) + + assert.Equal(t, "flow_tag", m.exprMode) +} + +func TestApply_ExprFlowName_StoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprFlowName + + m.applyFormValues(formValues{str: "prod"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `flow.name == "prod"`, m.pendingExprs[0]) +} + +func TestApply_ExprFlowTag_StoresTagKey(t *testing.T) { + m := newTestModel() + m.step = stepExprFlowTag + + m.applyFormValues(formValues{str: "risk-level"}) + + assert.Equal(t, "risk-level", m.exprTagKey) +} + +func TestApply_ExprFlowTagOp_StoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprFlowTagOp + m.exprTagKey = "team" + + m.applyFormValues(formValues{operator: "==", str: "backend"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `flow.tags.team == "backend"`, m.pendingExprs[0]) +} + +func TestApply_ExprArtifactName_StoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprArtifactName + + m.applyFormValues(formValues{str: "^datadog:.*"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `matches(artifact.name, "^datadog:.*")`, m.pendingExprs[0]) +} + +func TestApply_ExprRaw_StoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprRaw + + m.applyFormValues(formValues{str: `flow.name == "prod"`}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `flow.name == "prod"`, m.pendingExprs[0]) +} + +func TestApply_ExprCustomCtx_StoresContext(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomCtx + + m.applyFormValues(formValues{str: "artifact.name"}) + + assert.Equal(t, "artifact.name", m.exprContext) +} + +func TestApply_ExprCustomTagKey_BuildsContext(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomTagKey + + m.applyFormValues(formValues{str: "risk-level"}) + + assert.Equal(t, "flow.tags.risk-level", m.exprContext) +} + +func TestApply_ExprCustomOp_StoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "artifact.name" + + m.applyFormValues(formValues{operator: "==", str: "myapp"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `artifact.name == "myapp"`, m.pendingExprs[0]) +} + +func TestApply_ExprCustomOp_MatchesStoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + m.applyFormValues(formValues{operator: "matches", str: "^prod"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `matches(flow.name, "^prod")`, m.pendingExprs[0]) +} + +func TestApply_ExprCustomOp_MatchesInvalidRegex(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + m.applyFormValues(formValues{operator: "matches", str: "[unclosed"}) + + assert.Empty(t, m.pendingExprs) + assert.Contains(t, m.validationErr, "invalid regex") +} + +func TestApply_ExprCustomOp_MatchesValidRetryClears(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + // First attempt: invalid regex + m.applyFormValues(formValues{operator: "matches", str: "[unclosed"}) + assert.Contains(t, m.validationErr, "invalid regex") + + // Second attempt: valid regex should clear error + m.applyFormValues(formValues{operator: "matches", str: "^prod"}) + assert.Empty(t, m.validationErr) + require.Len(t, m.pendingExprs, 1) +} + +func TestApply_ExprCustomOp_SwitchOperatorClearsError(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + // First attempt: invalid regex with matches + m.applyFormValues(formValues{operator: "matches", str: "[unclosed"}) + assert.Contains(t, m.validationErr, "invalid regex") + + // Switch to == operator should clear error + m.applyFormValues(formValues{operator: "==", str: "prod"}) + assert.Empty(t, m.validationErr) + require.Len(t, m.pendingExprs, 1) +} + +func TestApply_ExprCustomOp_EmptyValueRejected(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + m.applyFormValues(formValues{operator: "==", str: ""}) + + assert.Empty(t, m.pendingExprs) + assert.Contains(t, m.validationErr, "value is required") +} + +func TestApply_ExprCustomOp_MatchesEmptyRegexRejected(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow.name" + + m.applyFormValues(formValues{operator: "matches", str: ""}) + + assert.Empty(t, m.pendingExprs) + assert.Contains(t, m.validationErr, "regex pattern is required") +} + +func TestApply_ExprCustomOp_ExistsStoresPending(t *testing.T) { + m := newTestModel() + m.step = stepExprCustomOp + m.exprContext = "flow" + + m.applyFormValues(formValues{operator: "exists"}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `exists(flow)`, m.pendingExprs[0]) +} + +// --------------------------------------------------------------------------- +// Negate tests +// --------------------------------------------------------------------------- + +func TestApply_ExprNegate_Yes_NegatesLastPending(t *testing.T) { + m := newTestModel() + m.step = stepExprNegate + m.pendingExprs = []string{`flow.name == "prod"`} + + m.applyFormValues(formValues{confirm: true}) + + require.Len(t, m.pendingExprs, 1) + assert.Equal(t, `not (flow.name == "prod")`, m.pendingExprs[0]) +} + +func TestApply_ExprNegate_No_LeavesUnchanged(t *testing.T) { + m := newTestModel() + m.step = stepExprNegate + m.pendingExprs = []string{`flow.name == "prod"`} + + m.applyFormValues(formValues{confirm: false}) + + assert.Equal(t, `flow.name == "prod"`, m.pendingExprs[0]) +} + +// --------------------------------------------------------------------------- +// Combine tests +// --------------------------------------------------------------------------- + +func TestApply_ExprCombineOp_StoresOp(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineOp + + m.applyFormValues(formValues{str: "and"}) + + assert.Equal(t, "and", m.combineOp) +} + +func TestApply_ExprCombineConfirm_No_Finalizes(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineConfirm + m.exprTarget = targetProvException + m.pendingExprs = []string{`flow.name == "prod"`} + m.Policy.Artifacts = &policy.ArtifactRules{ + Provenance: &policy.BooleanRule{Required: true}, + } + + m.applyFormValues(formValues{confirm: false}) + + require.Len(t, m.Policy.Artifacts.Provenance.Exceptions, 1) + assert.Equal(t, `${{ flow.name == "prod" }}`, m.Policy.Artifacts.Provenance.Exceptions[0].If) + assert.Nil(t, m.pendingExprs) +} + +func TestApply_ExprCombineConfirm_Yes_DoesNotFinalize(t *testing.T) { + m := newTestModel() + m.step = stepExprCombineConfirm + m.exprTarget = targetProvException + m.pendingExprs = []string{`flow.name == "prod"`} + m.Policy.Artifacts = &policy.ArtifactRules{ + Provenance: &policy.BooleanRule{Required: true}, + } + + m.applyFormValues(formValues{confirm: true}) + + assert.Len(t, m.Policy.Artifacts.Provenance.Exceptions, 0) + assert.Len(t, m.pendingExprs, 1) // still pending +} + +// --------------------------------------------------------------------------- +// Full combine flow: two expressions with "and" +// --------------------------------------------------------------------------- + +func TestCombineFlow_TwoExprsWithAnd(t *testing.T) { + m := newTestModel() + m.exprTarget = targetProvException + m.Policy.Artifacts = &policy.ArtifactRules{ + Provenance: &policy.BooleanRule{Required: true}, + } + + // First expression + m.step = stepExprFlowName + m.applyFormValues(formValues{str: "prod"}) + + // Don't negate + m.step = stepExprNegate + m.applyFormValues(formValues{confirm: false}) + + // Combine with another + m.step = stepExprCombineConfirm + m.applyFormValues(formValues{confirm: true}) + + // Choose "and" + m.step = stepExprCombineOp + m.applyFormValues(formValues{str: "and"}) + + // Second expression + m.step = stepExprArtifactName + m.applyFormValues(formValues{str: "^svc:.*"}) + + // Don't negate + m.step = stepExprNegate + m.applyFormValues(formValues{confirm: false}) + + // Done combining + m.step = stepExprCombineConfirm + m.applyFormValues(formValues{confirm: false}) + + require.Len(t, m.Policy.Artifacts.Provenance.Exceptions, 1) + assert.Equal(t, + `${{ flow.name == "prod" and matches(artifact.name, "^svc:.*") }}`, + m.Policy.Artifacts.Provenance.Exceptions[0].If, + ) +} + +func TestCombineFlow_NegatedExprWithOr(t *testing.T) { + m := newTestModel() + m.exprTarget = targetTrailException + m.Policy.Artifacts = &policy.ArtifactRules{ + TrailCompliance: &policy.BooleanRule{Required: true}, + } + + // First expression + m.step = stepExprFlowName + m.applyFormValues(formValues{str: "staging"}) + + // Negate it + m.step = stepExprNegate + m.applyFormValues(formValues{confirm: true}) + + // Combine + m.step = stepExprCombineConfirm + m.applyFormValues(formValues{confirm: true}) + + m.step = stepExprCombineOp + m.applyFormValues(formValues{str: "or"}) + + // Second expression + m.step = stepExprCustomOp + m.exprContext = "flow" + m.applyFormValues(formValues{operator: "exists"}) + + // Don't negate + m.step = stepExprNegate + m.applyFormValues(formValues{confirm: false}) + + // Done + m.step = stepExprCombineConfirm + m.applyFormValues(formValues{confirm: false}) + + require.Len(t, m.Policy.Artifacts.TrailCompliance.Exceptions, 1) + assert.Equal(t, + `${{ not (flow.name == "staging") or exists(flow) }}`, + m.Policy.Artifacts.TrailCompliance.Exceptions[0].If, + ) +} + +func TestCombineFlow_AttCondition_CommitsAttestation(t *testing.T) { + m := newTestModel() + m.exprTarget = targetAttCondition + m.currentAttRule = policy.AttestationRule{Type: "snyk", Name: "scan"} + m.Policy.Artifacts = &policy.ArtifactRules{} + + // Single expression, no combine + m.step = stepExprFlowName + m.applyFormValues(formValues{str: "prod"}) + + m.step = stepExprNegate + m.applyFormValues(formValues{confirm: false}) + + m.step = stepExprCombineConfirm + m.applyFormValues(formValues{confirm: false}) + + require.Len(t, m.Policy.Artifacts.Attestations, 1) + assert.Equal(t, "snyk", m.Policy.Artifacts.Attestations[0].Type) + assert.Equal(t, `${{ flow.name == "prod" }}`, m.Policy.Artifacts.Attestations[0].If) +} + +func TestApply_SaveFile_SetsOutputFile(t *testing.T) { + m := newTestModel() + m.step = stepSaveFile + + m.applyFormValues(formValues{str: "my-policy.yaml"}) + + assert.Equal(t, "my-policy.yaml", m.OutputFile) +} + +func TestAdvance_SaveFile_GoesToDone(t *testing.T) { + m := newTestModel() + m.step = stepSaveFile + + m.advanceStep() + + assert.Equal(t, stepDone, m.step) +} + +func TestApply_StoresLastConfirm(t *testing.T) { + m := newTestModel() + m.step = stepProvConfirm + + m.applyFormValues(formValues{confirm: true}) + + assert.True(t, m.lastConfirm) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func newTestModel() *Model { + m := NewModel(&Context{}) + return &m +} diff --git a/internal/policywizard/model.go b/internal/policywizard/model.go new file mode 100644 index 000000000..2834ad6dc --- /dev/null +++ b/internal/policywizard/model.go @@ -0,0 +1,193 @@ +package policywizard + +import ( + "os" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/kosli-dev/cli/internal/policy" + "golang.org/x/term" +) + +const formWidth = 45 + +// fetchDoneMsg is sent when the async API fetch completes. +type fetchDoneMsg struct { + result FetchResult +} + +// Model is the bubbletea model for the policy wizard. +type Model struct { + step wizardStep + form *huh.Form + spinner spinner.Model + styles styles + width int + height int + ctx *Context + + // Public results — read after the program exits. + Policy *policy.Policy + OutputFile string + Cancelled bool + + // Internal state for loops and expression building. + exprTarget exprTarget + exprMode string + exprContext string + exprTagKey string + pendingExprs []string // raw (unwrapped) sub-expressions being combined + combineOp string // "and" or "or" + currentAttRule policy.AttestationRule + requireProv bool + requireTrail bool + lastConfirm bool + validationErr string +} + +// NewModel creates a new policy wizard model. +func NewModel(ctx *Context) Model { + s := spinner.New(spinner.WithSpinner(spinner.Dot)) + s.Style = lipgloss.NewStyle().Foreground(colorBlue) + + startStep := stepProvConfirm + if ctx.FetchFunc != nil { + startStep = stepLoading + } + + w := 80 + if tw, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && tw > 0 { + w = tw + } + + m := Model{ + step: startStep, + Policy: policy.NewPolicy(), + ctx: ctx, + styles: newStyles(), + spinner: s, + width: w, + } + if startStep != stepLoading { + m.form = m.buildForm() + } + return m +} + +func (m Model) Init() tea.Cmd { + if m.step == stepLoading { + return tea.Batch(m.spinner.Tick, m.startFetch()) + } + return m.form.Init() +} + +func (m Model) startFetch() tea.Cmd { + fetchFn := m.ctx.FetchFunc + return func() tea.Msg { + result := fetchFn() + return fetchDoneMsg{result: result} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.Cancelled = true + return m, tea.Quit + } + case fetchDoneMsg: + m.ctx.FlowNames = msg.result.FlowNames + m.ctx.CustomAttestTypes = msg.result.CustomAttestTypes + m.step = stepProvConfirm + m.form = m.buildForm() + return m, m.form.Init() + } + + // During loading, only update the spinner + if m.step == stepLoading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + + if m.form.State == huh.StateAborted { + m.Cancelled = true + return m, tea.Quit + } + + if m.form.State == huh.StateCompleted { + m.processFormResults() + if m.validationErr != "" { + m.form = m.buildForm() + return m, m.form.Init() + } + m.advanceStep() + if m.step == stepDone { + return m, tea.Quit + } + m.form = m.buildForm() + return m, m.form.Init() + } + + return m, cmd +} + +func (m Model) View() string { + if m.Cancelled || m.step == stepDone { + return "" + } + + s := m.styles + header := s.title.Render("Kosli Policy Builder") + + if m.step == stepLoading { + loading := m.spinner.View() + " Fetching flows and attestation types from Kosli..." + return s.base.Render(header + "\n\n" + loading) + } + + fw := formWidth + available := m.width - s.base.GetHorizontalFrameSize() + pw := available - fw - 2 + if pw < 25 { + pw = 0 + } + + formContent := m.form.View() + if m.validationErr != "" { + formContent = s.err.Render("⚠ "+m.validationErr) + "\n\n" + formContent + } + formView := lipgloss.NewStyle().Width(fw).Render(formContent) + + var body string + if pw > 0 { + yamlBytes, _ := m.Policy.ToYAML() + yamlStr := strings.TrimRight(string(yamlBytes), "\n") + if yamlStr == "" { + yamlStr = "(empty)" + } + previewContent := s.previewText.Render(yamlStr) + previewTitle := s.accent.Bold(true).Render("Live Preview") + previewPanel := s.preview.Width(pw). + Render(previewTitle + "\n\n" + previewContent) + + body = lipgloss.JoinHorizontal(lipgloss.Top, formView, " ", previewPanel) + } else { + body = formView + } + + footer := s.footer.Render("ctrl+c to cancel • enter to confirm") + + return s.base.Render(header + "\n\n" + body + "\n" + footer) +}