tmp-commit

This commit is contained in:
Paweł Banaszewski
2026-05-26 17:05:56 +00:00
parent 2f6ac7f37e
commit 2049809391
18 changed files with 559 additions and 17 deletions
+5 -5
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai
Manage AI features.
## Usage
```console
coder ai
```
## Subcommands
| Name | Purpose |
|-----------------------------------------|--------------------|
| [<code>gateway</code>](./ai_gateway.md) | Manage AI Gateway. |
+16
View File
@@ -0,0 +1,16 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai gateway
Manage AI Gateway.
## Usage
```console
coder ai gateway
```
## Subcommands
| Name | Purpose |
|-------------------------------------------|-------------------------|
| [<code>keys</code>](./ai_gateway_keys.md) | Manage AI Gateway keys. |
+18
View File
@@ -0,0 +1,18 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai gateway keys
Manage AI Gateway keys.
## Usage
```console
coder ai gateway keys
```
## Subcommands
| Name | Purpose |
|----------------------------------------------------|--------------------------|
| [<code>create</code>](./ai_gateway_keys_create.md) | Create an AI Gateway key |
| [<code>delete</code>](./ai_gateway_keys_delete.md) | Delete an AI Gateway key |
| [<code>list</code>](./ai_gateway_keys_list.md) | List AI Gateway keys |
+10
View File
@@ -0,0 +1,10 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai gateway keys create
Create an AI Gateway key
## Usage
```console
coder ai gateway keys create <name>
```
+24
View File
@@ -0,0 +1,24 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai gateway keys delete
Delete an AI Gateway key
Aliases:
* rm
## Usage
```console
coder ai gateway keys delete [flags] <id>
```
## Options
### -y, --yes
| | |
|------|-------------------|
| Type | <code>bool</code> |
Bypass confirmation prompts.
+30
View File
@@ -0,0 +1,30 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# ai gateway keys list
List AI Gateway keys
## Usage
```console
coder ai gateway keys list [flags]
```
## Options
### -c, --column
| | |
|---------|---------------------------------------------------------------|
| Type | <code>[id\|name\|key prefix\|created at\|last used at]</code> |
| Default | <code>id,name,key prefix,last used at,created at</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.
+7 -6
View File
@@ -66,13 +66,14 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>support</code>](./support.md) | Commands for troubleshooting issues with a Coder deployment. |
| [<code>server</code>](./server.md) | Start a Coder server |
| [<code>provisioner</code>](./provisioner.md) | View and manage provisioner daemons and jobs |
| [<code>boundary</code>](./boundary.md) | Network isolation tool for monitoring and restricting HTTP/HTTPS requests |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
| [<code>external-workspaces</code>](./external-workspaces.md) | Create or manage external workspaces |
| [<code>ai</code>](./ai.md) | Manage AI features. |
| [<code>aibridge</code>](./aibridge.md) | Manage AI Bridge. |
| [<code>boundary</code>](./boundary.md) | Network isolation tool for monitoring and restricting HTTP/HTTPS requests |
| [<code>external-workspaces</code>](./external-workspaces.md) | Create or manage external workspaces |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
## Options
+16
View File
@@ -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(),
},
}
}
+178
View File
@@ -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 <name>",
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 <id>",
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)
}
+179
View File
@@ -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")
}
})
}
+5 -4
View File
@@ -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(),
}
}
+1
View File
@@ -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
+12
View File
@@ -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.
+12
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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": "",