diff --git a/coder-sdk/interface.go b/coder-sdk/interface.go index 47ed28c3..e439ca32 100644 --- a/coder-sdk/interface.go +++ b/coder-sdk/interface.go @@ -238,4 +238,7 @@ type Client interface { // RenameWorkspaceProvider changes an existing providers name field. RenameWorkspaceProvider(ctx context.Context, id string, name string) error + + // SetPolicyTemplate sets the workspace policy template + SetPolicyTemplate(ctx context.Context, templateID string, templateScope TemplateScope, dryRun bool) (*SetPolicyTemplateResponse, error) } diff --git a/coder-sdk/workspace.go b/coder-sdk/workspace.go index e48d4bc7..b6071b5a 100644 --- a/coder-sdk/workspace.go +++ b/coder-sdk/workspace.go @@ -2,9 +2,11 @@ package coder import ( "context" + "fmt" "io" "net/http" "net/url" + "strings" "time" "cdr.dev/wsep" @@ -364,3 +366,146 @@ func (c *DefaultClient) WorkspacesByWorkspaceProvider(ctx context.Context, wpID } return workspaces, nil } + +const ( + // SkipTemplateOrg allows skipping checks on organizations. + SkipTemplateOrg = "SKIP_ORG" +) + +type TemplateScope string + +const ( + // TemplateScopeSite is the scope for a site wide policy template. + TemplateScopeSite = "site" +) + +type SetPolicyTemplateRequest struct { + TemplateID string `json:"template_id"` + Type string `json:"type"` // site, org +} + +type SetPolicyTemplateResponse struct { + MergeConflicts []*WorkspaceTemplateMergeConflict `json:"merge_conflicts"` +} + +type WorkspaceTemplateMergeConflict struct { + WorkspaceID string `json:"workspace_id"` + CurrentTemplateWarnings []string `json:"current_template_warnings"` + CurrentTemplateError *TplError `json:"current_template_errors"` + LatestTemplateWarnings []string `json:"latest_template_warnings"` + LatestTemplateError *TplError `json:"latest_template_errors"` + CurrentTemplateIsLatest bool `json:"current_template_is_latest"` + Message string `json:"message"` +} + +func (mc WorkspaceTemplateMergeConflict) String() string { + var sb strings.Builder + + if mc.Message != "" { + sb.WriteString(mc.Message) + } + + currentConflicts := len(mc.CurrentTemplateWarnings) != 0 || mc.CurrentTemplateError != nil + updateConflicts := len(mc.LatestTemplateWarnings) != 0 || mc.LatestTemplateError != nil + + if !currentConflicts && !updateConflicts { + sb.WriteString("No workspace conflicts\n") + return sb.String() + } + + if currentConflicts { + if len(mc.CurrentTemplateWarnings) != 0 { + fmt.Fprintf(&sb, "Warnings: \n%s\n", strings.Join(mc.CurrentTemplateWarnings, "\n")) + } + if mc.CurrentTemplateError != nil { + fmt.Fprintf(&sb, "Errors: \n%s\n", strings.Join(mc.CurrentTemplateError.Msgs, "\n")) + } + } + + if !mc.CurrentTemplateIsLatest && updateConflicts { + sb.WriteString("If workspace is updated to the latest template:\n") + if len(mc.LatestTemplateWarnings) != 0 { + fmt.Fprintf(&sb, "Warnings: \n%s\n", strings.Join(mc.LatestTemplateWarnings, "\n")) + } + if mc.LatestTemplateError != nil { + fmt.Fprintf(&sb, "Errors: \n%s\n", strings.Join(mc.LatestTemplateError.Msgs, "\n")) + } + } + + return sb.String() +} + +type WorkspaceTemplateMergeConflicts []*WorkspaceTemplateMergeConflict + +func (mcs WorkspaceTemplateMergeConflicts) Summary() string { + var ( + sb strings.Builder + currentWarnings int + updateWarnings int + currentErrors int + updateErrors int + ) + + for _, mc := range mcs { + if len(mc.CurrentTemplateWarnings) != 0 { + currentWarnings++ + } + if len(mc.LatestTemplateWarnings) != 0 { + updateWarnings++ + } + if mc.CurrentTemplateError != nil { + currentErrors++ + } + if mc.LatestTemplateError != nil { + updateErrors++ + } + } + + if currentErrors == 0 && updateErrors == 0 && currentWarnings == 0 && updateWarnings == 0 { + sb.WriteString("No workspace conflicts\n") + return sb.String() + } + + if currentErrors != 0 { + fmt.Fprintf(&sb, "%d workspaces will not be able to be rebuilt\n", currentErrors) + } + if updateErrors != 0 { + fmt.Fprintf(&sb, "%d workspaces will not be able to be rebuilt if updated to the latest version\n", updateErrors) + } + if currentWarnings != 0 { + fmt.Fprintf(&sb, "%d workspaces will be impacted\n", currentWarnings) + } + if updateWarnings != 0 { + fmt.Fprintf(&sb, "%d workspaces will be impacted if updated to the latest version\n", updateWarnings) + } + + return sb.String() +} + +type TplError struct { + // Msgs are the human facing strings to present to the user. Since there can be multiple + // problems with a template, there might be multiple strings + Msgs []string `json:"messages"` +} + +func (c *DefaultClient) SetPolicyTemplate(ctx context.Context, templateID string, templateScope TemplateScope, dryRun bool) (*SetPolicyTemplateResponse, error) { + var ( + resp SetPolicyTemplateResponse + query = url.Values{} + ) + + req := SetPolicyTemplateRequest{ + TemplateID: templateID, + Type: string(templateScope), + } + + if dryRun { + query.Set("dry-run", "true") + } + + if err := c.requestBody(ctx, http.MethodPost, "/api/private/workspaces/template/policy", req, &resp, withQueryParams(query)); err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/docs/coder_workspaces.md b/docs/coder_workspaces.md index 4402d92c..a7ec4615 100644 --- a/docs/coder_workspaces.md +++ b/docs/coder_workspaces.md @@ -26,6 +26,7 @@ Perform operations on the Coder workspaces owned by the active user. * [coder workspaces edit](coder_workspaces_edit.md) - edit an existing workspace and initiate a rebuild. * [coder workspaces edit-from-config](coder_workspaces_edit-from-config.md) - change the template a workspace is tracking * [coder workspaces ls](coder_workspaces_ls.md) - list all workspaces owned by the active user +* [coder workspaces policy-template](coder_workspaces_policy-template.md) - Set workspace policy template * [coder workspaces rebuild](coder_workspaces_rebuild.md) - rebuild a Coder workspace * [coder workspaces rm](coder_workspaces_rm.md) - remove Coder workspaces by name * [coder workspaces stop](coder_workspaces_stop.md) - stop Coder workspaces by name diff --git a/docs/coder_workspaces_policy-template.md b/docs/coder_workspaces_policy-template.md new file mode 100644 index 00000000..36bf34fc --- /dev/null +++ b/docs/coder_workspaces_policy-template.md @@ -0,0 +1,32 @@ +## coder workspaces policy-template + +Set workspace policy template + +### Synopsis + +Set workspace policy template or restore to default configuration. This feature is for site admins only. + +``` +coder workspaces policy-template [flags] +``` + +### Options + +``` + --default Restore policy template to default configuration + --dry-run skip setting policy template, but view errors/warnings about how this policy template would impact existing workspaces + -f, --filepath string full path to local policy template file. + -h, --help help for policy-template + --scope string scope of impact for the policy template. Supported values: site (default "site") +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces + diff --git a/internal/cmd/workspaces.go b/internal/cmd/workspaces.go index b8017baa..d1135cf4 100644 --- a/internal/cmd/workspaces.go +++ b/internal/cmd/workspaces.go @@ -47,6 +47,7 @@ func workspacesCmd() *cobra.Command { workspaceFromConfigCmd(true), workspaceFromConfigCmd(false), editWorkspaceCmd(), + setPolicyTemplate(), ) return cmd } @@ -752,3 +753,85 @@ func buildUpdateReq(ctx context.Context, client coder.Client, conf updateConf) ( } return &updateReq, nil } + +func setPolicyTemplate() *cobra.Command { + var ( + ref string + repo string + filepath string + dryRun bool + defaultTemplate bool + scope string + ) + + cmd := &cobra.Command{ + Use: "policy-template", + Short: "Set workspace policy template", + Long: "Set workspace policy template or restore to default configuration. This feature is for site admins only.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx, true) + if err != nil { + return err + } + + if scope != coder.TemplateScopeSite { + return clog.Error("Invalid 'scope' value", "Valid scope values: site") + } + + if filepath == "" && !defaultTemplate { + return clog.Error("Missing required parameter --filepath or --default", "Must specify a template to set") + } + + templateID := "" + if filepath != "" { + var rd io.Reader + b, err := ioutil.ReadFile(filepath) + if err != nil { + return xerrors.Errorf("read local file: %w", err) + } + rd = bytes.NewReader(b) + + req := coder.ParseTemplateRequest{ + RepoURL: repo, + Ref: ref, + Local: rd, + OrgID: coder.SkipTemplateOrg, + Filepath: ".coder/coder.yaml", + } + + version, err := client.ParseTemplate(ctx, req) + if err != nil { + return handleAPIError(err) + } + templateID = version.TemplateID + } + + resp, err := client.SetPolicyTemplate(ctx, templateID, coder.TemplateScope(scope), dryRun) + if err != nil { + return handleAPIError(err) + } + + for _, mc := range resp.MergeConflicts { + workspace, err := client.WorkspaceByID(ctx, mc.WorkspaceID) + if err != nil { + fmt.Printf("Workspace %q:\n", mc.WorkspaceID) + } else { + fmt.Printf("Workspace %q in organization %q:\n", workspace.Name, workspace.OrganizationID) + } + + fmt.Println(mc.String()) + } + + fmt.Println("Summary:") + fmt.Println(coder.WorkspaceTemplateMergeConflicts(resp.MergeConflicts).Summary()) + + return nil + }, + } + cmd.Flags().BoolVarP(&dryRun, "dry-run", "", false, "skip setting policy template, but view errors/warnings about how this policy template would impact existing workspaces") + cmd.Flags().StringVarP(&filepath, "filepath", "f", "", "full path to local policy template file.") + cmd.Flags().StringVar(&scope, "scope", "site", "scope of impact for the policy template. Supported values: site") + cmd.Flags().BoolVar(&defaultTemplate, "default", false, "Restore policy template to default configuration") + return cmd +}