-
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
db257c2
7ee86f3
307b8f4
f1c8941
636024c
1ee753e
9a908c6
0e4e415
c4309bc
0966799
df1f6b0
1812622
596f607
11bba47
a989807
4370127
346ee03
2f0b930
b967714
682c518
9a9bea4
be20ef7
0c10db1
7f0797a
b11d2a0
42b0aaa
467c828
715f1fc
b83939a
95accab
f5d6b2a
a56e1b5
9409515
c57263e
e1eb928
3aa1d34
2b38e2d
324137a
858caea
38c5434
5c8c65f
476bef9
872c07d
e3f0cb9
2c51003
c47ae44
1aba159
47ebc66
aeae085
2c28464
2dd5d56
aaa321f
103089f
9a48e7d
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 | ||
|
dangrondahl marked this conversation as resolved.
|
||
| } | ||
|
|
||
| err = os.WriteFile(outPath, yamlBytes, 0644) | ||
|
dangrondahl marked this conversation as 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.
|
||
|
|
||
| func fetchFlowNames() []string { | ||
| return fetchNameList("api/v2/flows", nil) | ||
| } | ||
|
dangrondahl marked this conversation as 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.
|
||
| 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.