From 1c536efd053dab90d6436e9b7e47a28672b1a919 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 15 Aug 2022 14:57:10 -0700 Subject: [PATCH 1/8] AGPL Entitlements API Signed-off-by: Spike Curtis --- coderd/coderd.go | 4 ++++ coderd/coderd_test.go | 1 + coderd/features.go | 23 ++++++++++++++++++++ coderd/features_internal_test.go | 36 ++++++++++++++++++++++++++++++++ codersdk/features.go | 29 +++++++++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 coderd/features.go create mode 100644 coderd/features_internal_test.go create mode 100644 codersdk/features.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 9c5633cb03033..cf29aa986fc22 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -391,6 +391,10 @@ func New(options *Options) *API { r.Get("/resources", api.workspaceBuildResources) r.Get("/state", api.workspaceBuildState) }) + r.Route("/entitlements", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", entitlements) + }) }) r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 89fa81b275db7..c042855687cd3 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -242,6 +242,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "POST:/api/v2/users/login": {NoAuthorize: true}, "GET:/api/v2/users/authmethods": {NoAuthorize: true}, "POST:/api/v2/csp/reports": {NoAuthorize: true}, + "GET:/api/v2/entitlements": {NoAuthorize: true}, "GET:/%40{user}/{workspacename}/apps/{application}/*": { AssertAction: rbac.ActionRead, diff --git a/coderd/features.go b/coderd/features.go new file mode 100644 index 0000000000000..33491a9583aa7 --- /dev/null +++ b/coderd/features.go @@ -0,0 +1,23 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +func entitlements(rw http.ResponseWriter, _ *http.Request) { + features := make(map[string]codersdk.Feature) + for _, f := range codersdk.AllFeatures { + features[f] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: false, + } + } + httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{ + Features: features, + Warnings: nil, + HasLicense: false, + }) +} diff --git a/coderd/features_internal_test.go b/coderd/features_internal_test.go new file mode 100644 index 0000000000000..79969516d398f --- /dev/null +++ b/coderd/features_internal_test.go @@ -0,0 +1,36 @@ +package coderd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/coder/coder/codersdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEntitlements(t *testing.T) { + t.Parallel() + t.Run("GET", func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest("GET", "https://example.com/api/v2/entitlements", nil) + rw := httptest.NewRecorder() + entitlements(rw, r) + resp := rw.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + dec := json.NewDecoder(resp.Body) + var result codersdk.Entitlements + err := dec.Decode(&result) + require.NoError(t, err) + assert.False(t, result.HasLicense) + assert.Empty(t, result.Warnings) + for _, f := range codersdk.AllFeatures { + require.Contains(t, result.Features, f) + fe := result.Features[f] + assert.False(t, fe.Enabled) + assert.Equal(t, codersdk.EntitlementNotEntitled, fe.Entitlement) + } + }) +} diff --git a/codersdk/features.go b/codersdk/features.go new file mode 100644 index 0000000000000..2d2424d00479a --- /dev/null +++ b/codersdk/features.go @@ -0,0 +1,29 @@ +package codersdk + +type Entitlement string + +const ( + EntitlementEntitled Entitlement = "entitled" + EntitlementGracePeriod Entitlement = "grace_period" + EntitlementNotEntitled Entitlement = "not_entitled" +) + +const ( + FeatureUserLimit = "user_limit" + FeatureAuditLog = "audit_log" +) + +var AllFeatures = []string{FeatureUserLimit, FeatureAuditLog} + +type Feature struct { + Entitlement Entitlement `json:"entitlement"` + Enabled bool `json:"enabled"` + Limit *int64 `json:"limit"` + Actual *int64 `json:"actual"` +} + +type Entitlements struct { + Features map[string]Feature `json:"features"` + Warnings []string `json:"warnings"` + HasLicense bool `json:"has_license"` +} From 978590761f8fff2b695f21e01bea8a4f2a518659 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 15 Aug 2022 16:49:31 -0700 Subject: [PATCH 2/8] Generate typesGenerated.ts Signed-off-by: Spike Curtis --- site/src/api/typesGenerated.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index def9cd07e894a..256a41751ba96 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -129,6 +129,21 @@ export interface CreateWorkspaceRequest { readonly parameter_values?: CreateParameterRequest[] } +// From codersdk/features.go +export interface Entitlements { + readonly features: Record + readonly warnings: string[] + readonly has_license: boolean +} + +// From codersdk/features.go +export interface Feature { + readonly entitlement: Entitlement + readonly enabled: boolean + readonly limit?: number + readonly actual?: number +} + // From codersdk/users.go export interface GenerateAPIKeyResponse { readonly key: string @@ -530,6 +545,9 @@ export interface WorkspaceResourceMetadata { // From codersdk/workspacebuilds.go export type BuildReason = "autostart" | "autostop" | "initiator" +// From codersdk/features.go +export type Entitlement = "entitled" | "grace_period" | "not_entitled" + // From codersdk/provisionerdaemons.go export type LogLevel = "debug" | "error" | "info" | "trace" | "warn" From 1f07ceb8b1b5e11adf29d1e5731c5a2cba1fd404 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 16 Aug 2022 15:18:55 -0700 Subject: [PATCH 3/8] AllFeatures -> FeatureNames Signed-off-by: Spike Curtis --- coderd/features.go | 2 +- coderd/features_internal_test.go | 2 +- codersdk/features.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/features.go b/coderd/features.go index 33491a9583aa7..e8fbc67612e4f 100644 --- a/coderd/features.go +++ b/coderd/features.go @@ -9,7 +9,7 @@ import ( func entitlements(rw http.ResponseWriter, _ *http.Request) { features := make(map[string]codersdk.Feature) - for _, f := range codersdk.AllFeatures { + for _, f := range codersdk.FeatureNames { features[f] = codersdk.Feature{ Entitlement: codersdk.EntitlementNotEntitled, Enabled: false, diff --git a/coderd/features_internal_test.go b/coderd/features_internal_test.go index 79969516d398f..b86eb30dc8d8c 100644 --- a/coderd/features_internal_test.go +++ b/coderd/features_internal_test.go @@ -26,7 +26,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) assert.False(t, result.HasLicense) assert.Empty(t, result.Warnings) - for _, f := range codersdk.AllFeatures { + for _, f := range codersdk.FeatureNames { require.Contains(t, result.Features, f) fe := result.Features[f] assert.False(t, fe.Enabled) diff --git a/codersdk/features.go b/codersdk/features.go index 2d2424d00479a..124a351dc69ed 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -13,7 +13,7 @@ const ( FeatureAuditLog = "audit_log" ) -var AllFeatures = []string{FeatureUserLimit, FeatureAuditLog} +var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog} type Feature struct { Entitlement Entitlement `json:"entitlement"` From 57095bd7454814d76402ef1e9b3a0cb983e89794 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 15 Aug 2022 15:49:28 -0700 Subject: [PATCH 4/8] Features CLI command Signed-off-by: Spike Curtis --- cli/features.go | 101 +++++++++++++++++++++++++++++++++ cli/root.go | 1 + coderd/database/dump.sql | 1 - coderd/features.go | 2 +- codersdk/features.go | 24 ++++++++ site/src/api/typesGenerated.ts | 3 + 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 cli/features.go diff --git a/cli/features.go b/cli/features.go new file mode 100644 index 0000000000000..092fb531117c8 --- /dev/null +++ b/cli/features.go @@ -0,0 +1,101 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/coder/coder/cli/cliui" + "github.com/jedib0t/go-pretty/v6/table" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +func features() *cobra.Command { + cmd := &cobra.Command{ + Short: "List features", + Use: "features", + Aliases: []string{"feature"}, + } + cmd.AddCommand( + featuresList(), + ) + return cmd +} + +func featuresList() *cobra.Command { + var ( + columns []string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + entitlements, err := client.Entitlements(cmd.Context(), codersdk.EntitlementsRequest{}) + if err != nil { + return err + } + + out := "" + switch outputFormat { + case "table", "": + out = displayFeatures(columns, entitlements.Features) + case "json": + outBytes, err := json.Marshal(entitlements) + if err != nil { + return xerrors.Errorf("marshal users to JSON: %w", err) + } + + out = string(outBytes) + default: + return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat) + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) + return err + }, + } + + cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "entitlement", "enabled", "limit", "actual"}, + "Specify a column to filter in the table. Available columns are: name, entitlement, enabled, limit, actual.") + cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") + return cmd +} + +// displayFeatures will return a table displaying all features passed in. +// filterColumns must be a subset of the feature fields and will determine which +// columns to display +func displayFeatures(filterColumns []string, features map[string]codersdk.Feature) string { + tableWriter := cliui.Table() + header := table.Row{"name", "entitlement", "enabled", "limit", "actual"} + tableWriter.AppendHeader(header) + tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns)) + tableWriter.SortBy([]table.SortBy{{ + Name: "username", + }}) + for name, feat := range features { + tableWriter.AppendRow(table.Row{ + name, + feat.Entitlement, + feat.Enabled, + intOrNil(feat.Limit), + intOrNil(feat.Actual), + }) + } + return tableWriter.Render() +} + +func intOrNil(i *int64) string { + if i == nil { + return "" + } + return fmt.Sprintf("%d", *i) +} diff --git a/cli/root.go b/cli/root.go index e63a308712f02..74a37ac4b6d8b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -135,6 +135,7 @@ func Root() *cobra.Command { versionCmd(), wireguardPortForward(), workspaceAgent(), + features(), ) cmd.SetUsageTemplate(usageTemplate()) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 87853f23fe16e..11843bc2fba20 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -542,4 +542,3 @@ ALTER TABLE ONLY workspaces ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; - diff --git a/coderd/features.go b/coderd/features.go index e8fbc67612e4f..a6eaeca9c545b 100644 --- a/coderd/features.go +++ b/coderd/features.go @@ -17,7 +17,7 @@ func entitlements(rw http.ResponseWriter, _ *http.Request) { } httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{ Features: features, - Warnings: nil, + Warnings: []string{}, HasLicense: false, }) } diff --git a/codersdk/features.go b/codersdk/features.go index 124a351dc69ed..6674073031e76 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -1,5 +1,11 @@ package codersdk +import ( + "context" + "encoding/json" + "net/http" +) + type Entitlement string const ( @@ -27,3 +33,21 @@ type Entitlements struct { Warnings []string `json:"warnings"` HasLicense bool `json:"has_license"` } + +type EntitlementsRequest struct { + // placeholder so that we can add request parameters in future + // without breaking changes to the go API +} + +func (c *Client) Entitlements(ctx context.Context, _ EntitlementsRequest) (Entitlements, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil) + if err != nil { + return Entitlements{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Entitlements{}, readBodyAsError(res) + } + var ent Entitlements + return ent, json.NewDecoder(res.Body).Decode(&ent) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 256a41751ba96..a6fda1cabbde6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -136,6 +136,9 @@ export interface Entitlements { readonly has_license: boolean } +// From codersdk/features.go +export interface EntitlementsRequest {} + // From codersdk/features.go export interface Feature { readonly entitlement: Entitlement From 711addca32ab0bd5ce5b88fa1b93ccf591be40e1 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 15 Aug 2022 16:45:25 -0700 Subject: [PATCH 5/8] Validate columns Signed-off-by: Spike Curtis --- cli/cliui/table.go | 17 +++++++++++++++++ cli/features.go | 17 ++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 66ba29acaa170..ea835c1dc784e 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -1,6 +1,7 @@ package cliui import ( + "fmt" "strings" "github.com/jedib0t/go-pretty/v6/table" @@ -41,3 +42,19 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig } return columnConfigs } + +func ValidateColumns(all, given []string) error { + for _, col := range given { + found := false + for _, c := range all { + if strings.EqualFold(strings.ReplaceAll(col, "_", " "), c) { + found = true + break + } + } + if !found { + return fmt.Errorf("unknown column: %s", col) + } + } + return nil +} diff --git a/cli/features.go b/cli/features.go index 092fb531117c8..d8974a7ba82be 100644 --- a/cli/features.go +++ b/cli/features.go @@ -3,6 +3,7 @@ package cli import ( "encoding/json" "fmt" + "strings" "github.com/coder/coder/cli/cliui" "github.com/jedib0t/go-pretty/v6/table" @@ -13,6 +14,8 @@ import ( "github.com/coder/coder/codersdk" ) +var featureColumns = []string{"Name", "Entitlement", "Enabled", "Limit", "Actual"} + func features() *cobra.Command { cmd := &cobra.Command{ Short: "List features", @@ -35,6 +38,10 @@ func featuresList() *cobra.Command { Use: "list", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { + err := cliui.ValidateColumns(featureColumns, columns) + if err != nil { + return err + } client, err := createClient(cmd) if err != nil { return err @@ -64,8 +71,9 @@ func featuresList() *cobra.Command { }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "entitlement", "enabled", "limit", "actual"}, - "Specify a column to filter in the table. Available columns are: name, entitlement, enabled, limit, actual.") + cmd.Flags().StringArrayVarP(&columns, "column", "c", featureColumns, + fmt.Sprintf("Specify a column to filter in the table. Available columns are: %s", + strings.Join(featureColumns, ", "))) cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") return cmd } @@ -75,7 +83,10 @@ func featuresList() *cobra.Command { // columns to display func displayFeatures(filterColumns []string, features map[string]codersdk.Feature) string { tableWriter := cliui.Table() - header := table.Row{"name", "entitlement", "enabled", "limit", "actual"} + header := table.Row{} + for _, h := range featureColumns { + header = append(header, h) + } tableWriter.AppendHeader(header) tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns)) tableWriter.SortBy([]table.SortBy{{ From 23f4341cff2cfdde3d455ba5c5fae8e58858f9b0 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 16 Aug 2022 15:44:02 -0700 Subject: [PATCH 6/8] Tests for features list CLI command Signed-off-by: Spike Curtis --- cli/features_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 cli/features_test.go diff --git a/cli/features_test.go b/cli/features_test.go new file mode 100644 index 0000000000000..6c39fec81011a --- /dev/null +++ b/cli/features_test.go @@ -0,0 +1,66 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +func TestFeaturesList(t *testing.T) { + t.Parallel() + t.Run("Table", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "features", "list") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + require.NoError(t, <-errC) + pty.ExpectMatch("user_limit") + pty.ExpectMatch("not_entitled") + }) + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "features", "list", "-o", "json") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + + buf := bytes.NewBuffer(nil) + cmd.SetOut(buf) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + <-doneChan + + var entitlements codersdk.Entitlements + err := json.Unmarshal(buf.Bytes(), &entitlements) + require.NoError(t, err, "unmarshal JSON output") + assert.Len(t, entitlements.Features, 2) + assert.Empty(t, entitlements.Warnings) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureUserLimit].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + assert.False(t, entitlements.HasLicense) + }) +} From 393ba096df28b32aceff86835202cc356d2f3960 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 17 Aug 2022 09:35:57 -0700 Subject: [PATCH 7/8] Drop empty EntitlementsRequest Signed-off-by: Spike Curtis --- cli/features.go | 2 +- codersdk/features.go | 7 +------ site/src/api/typesGenerated.ts | 3 --- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/cli/features.go b/cli/features.go index d8974a7ba82be..307404d5c83c6 100644 --- a/cli/features.go +++ b/cli/features.go @@ -46,7 +46,7 @@ func featuresList() *cobra.Command { if err != nil { return err } - entitlements, err := client.Entitlements(cmd.Context(), codersdk.EntitlementsRequest{}) + entitlements, err := client.Entitlements(cmd.Context()) if err != nil { return err } diff --git a/codersdk/features.go b/codersdk/features.go index 6674073031e76..6bdfd5ff53bd4 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -34,12 +34,7 @@ type Entitlements struct { HasLicense bool `json:"has_license"` } -type EntitlementsRequest struct { - // placeholder so that we can add request parameters in future - // without breaking changes to the go API -} - -func (c *Client) Entitlements(ctx context.Context, _ EntitlementsRequest) (Entitlements, error) { +func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil) if err != nil { return Entitlements{}, err diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c91e02859419b..df1b51c18ebe2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -141,9 +141,6 @@ export interface Entitlements { readonly has_license: boolean } -// From codersdk/features.go -export interface EntitlementsRequest {} - // From codersdk/features.go export interface Feature { readonly entitlement: Entitlement From ead4b6cb5198cc5717e17827cf8525ff0a26f4ad Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 17 Aug 2022 16:51:30 +0000 Subject: [PATCH 8/8] Fix dump.sql generation Signed-off-by: Spike Curtis --- coderd/database/dump.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 11843bc2fba20..87853f23fe16e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -542,3 +542,4 @@ ALTER TABLE ONLY workspaces ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; +