Skip to content

feat: add interactive policy-file wizard (kosli create policy-file)#766

Open
dangrondahl wants to merge 51 commits intomainfrom
create_policy_file
Open

feat: add interactive policy-file wizard (kosli create policy-file)#766
dangrondahl wants to merge 51 commits intomainfrom
create_policy_file

Conversation

@dangrondahl
Copy link
Copy Markdown
Contributor

@dangrondahl dangrondahl commented Apr 8, 2026

Why

Writing Kosli environment policy files by hand is harder than it should be. The YAML format, exception syntax, expression operators (==, !=, matches(), exists(), not, and, or), and available context fields (flow.name, flow.tags.<key>, artifact.name, artifact.fingerprint) are easy to forget — especially when you're creating your first policy or haven't touched one in a while.

This is an attempt to lower the barrier to creating the first (and many subsequent) policy files by providing a guided, interactive experience.

Summary

  • Add kosli create policy-file command that launches an interactive TUI wizard to build environment policy YAML files conforming to the policy schema
  • Side-by-side layout: form on left, live YAML preview on right (using charmbracelet/huh + bubbletea + lipgloss)
  • Guided expression builder for policy conditions (${{ }} syntax) with support for:
    • Flow name matching
    • Flow tag comparison
    • Artifact name regex (matches())
    • Field existence checks (exists())
    • Custom comparisons with all operators
    • Logical combining (and, or, not)
    • Raw expression fallback
  • Optional API fetch (spinner on startup) to populate flow names and custom attestation types in wizard selects
  • File validation: .yaml/.yml extension required, no silent overwrites

New packages

Package Purpose
internal/policy Policy model structs, YAML generation, expression builder helpers
internal/policywizard Bubbletea model, form builders, state machine, styles

Dependencies added

  • charmbracelet/huh — form components
  • charmbracelet/bubbletea — TUI framework
  • charmbracelet/lipgloss — styling
  • charmbracelet/bubbles — spinner

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 8, 2026

Claude finished @dangrondahl's task in 2m 43s —— View job


Review of PR #766: kosli create policy-file interactive wizard

  • Run static analysis (build, vet, mod tidy)
  • Read and review all changed files
  • Post inline comments on specific issues
  • Post summary review comment

Static Analysis

All three checks pass cleanly:

  • go build ./... — compiles without errors
  • go vet ./... — no issues
  • go mod tidygo.mod and go.sum are clean

Summary

This is a well-structured PR. Most of the issues raised in previous review rounds have been addressed — NegateExpr parenthesizes, CombineExprs guards zero args, UnwrapExpr handles varying whitespace, regex validation was added for matches in custom comparison, empty values are rejected, and validationErr is cleared on retry. The code is clean, well-separated, and thoroughly tested.

Remaining items worth addressing (1 actionable, 2 low-priority):

Priority File Issue
Medium createPolicyFile.go:91 File-exists check happens after the entire wizard — if the file exists, the user loses all their work. Move the os.Stat check into validateYAMLExtension in forms.go so users get feedback at stepSaveFile time (inline comment)
Low forms.go:175 exists operator still shows Value input that is silently ignored — a Placeholder("(not used)") would reduce confusion (inline comment)
Low forms.go:296 extractFormValues — the firstNonEmpty invariant still lacks a comment explaining the implicit coupling (inline comment)

What's done well

  • Clean separation of concerns: internal/policy handles data/YAML, internal/policywizard handles the TUI state machine, and the Cobra command is a thin orchestration layer. This makes the code easy to test and extend.
  • Thorough test coverage: 70+ unit tests covering the full state machine — advance steps, form value application, expression combining, negation, and edge cases like invalid regex retries and empty values. The tests are well-organized using table-driven patterns where appropriate.
  • Good defensive coding: nil guards in applyExpression, CombineExprs zero-arg guard, regex validation, terminal detection, empty-value rejection — these were all addressed in the latest commits.
  • The fetchNameList refactor (createPolicyFile.go:124) nicely deduplicates the two API fetch functions with a transform callback.
  • Expression builder design: The unwrap/wrap/combine/negate pipeline is clean and composable. The tests at expression_test.go:106-111 demonstrating CombineAndNegate show the pieces work well together.

Overall this is solid work — the medium-priority item (file-exists check timing) is the only one I'd want addressed before merge.

@dangrondahl
Copy link
Copy Markdown
Contributor Author

