Repository navigation
feat(publish): hidden kosli publish rego-policy, publish a versioned Rego policy #1256
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
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
eeeadc6
refactor(evaluations): move the wait loop into a generic poll package
jumboduck aa85f6e
fix(requests): accept 202 Accepted as a success
jumboduck a3b09ca
feat(regopolicies): publish a rego policy version
jumboduck 32460f8
feat(regopolicies): read a version back and wait for its validation
jumboduck e78f8c3
feat(create): hidden kosli create rego-policy, publish and wait for a…
jumboduck 1540866
feat(create): tell rejected, failed and still-pending rego policy ver…
jumboduck 43f5f01
feat(create): refuse an unknown input schema and a policy URL before …
jumboduck 6c95937
feat(create): add --output json and --dry-run to create rego-policy
jumboduck 68fff87
docs(create): explain versions, outcomes and exit codes in create reg…
jumboduck 9ac6624
fix(regopolicies): keep the identifier in one path segment
jumboduck 01cefec
fix(create): do not poll a version the publish already answered as te…
jumboduck aa78c6d
fix(regopolicies): report a 409 on publish instead of retrying it
jumboduck 470d9da
refactor: hold comments to the comment standard
jumboduck 9cafca3
fix(create): print the version as JSON for every result, not only a v…
jumboduck 08ee1f4
fix(regopolicies): give the full URL, with the host, in the still-pen…
jumboduck 732f7d7
refactor(poll): one WaitOptions type, with defaults filled by Options.Or
jumboduck 96beeb9
fix(evaluations): give the full URL, with the host, in the still-pend…
jumboduck 2c1d383
fix(create): print the pending version as JSON when the wait expires
jumboduck 9749077
fix(create): say when a publish stored nothing new
jumboduck c25a4cc
refactor: build the still-pending URL once, when the error is made
jumboduck 7854b7a
fix(create): generate no docs page for the hidden rego-policy command
jumboduck f273e21
refactor: rename create rego-policy to publish rego-policy
jumboduck c711ef4
fix(publish): name the published version when it cannot be read back
jumboduck 67e6240
fix(publish): say a pending publish stored nothing when its comment w…
jumboduck 14bcab2
docs(publish): correct what the JSON printer comment claims
jumboduck f3da36f
fix(publish): stop when the publish answer names no version
jumboduck File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "io" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| const publishDesc = `All Kosli publish commands.` | ||
|
|
||
| func newPublishCmd(out io.Writer) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "publish", | ||
| Short: publishDesc, | ||
| Long: publishDesc, | ||
| } | ||
|
|
||
| cmd.AddCommand( | ||
| newPublishRegoPolicyCmd(out), | ||
| ) | ||
| return cmd | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "slices" | ||
|
|
||
| "github.com/kosli-dev/cli/internal/output" | ||
| "github.com/kosli-dev/cli/internal/regopolicies" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| const publishRegoPolicyShortDesc = `Publish a new version of a rego policy in a Kosli org.` | ||
|
|
||
| const publishRegoPolicyLongDesc = publishRegoPolicyShortDesc + ` | ||
| The policy is addressed by IDENTIFIER, which decisions cite and which cannot be | ||
| changed after the first publish. ^--name^ is required on the first publish only. | ||
| Only an org admin can publish. | ||
|
|
||
| PATH is a Rego file or a directory on this machine. A directory is sent as one | ||
| bundle, by the same rules as ^kosli evaluate policy --policy^: files in | ||
| dot-directories are left out, and the bundle can hold at most 100 files and | ||
| 1 MiB. | ||
|
|
||
| Each publish stores the next version, with ^--comment^ kept on it. A change to | ||
| the files or to ^--input-schema^ makes a new version. When the files and the | ||
| input schema are the same as the newest version, and that version is pending or | ||
| valid, nothing new is stored and that version is reported. A change to only | ||
| ^--name^ or ^--description^ makes no new version. | ||
|
|
||
| Kosli validates each version after it is stored, and the command waits up to 30 | ||
| seconds for the result: | ||
| - valid: the version can be evaluated. The command exits 0. | ||
| - rejected: the policy is at fault, for example it does not compile. The | ||
| command prints the reason and exits 1. | ||
| - failed: Kosli could not validate the version. Publish again to retry. The | ||
| command exits 1. | ||
| If the wait expires, the command exits 1 and says how to read the version later. | ||
| With ^--output json^, the version is always printed: with the reason for a | ||
| rejected or failed one, and as pending when the wait expires. | ||
|
|
||
| An evaluation runs the highest-numbered valid version, so a rejected or failed | ||
| version leaves the policy in service unchanged.` | ||
|
|
||
| const publishRegoPolicyExample = ` | ||
| # publish the first version of a rego policy from a directory: | ||
| kosli publish rego-policy pr-approved ./policies/pr-approved/ \ | ||
| --input-schema trail \ | ||
| --name "Pull request approved" \ | ||
| --description "Every change reached main through an approved PR" \ | ||
| --comment "Initial version" \ | ||
| --api-token yourAPIToken \ | ||
| --org yourOrgName | ||
|
|
||
| # publish a new version of an existing rego policy from one file: | ||
| kosli publish rego-policy pr-approved ./policies/pr-approved.rego \ | ||
| --input-schema trail \ | ||
| --comment "Also require two approvers" \ | ||
| --api-token yourAPIToken \ | ||
| --org yourOrgName | ||
|
|
||
| # publish a policy written against a set of trails, and print the version as JSON: | ||
| kosli publish rego-policy release-ready ./policies/release-ready/ \ | ||
| --input-schema trails \ | ||
| --name "Release ready" \ | ||
| --output json \ | ||
| --api-token yourAPIToken \ | ||
| --org yourOrgName | ||
|
|
||
| # show what would be published, without sending it: | ||
| kosli publish rego-policy pr-approved ./policies/pr-approved/ \ | ||
| --input-schema trail \ | ||
| --dry-run \ | ||
| --api-token yourAPIToken \ | ||
| --org yourOrgName | ||
| ` | ||
|
|
||
| // A variable so that tests can shorten the wait. | ||
| var regoPolicyWaitOptions = regopolicies.WaitOptions{} | ||
|
|
||
| var regoPolicyInputSchemas = []string{"trail", "trails"} | ||
|
|
||
| type publishRegoPolicyOptions struct { | ||
| name string | ||
| description string | ||
| inputSchema string | ||
| comment string | ||
| output string | ||
| } | ||
|
|
||
| func newPublishRegoPolicyCmd(out io.Writer) *cobra.Command { | ||
| o := new(publishRegoPolicyOptions) | ||
| cmd := &cobra.Command{ | ||
| Use: "rego-policy IDENTIFIER PATH", | ||
| Short: publishRegoPolicyShortDesc, | ||
| Long: publishRegoPolicyLongDesc, | ||
| Example: publishRegoPolicyExample, | ||
| Args: cobra.ExactArgs(2), | ||
| // Hidden while the server gates the endpoint behind is-server-side-evaluation-enabled. | ||
| Hidden: true, | ||
| Annotations: map[string]string{betaCLIAnnotation: ""}, | ||
| PreRunE: func(cmd *cobra.Command, args []string) error { | ||
| err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) | ||
| if err != nil { | ||
| return ErrorBeforePrintingUsage(cmd, err.Error()) | ||
| } | ||
| return nil | ||
| }, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| return o.run(out, args[0], args[1]) | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&o.inputSchema, "input-schema", "", regoPolicyInputSchemaFlag) | ||
| cmd.Flags().StringVar(&o.name, "name", "", regoPolicyNameFlag) | ||
| cmd.Flags().StringVar(&o.description, "description", "", regoPolicyDescriptionFlag) | ||
| cmd.Flags().StringVar(&o.comment, "comment", "", regoPolicyCommentFlag) | ||
| cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) | ||
| addDryRunFlag(cmd) | ||
|
|
||
| err := RequireFlags(cmd, []string{"input-schema"}) | ||
| if err != nil { | ||
| logger.Error("failed to configure required flags: %v", err) | ||
| } | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func (o *publishRegoPolicyOptions) run(out io.Writer, identifier, path string) error { | ||
| // Checked before publishing, so a bad format cannot fail a command whose version is stored. | ||
| printVersion, known := regoPolicyPrinters[o.output] | ||
| if !known { | ||
| return fmt.Errorf("unsupported output format: %s. Valid formats are: [table, json]", o.output) | ||
| } | ||
| if !slices.Contains(regoPolicyInputSchemas, o.inputSchema) { | ||
| return fmt.Errorf("--input-schema must be one of %v, got %q", regoPolicyInputSchemas, o.inputSchema) | ||
| } | ||
| // Decisions cite a published version, so its source must be on disk, not behind a URL. | ||
| if isRemotePolicyRef(path) { | ||
| return fmt.Errorf("PATH takes a file or a directory on this machine, not a URL") | ||
| } | ||
|
|
||
| files, err := policyBundle([]string{path}) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| client := regopolicies.NewClient(kosliClient, global.Host, global.ApiToken, global.DryRun) | ||
| published, err := client.Publish(global.Org, regopolicies.PublishRequest{ | ||
| Identifier: identifier, | ||
| Name: o.name, | ||
| Description: o.description, | ||
| InputSchema: o.inputSchema, | ||
| Comment: o.comment, | ||
| Files: files, | ||
| }) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if published == nil { | ||
| // Nil on a dry run. | ||
| return nil | ||
| } | ||
| if published.Version < 1 { | ||
| // Versions count from 1, so any read would be of a version that does not exist. | ||
| return errors.New("the Kosli server accepted the publish but named no version, so it cannot be read back") | ||
| } | ||
|
|
||
| // Publishing the content of the newest version answers with that version, which can be terminal. | ||
| version := published | ||
| if !version.IsTerminal() { | ||
| version, err = client.WaitForTerminal(context.Background(), global.Org, identifier, | ||
| published.Version, regoPolicyWaitOptions) | ||
| if err != nil { | ||
| if printErr := o.printIfJSON(out, published); printErr != nil { | ||
| return printErr | ||
| } | ||
| var stillPending *regopolicies.StillPendingError | ||
| if errors.As(err, &stillPending) { | ||
| return err | ||
| } | ||
| return fmt.Errorf("rego policy '%s' version %d was published, but could not be read back: %w", | ||
| identifier, published.Version, err) | ||
| } | ||
| } | ||
| if version.Status != regopolicies.StatusValid { | ||
| if err := o.printIfJSON(out, version); err != nil { | ||
| return err | ||
| } | ||
| return notValidError(identifier, version) | ||
| } | ||
|
|
||
| // A new version is stored pending and with the comment it was sent, so a | ||
| // terminal answer, or one with another comment, is an existing version. | ||
| commentDropped := o.comment != "" && published.Comment != o.comment | ||
| note := "" | ||
| if published.IsTerminal() || commentDropped { | ||
| note = "nothing new was stored, as the files and input schema are the same as this version" | ||
| if commentDropped { | ||
| note += ", so --comment was not kept" | ||
| } | ||
| } | ||
| return printVersion(out, version, note) | ||
| } | ||
|
|
||
| // printIfJSON gives a script the version even when the command fails, as JSON | ||
| // carries what the error text does not, such as the version number. | ||
| func (o *publishRegoPolicyOptions) printIfJSON(out io.Writer, version *regopolicies.Version) error { | ||
| if o.output != "json" { | ||
| return nil | ||
| } | ||
| return regoPolicyPrinters["json"](out, version, "") | ||
| } | ||
|
|
||
| // The JSON printer ignores note, so a script reads only the fields of Version. | ||
| var regoPolicyPrinters = map[string]func(out io.Writer, version *regopolicies.Version, note string) error{ | ||
| "table": func(out io.Writer, version *regopolicies.Version, note string) error { | ||
| line := fmt.Sprintf("rego policy '%s' version %d is valid (%s)", | ||
| version.Identifier, version.Version, version.Digest) | ||
| if note != "" { | ||
| line += "; " + note | ||
| } | ||
| _, err := fmt.Fprintln(out, line) | ||
| return err | ||
| }, | ||
| "json": func(out io.Writer, version *regopolicies.Version, _ string) error { | ||
| raw, err := json.Marshal(version) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| return output.PrintJson(string(raw), out, 0) | ||
| }, | ||
| } | ||
|
|
||
| // notValidError words a rejected version as the policy's fault and a failed one as Kosli's. | ||
| func notValidError(identifier string, version *regopolicies.Version) error { | ||
| subject := fmt.Sprintf("rego policy '%s' version %d", identifier, version.Version) | ||
| switch version.Status { | ||
| case regopolicies.StatusRejected: | ||
| if version.Error == nil { | ||
| return fmt.Errorf("%s was rejected, and Kosli gave no reason", subject) | ||
| } | ||
| return fmt.Errorf("%s was rejected (%s): %s", subject, version.Error.Kind, version.Error.Message) | ||
| case regopolicies.StatusFailed: | ||
| reason := ", with no reason given" | ||
| if version.Error != nil { | ||
| reason = fmt.Sprintf(" (%s): %s", version.Error.Kind, version.Error.Message) | ||
| } | ||
| return fmt.Errorf("validation of %s failed on the Kosli side%s; "+ | ||
| "this is not a fault in the policy, publish again to retry", subject, reason) | ||
| } | ||
| return fmt.Errorf("%s is %s", subject, version.Status) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.