Skip to content

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

Open
dangrondahl wants to merge 52 commits intomainfrom
create_policy_file
Open

feat: add interactive policy-file wizard (kosli create policy-file)#766
dangrondahl wants to merge 52 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 5m 30s —— View job


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

  • Run static analysis (go build, go vet, go mod tidy)
  • Read all changed files
  • Review code quality, edge cases, bugs, and testing
  • Post inline comments and summary

Static Analysis

All checks pass:

  • go build ./... — compiles cleanly
  • go vet ./... — no warnings
  • go mod tidy — no drift in go.mod / go.sum

Summary

This is a well-structured PR that adds a TUI wizard for building Kosli environment policy YAML files. The code is clean, the package decomposition is sensible (internal/policy for the model/expressions, internal/policywizard for the TUI), and the test coverage is thorough for the non-TUI logic.

The latest commits (aaa321fa through 47ebc660) have addressed the majority of the bugs and concerns from the prior review rounds. Specifically:

Previously flagged issues now fixed:

  • CombineExprs zero-args guard added
  • NegateExpr now parenthesizes (not (expr))
  • UnwrapExpr tolerates varying whitespace
  • matches operator now validates regex at stepExprCustomOp
  • validationErr cleared on successful retry (prevents infinite loop)
  • Empty values rejected for comparison operators
  • Empty regex rejected for matches
  • File existence check moved into wizard-time validateYAMLExtension
  • Type assertion uses comma-ok form
  • applyExpression nil-guards for provenance/trail
  • Terminal width read at init time (was hardcoded 120)

Remaining non-blocking observations (see inline comments):

  1. extractFormValues comment — The firstNonEmpty conflation of "value", "filename", and "mode" works today but the invariant is undocumented. One comment line would prevent future mistakes.

  2. exists operator UX — The Value input remains visible and accepts input that is silently discarded. Description text helps but a Placeholder("(not used)") would reinforce the message.

  3. Post-wizard validateOutputFile redundancy — Now that the wizard validates file existence at input time, the post-wizard check is only useful for the TOCTOU gap. Fine as a safety net.


What's Good

  • Clean separation: Policy model, expression builder, and wizard UI are in separate packages with clear interfaces. The internal/policy package is independently useful.
  • Test coverage: 700+ lines of tests for forms_test.go, covering state machine transitions, all operator paths, combine flows, negation, validation errors, and retry behavior. The expression builder tests are comprehensive. The policy_test.go covers all YAML generation paths.
  • Robustness of expression builder: Guards against empty inputs, invalid regex, zero-arg combines, precedence ambiguity — all the sharp edges that earlier reviews surfaced.
  • Graceful API degradation: If the API is unreachable, the wizard silently falls back to free-text input instead of failing.
  • Follows codebase conventions: Command registration pattern, newCreate*Cmd(out io.Writer) signature, TempDir-based test helpers.

Verdict

The bug fixes from the latest commits address all the material issues from prior reviews. The remaining comments are cosmetic/defensive. This looks ready to merge.

Comment thread cmd/kosli/createPolicyFile.go Outdated
Comment thread cmd/kosli/createPolicyFile.go
Comment thread internal/policy/expression.go
Comment thread internal/policywizard/forms.go Outdated
Comment thread internal/policywizard/forms.go
Comment thread internal/policywizard/model.go
Comment thread internal/policywizard/forms.go
@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.

Comment thread internal/policywizard/forms.go Outdated
Comment thread internal/policywizard/forms.go
Comment thread cmd/kosli/createPolicyFile.go
Comment thread internal/policywizard/forms.go
Comment thread internal/policy/expression.go
Comment thread internal/policywizard/forms.go
Comment thread internal/policy/expression.go
Comment thread internal/policywizard/model.go Outdated
Comment thread internal/policy/expression.go
Comment thread internal/policywizard/forms.go Outdated
Comment thread internal/policywizard/forms.go
Comment thread internal/policywizard/forms.go
Comment thread internal/policywizard/forms.go
Comment thread internal/policywizard/model.go
Comment thread internal/policywizard/forms.go
Comment thread cmd/kosli/createPolicyFile.go
@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.