Addressed 5 of the 7 review comments in commits 8330ac02a7405272. Leaving the remaining two unaddressed:

  1. out io.Writer unused (createPolicyFile.go:45) — This is a codebase-wide convention; every newCreate*Cmd function accepts out io.Writer even when unused. Changing just this command would break the pattern. Not worth addressing in isolation.

  2. extractFormValues conflates keys via firstNonEmpty (forms.go:250) — Acknowledged, but each wizard step only ever uses one of "value", "filename", or "mode", so there's no ambiguity today. Adding per-step extraction would add complexity without a concrete benefit. If a future step uses multiple keys we can revisit.

@dangrondahl
Copy link
Copy Markdown
Contributor Author

Reviewed the latest 5 inline comments — all are informational observations, none blocking:

  1. fetchNameList silent empty result — Graceful degradation by design; the wizard falls back to free-text input.
  2. validateYAMLExtension allows empty — Intentional: empty = accept placeholder default ("policy.yaml"), handled in applyFormValues.
  3. form.WithWidth() not updated on resizeformWidth is a constant 45; only the preview panel is responsive to width changes.
  4. OutputFile not cleaned in modelfilepath.Clean is applied in runCreatePolicyFile before writing; the model value is only consumed there.
  5. validationErr persists on abort — Not a bug; abort exits the program immediately via tea.Quit.

No changes needed for these.

Add internal/policy package with Policy structs and ToYAML().
Wildcard name "*" is normalized to omit from YAML output.
Add FlowNameExpr, FlowNameInExpr, ArtifactNameMatchExpr,
ComparisonExpr, CombineExprs, and WrapExpr functions for
constructing ${{ }} policy expressions from structured inputs.
Wire up `kosli create policy-file` as a new subcommand.
Adds charmbracelet/huh dependency for interactive TUI wizard.
Minimal wizard: provenance + trail-compliance confirms, YAML to stdout.
Add interactive attestation collection with type select (6 built-in types)
and name input. Users can add multiple attestations in a loop.
The go.mod conflict resolution during rebase accidentally downgraded
kind from v0.31.0 to v0.11.1, breaking ExportKubeConfig API.
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)"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: stepExprCustomOp value field accepts empty input for comparison operators

The Value input has no Validate call, so for operators like ==, !=, >, etc., the user can submit an empty string. This produces expressions like ${{ flow.name == "" }} — technically valid but likely unintended.

Consider adding a conditional validator, or at minimum a notEmpty("value") validator (like stepExprFlowTagOp at line 145 uses).

}

outPath := filepath.Clean(wm.OutputFile)
if err := validateOutputFile(outPath); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX note: file-exists check happens after the entire wizard completes

validateOutputFile runs after the user has gone through the full wizard. If the file already exists, the user loses all their work. Consider validating the file at wizard time (in stepSaveFile's validator) rather than only after the wizard exits.

One option: add an os.Stat check in validateYAMLExtension (forms.go:259) so the user gets immediate feedback within the wizard.

Without clearing, a failed matches regex validation would leave
validationErr set, causing an infinite loop when the user retried
with a valid regex or switched to a different operator.
}

outPath := filepath.Clean(wm.OutputFile)
if err := validateOutputFile(outPath); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX: file-exists check happens after the entire wizard completes

If the target file already exists, the user loses all their work after completing the full wizard. Consider moving the os.Stat existence check into validateYAMLExtension in forms.go so the user gets immediate feedback within the wizard at stepSaveFile. That way they can pick a different name without restarting.

Something like:

func validateYAMLExtension(s string) error {
	if s == "" {
		return nil
	}
	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
}

The post-wizard validateOutputFile can remain as a safety net, but the primary check should be at input time.

Empty values produced expressions like ${{ flow.name == "" }} which
are technically valid but likely unintended. The exists operator
correctly allows empty values since it takes no argument.
return err
}

err = os.WriteFile(outPath, yamlBytes, 0644)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX: file-exists check happens after the entire wizard

If the target file already exists, the user loses all their wizard work. Consider moving the os.Stat existence check into validateYAMLExtension in forms.go so the user gets immediate feedback within the wizard at stepSaveFile:

func validateYAMLExtension(s string) error {
	if s == "" {
		return nil
	}
	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
}

The post-wizard validateOutputFile can remain as a safety net, but the primary check should happen at input time to avoid frustration.

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)"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: exists operator — Value field accepts input that is silently discarded

The description text helps, but users can still type a value and be confused when it doesn't appear in the output. Consider adding a validator that rejects non-empty input when exists is selected, or at least adding Placeholder("(not used)") to reinforce the message.

Low priority — the current description is a reasonable mitigation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant