diff --git a/codersdk/aigatewaykeys.go b/codersdk/aigatewaykeys.go index 7c4eb1c7a1..cce57dafad 100644 --- a/codersdk/aigatewaykeys.go +++ b/codersdk/aigatewaykeys.go @@ -14,11 +14,11 @@ import ( // AIGatewayKey is a shared secret used by a standalone AI Gateway // to authenticate into coderd. type AIGatewayKey struct { - ID uuid.UUID `json:"id" format:"uuid"` - Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"` + ID uuid.UUID `json:"id" table:"id" format:"uuid"` + Name string `json:"name" table:"name,default_sort"` + KeyPrefix string `json:"key_prefix" table:"key prefix"` + CreatedAt time.Time `json:"created_at" table:"created at" format:"date-time"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" table:"last used at" format:"date-time"` } // CreateAIGatewayKeyRequest requests a new AI Gateway key. diff --git a/docs/reference/cli/ai.md b/docs/reference/cli/ai.md new file mode 100644 index 0000000000..9f9ad00ab6 --- /dev/null +++ b/docs/reference/cli/ai.md @@ -0,0 +1,16 @@ + +# ai + +Manage AI features. + +## Usage + +```console +coder ai +``` + +## Subcommands + +| Name | Purpose | +|-----------------------------------------|--------------------| +| [gateway](./ai_gateway.md) | Manage AI Gateway. | diff --git a/docs/reference/cli/ai_gateway.md b/docs/reference/cli/ai_gateway.md new file mode 100644 index 0000000000..6f5ec5b76b --- /dev/null +++ b/docs/reference/cli/ai_gateway.md @@ -0,0 +1,16 @@ + +# ai gateway + +Manage AI Gateway. + +## Usage + +```console +coder ai gateway +``` + +## Subcommands + +| Name | Purpose | +|-------------------------------------------|-------------------------| +| [keys](./ai_gateway_keys.md) | Manage AI Gateway keys. | diff --git a/docs/reference/cli/ai_gateway_keys.md b/docs/reference/cli/ai_gateway_keys.md new file mode 100644 index 0000000000..115357653d --- /dev/null +++ b/docs/reference/cli/ai_gateway_keys.md @@ -0,0 +1,18 @@ + +# ai gateway keys + +Manage AI Gateway keys. + +## Usage + +```console +coder ai gateway keys +``` + +## Subcommands + +| Name | Purpose | +|----------------------------------------------------|--------------------------| +| [create](./ai_gateway_keys_create.md) | Create an AI Gateway key | +| [delete](./ai_gateway_keys_delete.md) | Delete an AI Gateway key | +| [list](./ai_gateway_keys_list.md) | List AI Gateway keys | diff --git a/docs/reference/cli/ai_gateway_keys_create.md b/docs/reference/cli/ai_gateway_keys_create.md new file mode 100644 index 0000000000..45067326fe --- /dev/null +++ b/docs/reference/cli/ai_gateway_keys_create.md @@ -0,0 +1,10 @@ + +# ai gateway keys create + +Create an AI Gateway key + +## Usage + +```console +coder ai gateway keys create +``` diff --git a/docs/reference/cli/ai_gateway_keys_delete.md b/docs/reference/cli/ai_gateway_keys_delete.md new file mode 100644 index 0000000000..2279712c38 --- /dev/null +++ b/docs/reference/cli/ai_gateway_keys_delete.md @@ -0,0 +1,24 @@ + +# ai gateway keys delete + +Delete an AI Gateway key + +Aliases: + +* rm + +## Usage + +```console +coder ai gateway keys delete [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts. diff --git a/docs/reference/cli/ai_gateway_keys_list.md b/docs/reference/cli/ai_gateway_keys_list.md new file mode 100644 index 0000000000..1d46d08f6b --- /dev/null +++ b/docs/reference/cli/ai_gateway_keys_list.md @@ -0,0 +1,30 @@ + +# ai gateway keys list + +List AI Gateway keys + +## Usage + +```console +coder ai gateway keys list [flags] +``` + +## Options + +### -c, --column + +| | | +|---------|---------------------------------------------------------------| +| Type | [id\|name\|key prefix\|created at\|last used at] | +| Default | id,name,key prefix,last used at,created at | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 211cba86c8..63db53cc4d 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -66,13 +66,14 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | | [server](./server.md) | Start a Coder server | | [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | -| [boundary](./boundary.md) | Network isolation tool for monitoring and restricting HTTP/HTTPS requests | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | -| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | +| [ai](./ai.md) | Manage AI features. | | [aibridge](./aibridge.md) | Manage AI Bridge. | +| [boundary](./boundary.md) | Network isolation tool for monitoring and restricting HTTP/HTTPS requests | +| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | +| [features](./features.md) | List Enterprise features | +| [groups](./groups.md) | Manage groups | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | ## Options diff --git a/enterprise/cli/ai.go b/enterprise/cli/ai.go new file mode 100644 index 0000000000..dad7e6655d --- /dev/null +++ b/enterprise/cli/ai.go @@ -0,0 +1,16 @@ +package cli + +import "github.com/coder/serpent" + +func (r *RootCmd) ai() *serpent.Command { + return &serpent.Command{ + Use: "ai", + Short: "Manage AI features.", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.aiGateway(), + }, + } +} diff --git a/enterprise/cli/aigateway.go b/enterprise/cli/aigateway.go new file mode 100644 index 0000000000..f776546556 --- /dev/null +++ b/enterprise/cli/aigateway.go @@ -0,0 +1,178 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) aiGateway() *serpent.Command { + return &serpent.Command{ + Use: "gateway", + Short: "Manage AI Gateway.", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.aiGatewayKeys(), + }, + } +} + +func (r *RootCmd) aiGatewayKeys() *serpent.Command { + return &serpent.Command{ + Use: "keys", + Short: "Manage AI Gateway keys.", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.aiGatewayKeysCreate(), + r.aiGatewayKeysDelete(), + r.aiGatewayKeysList(), + }, + } +} + +func (r *RootCmd) aiGatewayKeysCreate() *serpent.Command { + return &serpent.Command{ + Use: "create ", + Short: "Create an AI Gateway key", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + res, err := client.CreateAIGatewayCoderdKey(inv.Context(), codersdk.CreateAIGatewayCoderdKeyRequest{ + Name: inv.Args[0], + }) + if err != nil { + return xerrors.Errorf("create AI Gateway key %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "Successfully created AI Gateway key %s (ID: %s, Prefix: %s).\nSave this authentication token, it will not be shown again.\n\n%s\n", + cliui.Keyword(res.Name), + res.ID, + res.KeyPrefix, + cliui.Keyword(res.Key), + ) + return nil + }, + } +} + +func (r *RootCmd) aiGatewayKeysList() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.AIGatewayCoderdKey{}, []string{"id", "name", "key prefix", "last used at", "created at"}), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "list", + Short: "List AI Gateway keys", + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + keys, err := client.ListAIGatewayCoderdKeys(inv.Context()) + if err != nil { + return xerrors.Errorf("list AI Gateway keys: %w", err) + } + + out, err := formatter.Format(inv.Context(), keys) + if err != nil { + return xerrors.Errorf("format AI Gateway keys: %w", err) + } + if out == "" { + cliui.Info(inv.Stderr, "No AI Gateway keys found.") + return nil + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (r *RootCmd) aiGatewayKeysDelete() *serpent.Command { + cmd := &serpent.Command{ + Use: "delete ", + Short: "Delete an AI Gateway key", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + key, err := aiGatewayKeyByNameOrID(inv.Context(), client, inv.Args[0]) + if err != nil { + return err + } + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Are you sure you want to delete AI Gateway key %s (ID: %s, Prefix: %s)?", cliui.Keyword(key.Name), key.ID, key.KeyPrefix), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + err = client.DeleteAIGatewayCoderdKey(inv.Context(), key.ID) + if err != nil { + return xerrors.Errorf("delete AI Gateway key %q: %w", key.Name, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Deleted AI Gateway key %s at %s.\n", cliui.Keyword(key.Name), cliui.Timestamp(time.Now())) + return nil + }, + } + + return cmd +} + +func aiGatewayKeyByNameOrID(ctx context.Context, client *codersdk.Client, nameOrID string) (codersdk.AIGatewayCoderdKey, error) { + keys, err := client.ListAIGatewayCoderdKeys(ctx) + if err != nil { + return codersdk.AIGatewayCoderdKey{}, xerrors.Errorf("list AI Gateway keys: %w", err) + } + + for _, key := range keys { + if key.ID.String() == nameOrID { + return key, nil + } + } + for _, key := range keys { + if key.Name == nameOrID { + return key, nil + } + } + + return codersdk.AIGatewayCoderdKey{}, xerrors.Errorf("AI Gateway key %q not found", nameOrID) +} diff --git a/enterprise/cli/aigateway_test.go b/enterprise/cli/aigateway_test.go new file mode 100644 index 0000000000..04d80efecd --- /dev/null +++ b/enterprise/cli/aigateway_test.go @@ -0,0 +1,179 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestAIGatewayKeys(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = true + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, + }) + t.Run("CRUD", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // List returns empty when no keys exist. + inv, root := newCLI(t, "ai", "gateway", "keys", "list") + clitest.SetupConfig(t, ownerClient, root) + out := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + inv.Stdout = out + inv.Stderr = stderr + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Empty(t, out.String()) + require.Contains(t, stderr.String(), "No AI Gateway keys found.") + + // Create two keys and capture their IDs and prefixes from output. + keyNames := []string{"gateway-key-a", "gateway-key-b"} + createRe := regexp.MustCompile(`ID: ([0-9a-f-]+), Prefix: (\S+)\)`) + type createdKey struct { + id uuid.UUID + prefix string + } + created := make([]createdKey, 0, len(keyNames)) + for _, name := range keyNames { + inv, root = newCLI(t, "ai", "gateway", "keys", "create", name) + clitest.SetupConfig(t, ownerClient, root) + out = bytes.NewBuffer(nil) + inv.Stdout = out + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, out.String(), "Successfully created AI Gateway key "+name) + + matches := createRe.FindStringSubmatch(out.String()) + require.Len(t, matches, 3, "expected ID and Prefix in create output") + id, err := uuid.Parse(matches[1]) + require.NoError(t, err) + created = append(created, createdKey{id: id, prefix: matches[2]}) + } + + // List returns both created keys as JSON with matching IDs and prefixes. + inv, root = newCLI(t, "ai", "gateway", "keys", "list", "--output=json") + clitest.SetupConfig(t, ownerClient, root) + out = bytes.NewBuffer(nil) + inv.Stdout = out + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + var listed []codersdk.AIGatewayCoderdKey + require.NoError(t, json.Unmarshal(out.Bytes(), &listed)) + require.Len(t, listed, 2) + for i, key := range listed { + require.Equal(t, keyNames[i], key.Name) + require.Equal(t, created[i].id, key.ID) + require.Equal(t, created[i].prefix, key.KeyPrefix) + } + + // Delete the first key by name. + inv, root = newCLI(t, "ai", "gateway", "keys", "delete", "--yes", keyNames[0]) + clitest.SetupConfig(t, ownerClient, root) + out = bytes.NewBuffer(nil) + inv.Stdout = out + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, out.String(), "Deleted AI Gateway key "+keyNames[0]) + + // List returns only the remaining key. + inv, root = newCLI(t, "ai", "gateway", "keys", "list", "--output=json") + clitest.SetupConfig(t, ownerClient, root) + out = bytes.NewBuffer(nil) + inv.Stdout = out + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + require.NoError(t, json.Unmarshal(out.Bytes(), &listed)) + require.Len(t, listed, 1) + require.Equal(t, keyNames[1], listed[0].Name) + + // Delete the second key. + inv, root = newCLI(t, "ai", "gateway", "keys", "delete", "--yes", keyNames[1]) + clitest.SetupConfig(t, ownerClient, root) + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + // List returns empty after all keys deleted. + inv, root = newCLI(t, "ai", "gateway", "keys", "list", "--output=json") + clitest.SetupConfig(t, ownerClient, root) + out = bytes.NewBuffer(nil) + inv.Stdout = out + + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + require.NoError(t, json.Unmarshal(out.Bytes(), &listed)) + require.Empty(t, listed) + + // Delete a non-existent key returns not found. + missingID := uuid.New() + inv, root = newCLI(t, "ai", "gateway", "keys", "delete", "--yes", missingID.String()) + clitest.SetupConfig(t, ownerClient, root) + + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, missingID.String()) + require.ErrorContains(t, err, "not found") + }) + + t.Run("InvalidKeyName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + inv, root := newCLI(t, "ai", "gateway", "keys", "create", strings.Repeat("a", 65)) + clitest.SetupConfig(t, ownerClient, root) + + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "create AI Gateway key") + require.ErrorContains(t, err, "Invalid key name") + }) + + t.Run("MemberForbidden", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + for _, args := range [][]string{ + {"ai", "gateway", "keys", "list"}, + {"ai", "gateway", "keys", "create", "member-key"}, + {"ai", "gateway", "keys", "delete", "--yes", uuid.NewString()}, + } { + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, memberClient, root) + + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "Forbidden") + } + }) +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index baba6830e6..4275ca8a67 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -18,15 +18,16 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { agplcli.ExperimentalCommand(append(r.AGPLExperimental(), r.enterpriseExperimental()...)), // New commands that don't exist in AGPL: + r.ai(), + r.aibridge(), r.boundary(), - r.workspaceProxy(), + r.externalWorkspaces(), r.features(), - r.licenses(), r.groups(), + r.licenses(), r.prebuilds(), r.provisionerd(), - r.externalWorkspaces(), - r.aibridge(), + r.workspaceProxy(), } } diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 1db07b1801..0352462a8c 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -14,6 +14,7 @@ USAGE: $ coder templates init SUBCOMMANDS: + ai Manage AI features. aibridge Manage AI Bridge. boundary Network isolation tool for monitoring and restricting HTTP/HTTPS requests diff --git a/enterprise/cli/testdata/coder_ai_--help.golden b/enterprise/cli/testdata/coder_ai_--help.golden new file mode 100644 index 0000000000..c728716413 --- /dev/null +++ b/enterprise/cli/testdata/coder_ai_--help.golden @@ -0,0 +1,12 @@ +coder v0.0.0-devel + +USAGE: + coder ai + + Manage AI features. + +SUBCOMMANDS: + gateway Manage AI Gateway. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_ai_gateway_--help.golden b/enterprise/cli/testdata/coder_ai_gateway_--help.golden new file mode 100644 index 0000000000..388cb13931 --- /dev/null +++ b/enterprise/cli/testdata/coder_ai_gateway_--help.golden @@ -0,0 +1,12 @@ +coder v0.0.0-devel + +USAGE: + coder ai gateway + + Manage AI Gateway. + +SUBCOMMANDS: + keys Manage AI Gateway keys. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_ai_gateway_keys_--help.golden b/enterprise/cli/testdata/coder_ai_gateway_keys_--help.golden new file mode 100644 index 0000000000..0f2452d0aa --- /dev/null +++ b/enterprise/cli/testdata/coder_ai_gateway_keys_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder ai gateway keys + + Manage AI Gateway keys. + +SUBCOMMANDS: + create Create an AI Gateway key + delete Delete an AI Gateway key + list List AI Gateway keys + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_ai_gateway_keys_--help_--help.golden b/enterprise/cli/testdata/coder_ai_gateway_keys_--help_--help.golden new file mode 100644 index 0000000000..0f2452d0aa --- /dev/null +++ b/enterprise/cli/testdata/coder_ai_gateway_keys_--help_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder ai gateway keys + + Manage AI Gateway keys. + +SUBCOMMANDS: + create Create an AI Gateway key + delete Delete an AI Gateway key + list List AI Gateway keys + +——— +Run `coder --help` for a list of global options. diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json index a3ce227430..0d88784c09 100644 --- a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json @@ -33,11 +33,11 @@ "schema_version": 1, "values": { "access_port": 443, - "access_url": "https://dev.coder.com/", + "access_url": "https://mydeployment.coder.com", "id": "f8c4851f-dcbd-48bc-9a14-3fd506f8f015", "is_prebuild": false, "is_prebuild_claim": false, - "name": "ai-task-plan-check", + "name": "default", "prebuild_count": 0, "start_count": 1, "template_id": "",