Comment thread internal/policywizard/forms.go
Comment thread internal/policy/expression.go Outdated
Comment thread internal/policy/expression.go
These operators need special syntax (list literals, function form) that
ComparisonExpr doesn't produce. They already have dedicated wizard paths
(flow name match, artifact name regex). Replace with numeric comparison
operators that work correctly with the quoted-value format.
The matches operator needs function syntax matches(ctx, "pattern")
rather than infix syntax. Extract MatchesExpr and use it when the
custom comparison operator is "matches". Also replace "in" with
numeric comparison operators that work with quoted values.
Add exists() as an operator choice in the custom comparison wizard path.
Unlike other operators, exists() takes only a context field and no value.
Generates ${{ exists(field) }} syntax per the policy reference docs.
After building a sub-expression, the wizard now asks whether to negate
it (not) and whether to combine it with another condition (and/or).
This allows building compound expressions like:
  ${{ flow.name == "prod" and not(matches(artifact.name, "^datadog:.*")) }}

New steps: stepExprNegate, stepExprCombineConfirm, stepExprCombineOp.
Sub-expressions accumulate in pendingExprs and are combined on finalize.
Change NegateExpr to produce "not expr" instead of "not(expr)"
to match the policy expression language syntax.
Use term.GetSize to read the actual terminal width, falling back to 80.
This prevents a brief layout glitch on narrow terminals before the first
WindowSizeMsg arrives from bubbletea.
Handle expressions like ${{expr}} (no inner spaces) in addition to
the standard ${{ expr }} format.
Catch invalid regex patterns (e.g. [unclosed) at input time rather
than letting them silently end up in the policy file.
The go.mod conflict resolution during rebase accidentally downgraded
kind from v0.31.0 to v0.11.1, breaking ExportKubeConfig API.
not flow.name == "prod" could be parsed as (not flow.name) == "prod".
Using not (flow.name == "prod") is unambiguous.
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.
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.
Move the os.Stat existence check into validateYAMLExtension so users
get immediate feedback at stepSaveFile time, avoiding lost work if the
target file already exists.
func extractFormValues(f *huh.Form) formValues {
return formValues{
confirm: f.GetBool("confirm"),
str: firstNonEmpty(f.GetString("value"), f.GetString("filename"), f.GetString("mode")),
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.

Nit: document the firstNonEmpty invariant

This was flagged in prior reviews. Each form step uses at most one of "value", "filename", or "mode" as its primary string key — the implicit coupling here is easy for a future contributor to break. A one-line comment would make this self-documenting:

Suggested change
str: firstNonEmpty(f.GetString("value"), f.GetString("filename"), f.GetString("mode")),
func extractFormValues(f *huh.Form) formValues {
// Each form step uses at most one of "value", "filename", or "mode" as its
// primary string key. firstNonEmpty picks the one that was set.
return formValues{

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: Value input is live but ignored when exists is selected

The description (ignored for exists) is good, but users can still type into the field and expect it to matter. Consider adding a Placeholder("(not used)") to reinforce the message, or validating that the field is empty when exists is selected. Not blocking, but would improve clarity.

Comment on lines +90 to +92
outPath := filepath.Clean(wm.OutputFile)
if err := validateOutputFile(outPath); err != nil {
return err
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.

Minor: dual-location validation is a bit redundant now

Since validateYAMLExtension in forms.go:268 now checks os.Stat too, the user gets file-exists feedback at wizard time. This validateOutputFile still validates extension + existence again after the wizard. The extension check is guaranteed to pass (the form already validated it), and the existence check is only useful in the TOCTOU gap. This is fine as a safety net, but worth noting the redundancy — the post-wizard check now primarily protects against concurrent file creation.

Comment thread internal/policywizard/model.go
Comment thread internal/policywizard/forms.go
Comment thread internal/policywizard/forms.go
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