-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add interactive policy-file wizard (kosli create policy-file) #766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0ff5281
8a520a2
9e09e60
614ee30
79e33bf
79edd82
62ebe42
6dec77f
efe2ec1
275e7da
19eae60
024418f
29acfe6
89c3858
72205a3
f60f386
eebfde2
51fc780
2b533ef
75dfbb1
fa9595c
92c1fc4
a09f6f2
b021e88
1701796
59a6415
fff862b
6232841
d5d71a3
31dd3c6
6164c27
c014b0f
335180a
ff45033
db5103b
f5d6d48
e7a0f8d
8d2dee3
2fc8ae6
0f01af2
ca33a1a
d480043
692dfa0
0a8ac1a
048b2a7
61ff258
41a4e99
c83ccd9
f4226b8
6d0c7f5
5ad8b6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. UX note: file-exists check happens after the entire wizard completes
One option: add an
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| return err | ||
| } | ||
|
|
||
| err = os.WriteFile(outPath, yamlBytes, 0644) | ||
dangrondahl marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| 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 | ||
| } | ||
dangrondahl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| func fetchFlowNames() []string { | ||
| return fetchNameList("api/v2/flows", nil) | ||
| } | ||
dangrondahl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 { | ||
dangrondahl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.