Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed3aa7877 | |||
| ed679bb3da | |||
| bfae5b03dc | |||
| ca2e728fcb | |||
| 12a6a9b5f0 | |||
| b163b4c950 | |||
| 9776dc16bd | |||
| e79f1d0406 | |||
| 2bfd54dfdb | |||
| 8db1e0481a | |||
| 31654deb87 | |||
| 25ac3dbab8 | |||
| a002fbbae6 | |||
| 08343a7a9f | |||
| d176714f90 | |||
| 34c7fe2eaf | |||
| a406ed7cc5 | |||
| 1813605012 | |||
| a4e14448c2 | |||
| 4d414a0df7 | |||
| ff9ed91811 | |||
| ea465d4ea3 | |||
| fe68ec9095 | |||
| ab126e0f0a | |||
| ad23ea3561 | |||
| 3b07f7b9c4 | |||
| 11b35a5f94 | |||
| 170fbcdb14 | |||
| 3db5558603 | |||
| 61961db41d | |||
| d2d7c0ee40 | |||
| d25d95231f | |||
| 3a62a8e70e | |||
| 7fc84ecf0b | |||
| 0ebe8e57ad | |||
| 3894edbcc3 | |||
| d5296a4855 | |||
| 5073493850 | |||
| 32354261d3 | |||
| 6683d807ac | |||
| 7c2479ce92 | |||
| e1156b050f | |||
| 0712faef4f | |||
| 7d5cd06f83 |
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.10"
|
||||
default: "1.24.11"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
@@ -211,14 +211,6 @@ issues:
|
||||
- path: scripts/rules.go
|
||||
linters:
|
||||
- ALL
|
||||
# Boundary code is imported from github.com/coder/boundary and has different
|
||||
# lint standards. Suppress lint issues in this imported code.
|
||||
- path: enterprise/cli/boundary/
|
||||
linters:
|
||||
- revive
|
||||
- gocritic
|
||||
- gosec
|
||||
- errorlint
|
||||
|
||||
fix: true
|
||||
max-issues-per-linter: 0
|
||||
|
||||
@@ -69,6 +69,9 @@ MOST_GO_SRC_FILES := $(shell \
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
MIGRATION_FILES := $(shell find ./coderd/database/migrations/ -maxdepth 1 $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
FIXTURE_FILES := $(shell find ./coderd/database/migrations/testdata/fixtures/ $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
|
||||
# Ensure we don't use the user's git configs which might cause side-effects
|
||||
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
|
||||
|
||||
@@ -561,7 +564,7 @@ endif
|
||||
|
||||
# Note: we don't run zizmor in the lint target because it takes a while. CI
|
||||
# runs it explicitly.
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes lint/migrations
|
||||
.PHONY: lint
|
||||
|
||||
lint/site-icons:
|
||||
@@ -619,6 +622,12 @@ lint/check-scopes: coderd/database/dump.sql
|
||||
go run ./scripts/check-scopes
|
||||
.PHONY: lint/check-scopes
|
||||
|
||||
# Verify migrations do not hardcode the public schema.
|
||||
lint/migrations:
|
||||
./scripts/check_pg_schema.sh "Migrations" $(MIGRATION_FILES)
|
||||
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
|
||||
.PHONY: lint/migrations
|
||||
|
||||
# All files generated by the database should be added here, and this can be used
|
||||
# as a target for jobs that need to run after the database is generated.
|
||||
DB_GEN_FILES := \
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
@@ -295,6 +296,8 @@ type agent struct {
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI *agentcontainers.API
|
||||
|
||||
filesAPI *agentfiles.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
socketServer *agentsocket.Server
|
||||
@@ -365,6 +368,8 @@ func (a *agent) init() {
|
||||
|
||||
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
|
||||
|
||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
|
||||
|
||||
a.reconnectingPTYServer = reconnectingpty.NewServer(
|
||||
a.logger.Named("reconnecting-pty"),
|
||||
a.sshServer,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
)
|
||||
|
||||
// API exposes file-related operations performed through the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
filesystem afero.Fs
|
||||
}
|
||||
|
||||
func NewAPI(logger slog.Logger, filesystem afero.Fs) *API {
|
||||
api := &API{
|
||||
logger: logger,
|
||||
filesystem: filesystem,
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for file-related routes.
|
||||
func (api *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Post("/list-directory", api.HandleLS)
|
||||
r.Get("/read-file", api.HandleReadFile)
|
||||
r.Post("/write-file", api.HandleWriteFile)
|
||||
r.Post("/edit-files", api.HandleEditFiles)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
type HTTPResponseCode = int
|
||||
|
||||
func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
query := r.URL.Query()
|
||||
@@ -42,7 +42,7 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := a.streamFile(ctx, rw, path, offset, limit)
|
||||
status, err := api.streamFile(ctx, rw, path, offset, limit)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
@@ -51,12 +51,12 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
|
||||
func (api *API) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Open(path)
|
||||
f, err := api.filesystem.Open(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -97,13 +97,13 @@ func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path str
|
||||
reader := io.NewSectionReader(f, offset, bytesToRead)
|
||||
_, err = io.Copy(rw, reader)
|
||||
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
|
||||
a.logger.Error(ctx, "workspace agent read file", slog.Error(err))
|
||||
api.logger.Error(ctx, "workspace agent read file", slog.Error(err))
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
query := r.URL.Query()
|
||||
@@ -118,7 +118,7 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := a.writeFile(ctx, r, path)
|
||||
status, err := api.writeFile(ctx, r, path)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
@@ -131,13 +131,13 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
|
||||
func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
err := a.filesystem.MkdirAll(dir, 0o755)
|
||||
err := api.filesystem.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -149,7 +149,7 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
|
||||
return status, err
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Create(path)
|
||||
f, err := api.filesystem.Create(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -164,13 +164,13 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
|
||||
|
||||
_, err = io.Copy(f, r.Body)
|
||||
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
|
||||
a.logger.Error(ctx, "workspace agent write file", slog.Error(err))
|
||||
api.logger.Error(ctx, "workspace agent write file", slog.Error(err))
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req workspacesdk.FileEditRequest
|
||||
@@ -188,7 +188,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
var combinedErr error
|
||||
status := http.StatusOK
|
||||
for _, edit := range req.Files {
|
||||
s, err := a.editFile(r.Context(), edit.Path, edit.Edits)
|
||||
s, err := api.editFile(r.Context(), edit.Path, edit.Edits)
|
||||
// Keep the highest response status, so 500 will be preferred over 400, etc.
|
||||
if s > status {
|
||||
status = s
|
||||
@@ -210,7 +210,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
|
||||
func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
|
||||
if path == "" {
|
||||
return http.StatusBadRequest, xerrors.New("\"path\" is required")
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Open(path)
|
||||
f, err := api.filesystem.Open(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -252,7 +252,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
|
||||
// Create an adjacent file to ensure it will be on the same device and can be
|
||||
// moved atomically.
|
||||
tmpfile, err := afero.TempFile(a.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
tmpfile, err := afero.TempFile(api.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -260,13 +260,13 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
|
||||
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
|
||||
if err != nil {
|
||||
if rerr := a.filesystem.Remove(tmpfile.Name()); rerr != nil {
|
||||
a.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
if rerr := api.filesystem.Remove(tmpfile.Name()); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = a.filesystem.Rename(tmpfile.Name(), path)
|
||||
err = api.filesystem.Rename(tmpfile.Name(), path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package agent_test
|
||||
package agentfiles_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -16,10 +18,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -106,15 +108,15 @@ func TestReadFile(t *testing.T) {
|
||||
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "a-directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -260,19 +262,22 @@ func TestReadFile(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
reader, mimeType, err := conn.ReadFile(ctx, tt.path, tt.offset, tt.limit)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
require.Contains(t, cerr.Error(), tt.error)
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
} else {
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
bytes, err := io.ReadAll(reader)
|
||||
require.ErrorContains(t, got, tt.error)
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
bytes, err := io.ReadAll(w.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.bytes, bytes)
|
||||
require.Equal(t, tt.mimeType, mimeType)
|
||||
require.Equal(t, tt.mimeType, w.Header().Get("Content-Type"))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -284,15 +289,14 @@ func TestWriteFile(t *testing.T) {
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
||||
noPermsDirPath := filepath.Join(tmpdir, "no-perms-dir")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath || file == noPermsDirPath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath || file == noPermsDirPath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -371,17 +375,21 @@ func TestWriteFile(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
reader := bytes.NewReader(tt.bytes)
|
||||
err := conn.WriteFile(ctx, tt.path, reader)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/write-file?path=%s", tt.path), reader)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
require.Contains(t, cerr.Error(), tt.error)
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, got, tt.error)
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
bytes, err := afero.ReadFile(fs, tt.path)
|
||||
require.NoError(t, err)
|
||||
b, err := afero.ReadFile(fs, tt.path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.bytes, b)
|
||||
require.Equal(t, tt.bytes, bytes)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -393,21 +401,20 @@ func TestEditFiles(t *testing.T) {
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
||||
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return &os.PathError{
|
||||
Op: call,
|
||||
Path: file,
|
||||
Err: os.ErrPermission,
|
||||
}
|
||||
} else if file == failRenameFilePath && call == "rename" {
|
||||
return xerrors.New("rename failed")
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return &os.PathError{
|
||||
Op: call,
|
||||
Path: file,
|
||||
Err: os.ErrPermission,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else if file == failRenameFilePath && call == "rename" {
|
||||
return xerrors.New("rename failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -701,16 +708,26 @@ func TestEditFiles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: tt.edits})
|
||||
buf := bytes.NewBuffer(nil)
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
err := enc.Encode(workspacesdk.FileEditRequest{Files: tt.edits})
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
for _, error := range tt.errors {
|
||||
require.Contains(t, cerr.Error(), error)
|
||||
}
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
} else {
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
for _, error := range tt.errors {
|
||||
require.ErrorContains(t, got, error)
|
||||
}
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
for path, expect := range tt.expected {
|
||||
b, err := afero.ReadFile(fs, path)
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
|
||||
|
||||
func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// An absolute path may be optionally provided, otherwise a path split into an
|
||||
@@ -43,7 +43,7 @@ func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := listFiles(a.filesystem, path, req)
|
||||
resp, err := listFiles(api.filesystem, path, req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"os"
|
||||
+2
-4
@@ -27,6 +27,8 @@ func (a *agent) apiHandler() http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
r.Mount("/api/v0", a.filesAPI.Routes())
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
|
||||
@@ -49,10 +51,6 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/api/v0/read-file", a.HandleReadFile)
|
||||
r.Post("/api/v0/write-file", a.HandleWriteFile)
|
||||
r.Post("/api/v0/edit-files", a.HandleEditFiles)
|
||||
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
|
||||
+2
-11
@@ -10,12 +10,8 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, defaultOverrides map[string]string) (string, error) {
|
||||
label := templateVersionParameter.Name
|
||||
if templateVersionParameter.DisplayName != "" {
|
||||
label = templateVersionParameter.DisplayName
|
||||
}
|
||||
|
||||
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, name, defaultValue string) (string, error) {
|
||||
label := name
|
||||
if templateVersionParameter.Ephemeral {
|
||||
label += pretty.Sprint(DefaultStyles.Warn, " (build option)")
|
||||
}
|
||||
@@ -26,11 +22,6 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
defaultValue := templateVersionParameter.DefaultValue
|
||||
if v, ok := defaultOverrides[templateVersionParameter.Name]; ok {
|
||||
defaultValue = v
|
||||
}
|
||||
|
||||
var err error
|
||||
var value string
|
||||
switch {
|
||||
|
||||
+2
-2
@@ -32,12 +32,12 @@ type PromptOptions struct {
|
||||
const skipPromptFlag = "yes"
|
||||
|
||||
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
|
||||
// prompts.
|
||||
// confirmation prompts.
|
||||
func SkipPromptOption() serpent.Option {
|
||||
return serpent.Option{
|
||||
Flag: skipPromptFlag,
|
||||
FlagShorthand: "y",
|
||||
Description: "Bypass prompts.",
|
||||
Description: "Bypass confirmation prompts.",
|
||||
// Discard
|
||||
Value: serpent.BoolOf(new(bool)),
|
||||
}
|
||||
|
||||
+17
-5
@@ -42,9 +42,10 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
useParameterDefaults bool
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
@@ -308,7 +309,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
displayAppliedPreset(inv, preset, presetParameters)
|
||||
} else {
|
||||
// Inform the user that no preset was applied
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
|
||||
}
|
||||
|
||||
if opts.BeforeCreate != nil {
|
||||
@@ -329,6 +330,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
RichParameterDefaults: cliBuildParameterDefaults,
|
||||
|
||||
SourceWorkspaceParameters: sourceWorkspaceParameters,
|
||||
|
||||
UseParameterDefaults: useParameterDefaults,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
@@ -435,6 +438,12 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
Description: "Specify the source workspace name to copy parameters from.",
|
||||
Value: serpent.StringOf(©ParametersFrom),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "use-parameter-defaults",
|
||||
Env: "CODER_WORKSPACE_USE_PARAMETER_DEFAULTS",
|
||||
Description: "Automatically accept parameter defaults when no value is provided.",
|
||||
Value: serpent.BoolOf(&useParameterDefaults),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
@@ -459,6 +468,8 @@ type prepWorkspaceBuildArgs struct {
|
||||
RichParameters []codersdk.WorkspaceBuildParameter
|
||||
RichParameterFile string
|
||||
RichParameterDefaults []codersdk.WorkspaceBuildParameter
|
||||
|
||||
UseParameterDefaults bool
|
||||
}
|
||||
|
||||
// resolvePreset returns the preset matching the given presetName (if specified),
|
||||
@@ -561,7 +572,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
WithPromptRichParameters(args.PromptRichParameters).
|
||||
WithRichParameters(args.RichParameters).
|
||||
WithRichParametersFile(parameterFile).
|
||||
WithRichParametersDefaults(args.RichParameterDefaults)
|
||||
WithRichParametersDefaults(args.RichParameterDefaults).
|
||||
WithUseParameterDefaults(args.UseParameterDefaults)
|
||||
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+411
-327
@@ -318,353 +318,437 @@ func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.P
|
||||
}
|
||||
}
|
||||
|
||||
type param struct {
|
||||
name string
|
||||
ptype string
|
||||
value string
|
||||
mutable bool
|
||||
}
|
||||
|
||||
func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
firstParameterName = "first_parameter"
|
||||
firstParameterDescription = "This is first parameter"
|
||||
firstParameterValue = "1"
|
||||
|
||||
secondParameterName = "second_parameter"
|
||||
secondParameterDisplayName = "Second Parameter"
|
||||
secondParameterDescription = "This is second parameter"
|
||||
secondParameterValue = "2"
|
||||
|
||||
immutableParameterName = "third_parameter"
|
||||
immutableParameterDescription = "This is not mutable parameter"
|
||||
immutableParameterValue = "4"
|
||||
)
|
||||
|
||||
echoResponses := func() *echo.Responses {
|
||||
return prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
|
||||
{Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true},
|
||||
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
|
||||
})
|
||||
// Default parameters and their expected values.
|
||||
params := []param{
|
||||
{
|
||||
name: "number_param",
|
||||
ptype: "number",
|
||||
value: "777",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "string_param",
|
||||
ptype: "string",
|
||||
value: "qux",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "bool_param",
|
||||
// TODO: Setting the type breaks booleans. It claims the default is false
|
||||
// but when you then accept this default it errors saying that the value
|
||||
// must be true or false. For now, use a string.
|
||||
ptype: "string",
|
||||
value: "false",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "immutable_string_param",
|
||||
ptype: "string",
|
||||
value: "i am eternal",
|
||||
mutable: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("InputParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
type testContext struct {
|
||||
client *codersdk.Client
|
||||
member *codersdk.Client
|
||||
owner codersdk.CreateFirstUserResponse
|
||||
template codersdk.Template
|
||||
workspaceName string
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
tests := []struct {
|
||||
name string
|
||||
// setup runs before the command is started and return arguments that will
|
||||
// be appended to the create command.
|
||||
setup func() []string
|
||||
// handlePty optionally runs after the command is started. It should handle
|
||||
// all expected prompts from the pty.
|
||||
handlePty func(pty *ptytest.PTY)
|
||||
// postRun runs after the command has finished but before the workspace is
|
||||
// verified. It must return the workspace name to check (used for the copy
|
||||
// workspace tests).
|
||||
postRun func(t *testing.T, args testContext) string
|
||||
// errors contains expected errors. The workspace will not be verified if
|
||||
// errors are expected.
|
||||
errors []string
|
||||
// inputParameters overrides the default parameters.
|
||||
inputParameters []param
|
||||
// expectedParameters defaults to inputParameters.
|
||||
expectedParameters []param
|
||||
// withDefaults sets DefaultValue to each parameter's value.
|
||||
withDefaults bool
|
||||
}{
|
||||
{
|
||||
name: "ValuesFromPrompt",
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Enter the value for each parameter as prompted.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.WriteLine(param.value)
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromDefaultFlags",
|
||||
setup: func() []string {
|
||||
// Provide the defaults on the command line.
|
||||
args := []string{}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Simply accept the defaults.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromFile",
|
||||
setup: func() []string {
|
||||
// Create a file with the values.
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
for _, param := range params {
|
||||
_, err := parameterFile.WriteString(fmt.Sprintf("%s: %s\n", param.name, param.value))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
return []string{"--rich-parameter-file", parameterFile.Name()}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromFlags",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
var args []string
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MisspelledParameter",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{}
|
||||
for i, param := range params {
|
||||
if i == 0 {
|
||||
// Slightly misspell the first parameter with an extra character.
|
||||
args = append(args, "--parameter", fmt.Sprintf("n%s=%s", param.name, param.value))
|
||||
} else {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
}
|
||||
return args
|
||||
},
|
||||
errors: []string{
|
||||
"parameter \"n" + params[0].name + "\" is not present in the template",
|
||||
"Did you mean: " + params[0].name,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromWorkspace",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{"-y"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
postRun: func(t *testing.T, tctx testContext) string {
|
||||
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
|
||||
clitest.SetupConfig(t, tctx.member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "failed to create a workspace based on the source workspace")
|
||||
return "other-workspace"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromOutdatedWorkspace",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{"-y"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
postRun: func(t *testing.T, tctx testContext) string {
|
||||
// Update the template to a new version.
|
||||
version2 := coderdtest.CreateTemplateVersion(t, tctx.client, tctx.owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "another_parameter", Type: "string", DefaultValue: "not-relevant"},
|
||||
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = tctx.template.ID
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, tctx.client, version2.ID)
|
||||
coderdtest.UpdateActiveTemplateVersion(t, tctx.client, tctx.template.ID, version2.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// Then create the copy. It should use the old template version.
|
||||
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
|
||||
clitest.SetupConfig(t, tctx.member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "failed to create a workspace based on the source workspace")
|
||||
return "other-workspace"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromTemplateDefaults",
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Simply accept the defaults.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
},
|
||||
{
|
||||
name: "ValuesFromTemplateDefaultsNoPrompt",
|
||||
setup: func() []string {
|
||||
return []string{"--use-parameter-defaults"}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Default values should get printed.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
|
||||
}
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
},
|
||||
{
|
||||
name: "ValuesFromDefaultFlagsNoPrompt",
|
||||
setup: func() []string {
|
||||
// Provide the defaults on the command line.
|
||||
args := []string{"--use-parameter-defaults"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Default values should get printed.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
|
||||
}
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
// File and flags should override template defaults. Additionally, if a
|
||||
// value has no default value we should still get a prompt for it.
|
||||
name: "ValuesFromMultipleSources",
|
||||
setup: func() []string {
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, err := parameterFile.WriteString(`
|
||||
file_param: from file
|
||||
cli_param: from file`)
|
||||
require.NoError(t, err)
|
||||
return []string{
|
||||
"--use-parameter-defaults",
|
||||
"--rich-parameter-file", parameterFile.Name(),
|
||||
"--parameter-default", "file_param=from cli default",
|
||||
"--parameter-default", "cli_param=from cli default",
|
||||
"--parameter", "cli_param=from cli",
|
||||
}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Should get prompted for the input param since it has no default.
|
||||
pty.ExpectMatch("input_param")
|
||||
pty.WriteLine("from input")
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDisplayName, "",
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
inputParameters: []param{
|
||||
{
|
||||
name: "template_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "file_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "cli_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "input_param",
|
||||
},
|
||||
},
|
||||
expectedParameters: []param{
|
||||
{
|
||||
name: "template_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "file_param",
|
||||
value: "from file",
|
||||
},
|
||||
{
|
||||
name: "cli_param",
|
||||
value: "from cli",
|
||||
},
|
||||
{
|
||||
name: "input_param",
|
||||
value: "from input",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parameters := params
|
||||
if len(tt.inputParameters) > 0 {
|
||||
parameters = tt.inputParameters
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ParametersDefaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Convert parameters for the echo provisioner response.
|
||||
var rparams []*proto.RichParameter
|
||||
for i, param := range parameters {
|
||||
defaultValue := ""
|
||||
if tt.withDefaults {
|
||||
defaultValue = param.value
|
||||
}
|
||||
rparams = append(rparams, &proto.RichParameter{
|
||||
Name: param.name,
|
||||
Type: param.ptype,
|
||||
Mutable: param.mutable,
|
||||
DefaultValue: defaultValue,
|
||||
Order: int32(i), //nolint:gosec
|
||||
})
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
// Set up the template.
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(rparams))
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// Run the command, possibly setting up values.
|
||||
workspaceName := "my-workspace"
|
||||
args := []string{"create", workspaceName, "--template", template.Name}
|
||||
if tt.setup != nil {
|
||||
args = append(args, tt.setup()...)
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan error)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
defaultValue := matches[i+1]
|
||||
// The test may do something with the pty.
|
||||
if tt.handlePty != nil {
|
||||
tt.handlePty(pty)
|
||||
}
|
||||
|
||||
pty.ExpectMatch(match)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + defaultValue + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
<-doneChan
|
||||
// Wait for the command to exit.
|
||||
err := <-doneChan
|
||||
|
||||
// Verify that the expected default values were used.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
// The test may want to run additional setup like copying the workspace.
|
||||
if tt.postRun != nil {
|
||||
workspaceName = tt.postRun(t, testContext{
|
||||
client: client,
|
||||
member: member,
|
||||
owner: owner,
|
||||
template: template,
|
||||
workspaceName: workspaceName,
|
||||
})
|
||||
}
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: "my-workspace",
|
||||
if len(tt.errors) > 0 {
|
||||
require.Error(t, err)
|
||||
for _, errstr := range tt.errors {
|
||||
assert.ErrorContains(t, err, errstr)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the workspace was created and has the right template and values.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Name: workspaceName})
|
||||
require.NoError(t, err, "expected to find created workspace")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
if len(tt.expectedParameters) > 0 {
|
||||
parameters = tt.expectedParameters
|
||||
}
|
||||
require.Len(t, buildParameters, len(parameters))
|
||||
for _, param := range parameters {
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: param.name, Value: param.value})
|
||||
}
|
||||
}
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
|
||||
t.Run("RichParametersFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString(
|
||||
firstParameterName + ": " + firstParameterValue + "\n" +
|
||||
secondParameterName + ": " + secondParameterValue + "\n" +
|
||||
immutableParameterName + ": " + immutableParameterValue)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ParameterFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("WrongParameterName/DidYouMean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
wrongFirstParameterName := "frst-prameter"
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter", fmt.Sprintf("%s=%s", wrongFirstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
assert.ErrorContains(t, err, "parameter \""+wrongFirstParameterName+"\" is not present in the template")
|
||||
assert.ErrorContains(t, err, "Did you mean: "+firstParameterName)
|
||||
})
|
||||
|
||||
t.Run("CopyParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
// Firstly, create a regular workspace using template with parameters.
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "can't create first workspace")
|
||||
|
||||
// Secondly, create a new workspace using parameters from the previous workspace.
|
||||
const otherWorkspace = "other-workspace"
|
||||
|
||||
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty = ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err, "can't create a workspace based on the source workspace")
|
||||
|
||||
// Verify if the new workspace uses expected parameters.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: otherWorkspace,
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
|
||||
t.Run("CopyParametersFromNotUpdatedWorkspace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
// Firstly, create a regular workspace using template with parameters.
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "can't create first workspace")
|
||||
|
||||
// Secondly, update the template to the newer version.
|
||||
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "third_parameter", Type: "string", DefaultValue: "not-relevant"},
|
||||
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = template.ID
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
|
||||
coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID)
|
||||
|
||||
// Thirdly, create a new workspace using parameters from the previous workspace.
|
||||
const otherWorkspace = "other-workspace"
|
||||
|
||||
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty = ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err, "can't create a workspace based on the source workspace")
|
||||
|
||||
// Verify if the new workspace uses expected parameters.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: otherWorkspace,
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, otherWorkspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithPreset(t *testing.T) {
|
||||
|
||||
+4
-10
@@ -1,18 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
boundarycli "github.com/coder/boundary/cli"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) boundary() *serpent.Command {
|
||||
return &serpent.Command{
|
||||
Use: "boundary",
|
||||
Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests (enterprise)",
|
||||
Long: `boundary creates an isolated network environment for target processes. This is an enterprise feature.`,
|
||||
Handler: func(_ *serpent.Invocation) error {
|
||||
return xerrors.New("boundary is an enterprise feature; upgrade to use this command")
|
||||
},
|
||||
}
|
||||
cmd := boundarycli.BaseCommand() // Package coder/boundary/cli exports a "base command" designed to be integrated as a subcommand.
|
||||
cmd.Use += " [args...]" // The base command looks like `boundary -- command`. Serpent adds the flags piece, but we need to add the args.
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
boundarycli "github.com/coder/boundary/cli"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// Here we want to test that integrating boundary as a subcommand doesn't break anything.
|
||||
// The full boundary functionality is tested in enterprise/cli.
|
||||
// Actually testing the functionality of coder/boundary takes place in the
|
||||
// coder/boundary repo, since it's a dependency of coder.
|
||||
// Here we want to test basically that integrating it as a subcommand doesn't break anything.
|
||||
func TestBoundarySubcommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -25,5 +27,7 @@ func TestBoundarySubcommand(t *testing.T) {
|
||||
}()
|
||||
|
||||
// Expect the --help output to include the short description.
|
||||
pty.ExpectMatch("Network isolation tool")
|
||||
// We're simply confirming that `coder boundary --help` ran without a runtime error as
|
||||
// a good chunk of serpents self validation logic happens at runtime.
|
||||
pty.ExpectMatch(boundarycli.BaseCommand().Short)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
r.scaletestTaskStatus(),
|
||||
r.scaletestSMTP(),
|
||||
r.scaletestPrebuilds(),
|
||||
r.scaletestBridge(),
|
||||
r.scaletestLLMMock(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/bridge"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestBridge() *serpent.Command {
|
||||
var (
|
||||
concurrentUsers int64
|
||||
noCleanup bool
|
||||
mode string
|
||||
upstreamURL string
|
||||
provider string
|
||||
requestsPerUser int64
|
||||
useStreamingAPI bool
|
||||
requestPayloadSize int64
|
||||
numMessages int64
|
||||
httpTimeout time.Duration
|
||||
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "bridge",
|
||||
Short: "Generate load on the AI Bridge service.",
|
||||
Long: `Generate load for AI Bridge testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Bridge, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons).
|
||||
|
||||
Examples:
|
||||
# Test OpenAI API through bridge
|
||||
coder scaletest bridge --mode bridge --provider openai --concurrent-users 10 --request-count 5 --num-messages 10
|
||||
|
||||
# Test Anthropic API through bridge
|
||||
coder scaletest bridge --mode bridge --provider anthropic --concurrent-users 10 --request-count 5 --num-messages 10
|
||||
|
||||
# Test directly against mock server
|
||||
coder scaletest bridge --mode direct --provider openai --upstream-url http://localhost:8080/v1/chat/completions
|
||||
`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := bridge.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
var userConfig createusers.Config
|
||||
if bridge.RequestMode(mode) == bridge.RequestModeBridge {
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(me.OrganizationIDs) == 0 {
|
||||
return xerrors.Errorf("admin user must have at least one organization")
|
||||
}
|
||||
userConfig = createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Bridge...")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Direct mode: making requests directly to %s\n", upstreamURL)
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
config := bridge.Config{
|
||||
Mode: bridge.RequestMode(mode),
|
||||
Metrics: metrics,
|
||||
Provider: provider,
|
||||
RequestCount: int(requestsPerUser),
|
||||
Stream: useStreamingAPI,
|
||||
RequestPayloadSize: int(requestPayloadSize),
|
||||
NumMessages: int(numMessages),
|
||||
HTTPTimeout: httpTimeout,
|
||||
UpstreamURL: upstreamURL,
|
||||
User: userConfig,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
if err := config.PrepareRequestBody(); err != nil {
|
||||
return xerrors.Errorf("prepare request body: %w", err)
|
||||
}
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := range concurrentUsers {
|
||||
id := strconv.Itoa(int(i))
|
||||
name := fmt.Sprintf("bridge-%s", id)
|
||||
var runner harness.Runnable = bridge.NewRunner(client, config)
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Bridge scaletest configuration:")
|
||||
tw := tabwriter.NewWriter(inv.Stderr, 0, 0, 2, ' ', 0)
|
||||
for _, opt := range inv.Command.Options {
|
||||
if opt.Hidden || opt.ValueSource == serpent.ValueSourceNone {
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, " %s:\t%s", opt.Name, opt.Value.String())
|
||||
if opt.ValueSource != serpent.ValueSourceDefault {
|
||||
_, _ = fmt.Fprintf(tw, "\t(from %s)", opt.ValueSource)
|
||||
}
|
||||
_, _ = fmt.Fprintln(tw)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nRunning bridge scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "concurrent-users",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_BRIDGE_CONCURRENT_USERS",
|
||||
Description: "Required: Number of concurrent users.",
|
||||
Value: serpent.Validate(serpent.Int64Of(&concurrentUsers), func(value *serpent.Int64) error {
|
||||
if value == nil || value.Value() <= 0 {
|
||||
return xerrors.Errorf("--concurrent-users must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "mode",
|
||||
Env: "CODER_SCALETEST_BRIDGE_MODE",
|
||||
Default: "direct",
|
||||
Description: "Request mode: 'bridge' (create users and use AI Bridge) or 'direct' (make requests directly to upstream-url).",
|
||||
Value: serpent.EnumOf(&mode, string(bridge.RequestModeBridge), string(bridge.RequestModeDirect)),
|
||||
},
|
||||
{
|
||||
Flag: "upstream-url",
|
||||
Env: "CODER_SCALETEST_BRIDGE_UPSTREAM_URL",
|
||||
Description: "URL to make requests to directly (required in direct mode, e.g., http://localhost:8080/v1/chat/completions).",
|
||||
Value: serpent.StringOf(&upstreamURL),
|
||||
},
|
||||
{
|
||||
Flag: "provider",
|
||||
Env: "CODER_SCALETEST_BRIDGE_PROVIDER",
|
||||
Default: "openai",
|
||||
Description: "API provider to use.",
|
||||
Value: serpent.EnumOf(&provider, "openai", "anthropic"),
|
||||
},
|
||||
{
|
||||
Flag: "request-count",
|
||||
Env: "CODER_SCALETEST_BRIDGE_REQUEST_COUNT",
|
||||
Default: "1",
|
||||
Description: "Number of sequential requests to make per runner.",
|
||||
Value: serpent.Validate(serpent.Int64Of(&requestsPerUser), func(value *serpent.Int64) error {
|
||||
if value == nil || value.Value() <= 0 {
|
||||
return xerrors.Errorf("--request-count must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
{
|
||||
Flag: "stream",
|
||||
Env: "CODER_SCALETEST_BRIDGE_STREAM",
|
||||
Description: "Enable streaming requests.",
|
||||
Value: serpent.BoolOf(&useStreamingAPI),
|
||||
},
|
||||
{
|
||||
Flag: "request-payload-size",
|
||||
Env: "CODER_SCALETEST_BRIDGE_REQUEST_PAYLOAD_SIZE",
|
||||
Default: "1024",
|
||||
Description: "Size in bytes of the request payload (user message content). If 0, uses default message content.",
|
||||
Value: serpent.Int64Of(&requestPayloadSize),
|
||||
},
|
||||
{
|
||||
Flag: "num-messages",
|
||||
Env: "CODER_SCALETEST_BRIDGE_NUM_MESSAGES",
|
||||
Default: "1",
|
||||
Description: "Number of messages to include in the conversation.",
|
||||
Value: serpent.Int64Of(&numMessages),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
{
|
||||
Flag: "http-timeout",
|
||||
Env: "CODER_SCALETEST_BRIDGE_HTTP_TIMEOUT",
|
||||
Default: "30s",
|
||||
Description: "Timeout for individual HTTP requests to the upstream provider.",
|
||||
Value: serpent.DurationOf(&httpTimeout),
|
||||
},
|
||||
}
|
||||
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/scaletest/llmmock"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) scaletestLLMMock() *serpent.Command {
|
||||
var (
|
||||
address string
|
||||
artificialLatency time.Duration
|
||||
responsePayloadSize int64
|
||||
|
||||
pprofEnable bool
|
||||
pprofAddress string
|
||||
|
||||
traceEnable bool
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "llm-mock",
|
||||
Short: "Start a mock LLM API server for testing",
|
||||
Long: `Start a mock LLM API server that simulates OpenAI and Anthropic APIs`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, stop := signal.NotifyContext(inv.Context(), StopSignals...)
|
||||
defer stop()
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||
|
||||
if pprofEnable {
|
||||
closePprof := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer closePprof()
|
||||
logger.Info(ctx, "pprof server started", slog.F("address", pprofAddress))
|
||||
}
|
||||
|
||||
config := llmmock.Config{
|
||||
Address: address,
|
||||
Logger: logger,
|
||||
ArtificialLatency: artificialLatency,
|
||||
ResponsePayloadSize: int(responsePayloadSize),
|
||||
PprofEnable: pprofEnable,
|
||||
PprofAddress: pprofAddress,
|
||||
TraceEnable: traceEnable,
|
||||
}
|
||||
srv := new(llmmock.Server)
|
||||
|
||||
if err := srv.Start(ctx, config); err != nil {
|
||||
return xerrors.Errorf("start mock LLM server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock LLM API server started on %s\n", srv.APIAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " OpenAI endpoint: %s/v1/chat/completions\n", srv.APIAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " Anthropic endpoint: %s/v1/messages\n", srv.APIAddress())
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "address",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_ADDRESS",
|
||||
Default: "localhost",
|
||||
Description: "Address to bind the mock LLM API server. Can include a port (e.g., 'localhost:8080' or ':8080'). Uses a random port if no port is specified.",
|
||||
Value: serpent.StringOf(&address),
|
||||
},
|
||||
{
|
||||
Flag: "artificial-latency",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_ARTIFICIAL_LATENCY",
|
||||
Default: "0s",
|
||||
Description: "Artificial latency to add to each response (e.g., 100ms, 1s). Simulates slow upstream processing.",
|
||||
Value: serpent.DurationOf(&artificialLatency),
|
||||
},
|
||||
{
|
||||
Flag: "response-payload-size",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_RESPONSE_PAYLOAD_SIZE",
|
||||
Default: "0",
|
||||
Description: "Size in bytes of the response payload. If 0, uses default context-aware responses.",
|
||||
Value: serpent.Int64Of(&responsePayloadSize),
|
||||
},
|
||||
{
|
||||
Flag: "pprof-enable",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ENABLE",
|
||||
Default: "false",
|
||||
Description: "Serve pprof metrics on the address defined by pprof-address.",
|
||||
Value: serpent.BoolOf(&pprofEnable),
|
||||
},
|
||||
{
|
||||
Flag: "pprof-address",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ADDRESS",
|
||||
Default: "127.0.0.1:6060",
|
||||
Description: "The bind address to serve pprof.",
|
||||
Value: serpent.StringOf(&pprofAddress),
|
||||
},
|
||||
{
|
||||
Flag: "trace-enable",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_TRACE_ENABLE",
|
||||
Default: "false",
|
||||
Description: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.",
|
||||
Value: serpent.BoolOf(&traceEnable),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -65,6 +65,22 @@ func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent
|
||||
return cli.OrganizationIDPSyncSettings(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "workspace-sharing",
|
||||
Aliases: []string{"workspacesharing"},
|
||||
Short: "Workspace sharing settings for the organization.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.WorkspaceSharingSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling workspace sharing settings: %w", err)
|
||||
}
|
||||
return cli.PatchWorkspaceSharingSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.WorkspaceSharingSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "settings",
|
||||
|
||||
@@ -34,6 +34,7 @@ type ParameterResolver struct {
|
||||
|
||||
promptRichParameters bool
|
||||
promptEphemeralParameters bool
|
||||
useParameterDefaults bool
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
||||
@@ -86,8 +87,21 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
|
||||
return pr
|
||||
}
|
||||
|
||||
// Resolve gathers workspace build parameters in a layered fashion, applying values from various sources
|
||||
// in order of precedence: parameter file < CLI/ENV < source build < last build < preset < user input.
|
||||
func (pr *ParameterResolver) WithUseParameterDefaults(useParameterDefaults bool) *ParameterResolver {
|
||||
pr.useParameterDefaults = useParameterDefaults
|
||||
return pr
|
||||
}
|
||||
|
||||
// Resolve gathers workspace build parameters in a layered fashion, applying
|
||||
// values from various sources in order of precedence:
|
||||
// 1. template defaults (if auto-accepting defaults)
|
||||
// 2. cli parameter defaults (if auto-accepting defaults)
|
||||
// 3. parameter file
|
||||
// 4. CLI/ENV
|
||||
// 5. source build
|
||||
// 6. last build
|
||||
// 7. preset
|
||||
// 8. user input (unless auto-accepting defaults)
|
||||
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
|
||||
var staged []codersdk.WorkspaceBuildParameter
|
||||
var err error
|
||||
@@ -262,9 +276,25 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
|
||||
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
|
||||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
|
||||
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
|
||||
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
name := tvp.Name
|
||||
if tvp.DisplayName != "" {
|
||||
name = tvp.DisplayName
|
||||
}
|
||||
|
||||
parameterValue := tvp.DefaultValue
|
||||
if v, ok := pr.richParametersDefaults[tvp.Name]; ok {
|
||||
parameterValue = v
|
||||
}
|
||||
|
||||
// Auto-accept the default if there is one.
|
||||
if pr.useParameterDefaults && parameterValue != "" {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %s: '%s'\n", name, parameterValue)
|
||||
} else {
|
||||
var err error
|
||||
parameterValue, err = cliui.RichParameter(inv, tvp, name, parameterValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/mod/semver"
|
||||
@@ -923,6 +924,9 @@ func splitNamedWorkspace(identifier string) (owner string, workspaceName string,
|
||||
// a bare name (for a workspace owned by the current user) or a "user/workspace" combination,
|
||||
// where user is either a username or UUID.
|
||||
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
|
||||
if uid, err := uuid.Parse(identifier); err == nil {
|
||||
return client.Workspace(ctx, uid)
|
||||
}
|
||||
owner, name, err := splitNamedWorkspace(identifier)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, err
|
||||
|
||||
+11
-2
@@ -865,6 +865,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
if err != nil {
|
||||
return xerrors.Errorf("remove secrets from deployment values: %w", err)
|
||||
}
|
||||
|
||||
// Create boundary telemetry collector for tracking boundary feature usage.
|
||||
boundaryTelemetryCollector := telemetry.NewBoundaryTelemetryCollector(
|
||||
options.Database,
|
||||
logger.Named("boundary_telemetry"),
|
||||
)
|
||||
options.BoundaryTelemetryCollector = boundaryTelemetryCollector
|
||||
|
||||
telemetryReporter, err := telemetry.New(telemetry.Options{
|
||||
Disabled: !vals.Telemetry.Enable.Value(),
|
||||
BuiltinPostgres: builtinPostgres,
|
||||
@@ -873,8 +881,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
Experiments: coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()),
|
||||
Logger: logger.Named("telemetry"),
|
||||
URL: vals.Telemetry.URL.Value(),
|
||||
Tunnel: tunnel != nil,
|
||||
DeploymentConfig: deploymentConfigWithoutSecrets,
|
||||
Tunnel: tunnel != nil,
|
||||
DeploymentConfig: deploymentConfigWithoutSecrets,
|
||||
BoundaryTelemetryCollector: boundaryTelemetryCollector,
|
||||
ParseLicenseJWT: func(lic *telemetry.License) error {
|
||||
// This will be nil when running in AGPL-only mode.
|
||||
if options.ParseLicenseClaims == nil {
|
||||
|
||||
+3
-2
@@ -1,8 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -43,11 +45,11 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
|
||||
options := cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
ServerVersion: buildInfo.Version,
|
||||
ShowDetails: details,
|
||||
Title: fmt.Sprintf("%s/%s (%s since %s) %s:%s", workspace.OwnerName, workspace.Name, workspace.LatestBuild.Status, time.Since(workspace.LatestBuild.CreatedAt).Round(time.Second).String(), workspace.TemplateName, workspace.LatestBuild.TemplateVersionName),
|
||||
}
|
||||
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning {
|
||||
// Get listening ports for each agent.
|
||||
@@ -55,7 +57,6 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
options.ListeningPorts = ports
|
||||
options.Devcontainers = devcontainers
|
||||
}
|
||||
|
||||
return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options)
|
||||
},
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestShow(t *testing.T) {
|
||||
@@ -28,7 +30,7 @@ func TestShow(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
args := []string{
|
||||
"show",
|
||||
@@ -38,26 +40,30 @@ func TestShow(t *testing.T) {
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: fmt.Sprintf("%s/%s", workspace.OwnerName, workspace.Name)},
|
||||
{match: fmt.Sprintf("(%s since ", build.Status)},
|
||||
{match: fmt.Sprintf("%s:%s", workspace.TemplateName, workspace.LatestBuild.TemplateVersionName)},
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "coder ssh " + workspace.Name},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.ExpectMatchContext(ctx, m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+255
-6
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,13 +45,18 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information
|
||||
` - Coder deployment version
|
||||
- Coder deployment Configuration (sanitized), including enabled experiments
|
||||
- Coder deployment health snapshot
|
||||
- Coder deployment stats (aggregated workspace/session metrics)
|
||||
- Entitlements (if available)
|
||||
- Health settings (dismissed healthchecks)
|
||||
- Coder deployment Network troubleshooting information
|
||||
- Workspace list accessible to the user (sanitized)
|
||||
- Workspace configuration, parameters, and build logs
|
||||
- Template version and source code for the given workspace
|
||||
- Agent details (with environment variable sanitized)
|
||||
- Agent network diagnostics
|
||||
- Agent logs
|
||||
- License status
|
||||
- pprof profiling data (if --pprof is enabled)
|
||||
` + cliui.Bold("Note: ") +
|
||||
cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") +
|
||||
cliui.Bold("Please confirm that you will:\n") +
|
||||
@@ -61,6 +67,9 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information
|
||||
func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
var outputPath string
|
||||
var coderURLOverride string
|
||||
var workspacesTotalCap64 int64 = 10
|
||||
var templateName string
|
||||
var pprof bool
|
||||
cmd := &serpent.Command{
|
||||
Use: "bundle <workspace> [<agent>]",
|
||||
Short: "Generate a support bundle to troubleshoot issues connecting to a workspace.",
|
||||
@@ -121,8 +130,9 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
wsID uuid.UUID
|
||||
agtID uuid.UUID
|
||||
wsID uuid.UUID
|
||||
agtID uuid.UUID
|
||||
templateID uuid.UUID
|
||||
)
|
||||
|
||||
if len(inv.Args) == 0 {
|
||||
@@ -155,6 +165,16 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve template by name if provided (captures active version)
|
||||
// Fallback: if canonical name lookup fails, match DisplayName (case-insensitive).
|
||||
if templateName != "" {
|
||||
id, err := resolveTemplateID(inv.Context(), client, templateName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templateID = id
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
cwd, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
@@ -176,12 +196,25 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
if r.verbose {
|
||||
clientLog.AppendSinks(sloghuman.Sink(inv.Stderr))
|
||||
}
|
||||
if pprof {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "pprof data collection will take approximately 30 seconds...")
|
||||
}
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
|
||||
deps := support.Deps{
|
||||
Client: client,
|
||||
// Support adds a sink so we don't need to supply one ourselves.
|
||||
Log: clientLog,
|
||||
WorkspaceID: wsID,
|
||||
AgentID: agtID,
|
||||
Log: clientLog,
|
||||
WorkspaceID: wsID,
|
||||
AgentID: agtID,
|
||||
WorkspacesTotalCap: int(workspacesTotalCap64),
|
||||
TemplateID: templateID,
|
||||
CollectPprof: pprof,
|
||||
}
|
||||
|
||||
bun, err := support.Run(inv.Context(), &deps)
|
||||
@@ -217,11 +250,102 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
Description: "Override the URL to your Coder deployment. This may be useful, for example, if you need to troubleshoot a specific Coder replica.",
|
||||
Value: serpent.StringOf(&coderURLOverride),
|
||||
},
|
||||
{
|
||||
Flag: "workspaces-total-cap",
|
||||
Env: "CODER_SUPPORT_BUNDLE_WORKSPACES_TOTAL_CAP",
|
||||
Description: "Maximum number of workspaces to include in the support bundle. Set to 0 or negative value to disable the cap. Defaults to 10.",
|
||||
Value: serpent.Int64Of(&workspacesTotalCap64),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
Env: "CODER_SUPPORT_BUNDLE_TEMPLATE",
|
||||
Description: "Template name to include in the support bundle. Use org_name/template_name if template name is reused across multiple organizations.",
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
{
|
||||
Flag: "pprof",
|
||||
Env: "CODER_SUPPORT_BUNDLE_PPROF",
|
||||
Description: "Collect pprof profiling data from the Coder server and agent. Requires Coder server version 2.28.0 or newer.",
|
||||
Value: serpent.BoolOf(&pprof),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Resolve a template to its ID, supporting:
|
||||
// - org/name form
|
||||
// - slug or display name match (case-insensitive) across all memberships
|
||||
func resolveTemplateID(ctx context.Context, client *codersdk.Client, templateArg string) (uuid.UUID, error) {
|
||||
orgPart := ""
|
||||
namePart := templateArg
|
||||
if slash := strings.IndexByte(templateArg, '/'); slash > 0 && slash < len(templateArg)-1 {
|
||||
orgPart = templateArg[:slash]
|
||||
namePart = templateArg[slash+1:]
|
||||
}
|
||||
|
||||
resolveInOrg := func(orgID uuid.UUID) (codersdk.Template, bool, error) {
|
||||
if t, err := client.TemplateByName(ctx, orgID, namePart); err == nil {
|
||||
return t, true, nil
|
||||
}
|
||||
tpls, err := client.TemplatesByOrganization(ctx, orgID)
|
||||
if err != nil {
|
||||
return codersdk.Template{}, false, nil
|
||||
}
|
||||
for _, t := range tpls {
|
||||
if strings.EqualFold(t.Name, namePart) || strings.EqualFold(t.DisplayName, namePart) {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
return codersdk.Template{}, false, nil
|
||||
}
|
||||
|
||||
if orgPart != "" {
|
||||
org, err := client.OrganizationByName(ctx, orgPart)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get organization %q: %w", orgPart, err)
|
||||
}
|
||||
t, found, err := resolveInOrg(org.ID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if !found {
|
||||
return uuid.Nil, xerrors.Errorf("template %q not found in organization %q", namePart, orgPart)
|
||||
}
|
||||
return t.ID, nil
|
||||
}
|
||||
|
||||
orgs, err := client.OrganizationsByUser(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get organizations: %w", err)
|
||||
}
|
||||
var (
|
||||
foundTpl codersdk.Template
|
||||
foundOrgs []string
|
||||
)
|
||||
for _, org := range orgs {
|
||||
if t, found, err := resolveInOrg(org.ID); err == nil && found {
|
||||
if len(foundOrgs) == 0 {
|
||||
foundTpl = t
|
||||
}
|
||||
foundOrgs = append(foundOrgs, org.Name)
|
||||
}
|
||||
}
|
||||
switch len(foundOrgs) {
|
||||
case 0:
|
||||
return uuid.Nil, xerrors.Errorf("template %q not found in your organizations", namePart)
|
||||
case 1:
|
||||
return foundTpl.ID, nil
|
||||
default:
|
||||
return uuid.Nil, xerrors.Errorf(
|
||||
"template %q found in multiple organizations (%s); use --template \"<org_name/%s>\" to target desired template.",
|
||||
namePart,
|
||||
strings.Join(foundOrgs, ", "),
|
||||
namePart,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeBundle makes a best-effort attempt to write a short summary
|
||||
// of the support bundle to the user's terminal.
|
||||
func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) {
|
||||
@@ -283,6 +407,10 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"deployment/config.json": src.Deployment.Config,
|
||||
"deployment/experiments.json": src.Deployment.Experiments,
|
||||
"deployment/health.json": src.Deployment.HealthReport,
|
||||
"deployment/stats.json": src.Deployment.Stats,
|
||||
"deployment/entitlements.json": src.Deployment.Entitlements,
|
||||
"deployment/health_settings.json": src.Deployment.HealthSettings,
|
||||
"deployment/workspaces.json": src.Deployment.Workspaces,
|
||||
"network/connection_info.json": src.Network.ConnectionInfo,
|
||||
"network/netcheck.json": src.Network.Netcheck,
|
||||
"network/interfaces.json": src.Network.Interfaces,
|
||||
@@ -302,6 +430,49 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Include named template artifacts (if requested)
|
||||
if src.NamedTemplate.Template.ID != uuid.Nil {
|
||||
name := src.NamedTemplate.Template.Name
|
||||
// JSON files
|
||||
for k, v := range map[string]any{
|
||||
"templates/" + name + "/template.json": src.NamedTemplate.Template,
|
||||
"templates/" + name + "/template_version.json": src.NamedTemplate.TemplateVersion,
|
||||
} {
|
||||
f, err := dest.Create(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return xerrors.Errorf("write json to %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
// Binary template file (zip)
|
||||
if namedZipBytes, err := base64.StdEncoding.DecodeString(src.NamedTemplate.TemplateFileBase64); err == nil {
|
||||
k := "templates/" + name + "/template_file.zip"
|
||||
f, err := dest.Create(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
||||
}
|
||||
if _, err := f.Write(namedZipBytes); err != nil {
|
||||
return xerrors.Errorf("write file %q in archive: %w", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buildInfoRef string
|
||||
if src.Deployment.BuildInfo != nil {
|
||||
if raw, err := json.Marshal(src.Deployment.BuildInfo); err == nil {
|
||||
buildInfoRef = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
}
|
||||
|
||||
tailnetHTML := src.Network.TailnetDebug
|
||||
if buildInfoRef != "" {
|
||||
tailnetHTML += "\n<!-- trace " + buildInfoRef + " -->"
|
||||
}
|
||||
|
||||
templateVersionBytes, err := base64.StdEncoding.DecodeString(src.Workspace.TemplateFileBase64)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode template zip from base64")
|
||||
@@ -319,10 +490,11 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"agent/client_magicsock.html": string(src.Agent.ClientMagicsockHTML),
|
||||
"agent/startup_logs.txt": humanizeAgentLogs(src.Agent.StartupLogs),
|
||||
"agent/prometheus.txt": string(src.Agent.Prometheus),
|
||||
"deployment/prometheus.txt": string(src.Deployment.Prometheus),
|
||||
"cli_logs.txt": string(src.CLILogs),
|
||||
"logs.txt": strings.Join(src.Logs, "\n"),
|
||||
"network/coordinator_debug.html": src.Network.CoordinatorDebug,
|
||||
"network/tailnet_debug.html": src.Network.TailnetDebug,
|
||||
"network/tailnet_debug.html": tailnetHTML,
|
||||
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
|
||||
"workspace/template_file.zip": string(templateVersionBytes),
|
||||
"license-status.txt": licenseStatus,
|
||||
@@ -335,12 +507,89 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
return xerrors.Errorf("write file %q in archive: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write pprof binary data
|
||||
if err := writePprofData(src.Pprof, dest); err != nil {
|
||||
return xerrors.Errorf("write pprof data: %w", err)
|
||||
}
|
||||
|
||||
if err := dest.Close(); err != nil {
|
||||
return xerrors.Errorf("close zip file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePprofData(pprof support.Pprof, dest *zip.Writer) error {
|
||||
// Write server pprof data directly to pprof directory
|
||||
if pprof.Server != nil {
|
||||
if err := writePprofCollection("pprof", pprof.Server, dest); err != nil {
|
||||
return xerrors.Errorf("write server pprof data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write agent pprof data
|
||||
if pprof.Agent != nil {
|
||||
if err := writePprofCollection("pprof/agent", pprof.Agent, dest); err != nil {
|
||||
return xerrors.Errorf("write agent pprof data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePprofCollection(basePath string, collection *support.PprofCollection, dest *zip.Writer) error {
|
||||
// Define the pprof files to write with their extensions
|
||||
files := map[string][]byte{
|
||||
"allocs.prof.gz": collection.Allocs,
|
||||
"heap.prof.gz": collection.Heap,
|
||||
"profile.prof.gz": collection.Profile,
|
||||
"block.prof.gz": collection.Block,
|
||||
"mutex.prof.gz": collection.Mutex,
|
||||
"goroutine.prof.gz": collection.Goroutine,
|
||||
"threadcreate.prof.gz": collection.Threadcreate,
|
||||
"trace.gz": collection.Trace,
|
||||
}
|
||||
|
||||
// Write binary pprof files
|
||||
for filename, data := range files {
|
||||
if len(data) > 0 {
|
||||
filePath := basePath + "/" + filename
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create pprof file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return xerrors.Errorf("write pprof file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write cmdline as text file
|
||||
if collection.Cmdline != "" {
|
||||
filePath := basePath + "/cmdline.txt"
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create cmdline file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write([]byte(collection.Cmdline)); err != nil {
|
||||
return xerrors.Errorf("write cmdline file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
if collection.Symbol != "" {
|
||||
filePath := basePath + "/symbol.txt"
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create symbol file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write([]byte(collection.Symbol)); err != nil {
|
||||
return xerrors.Errorf("write symbol file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string {
|
||||
var buf bytes.Buffer
|
||||
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
|
||||
|
||||
@@ -46,6 +46,8 @@ func TestSupportBundle(t *testing.T) {
|
||||
|
||||
// Support bundle tests can share a single coderdtest instance.
|
||||
var dc codersdk.DeploymentConfig
|
||||
dc.Values = coderdtest.DeploymentValues(t)
|
||||
dc.Values.Prometheus.Enable = true
|
||||
secretValue := uuid.NewString()
|
||||
seedSecretDeploymentOptions(t, &dc, secretValue)
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
@@ -203,6 +205,10 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
|
||||
var v codersdk.DeploymentConfig
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "deployment config should not be empty")
|
||||
case "deployment/entitlements.json":
|
||||
var v codersdk.Entitlements
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "entitlements should not be nil")
|
||||
case "deployment/experiments.json":
|
||||
var v codersdk.Experiments
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
@@ -211,6 +217,22 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
|
||||
var v healthsdk.HealthcheckReport
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "health report should not be empty")
|
||||
case "deployment/health_settings.json":
|
||||
var v healthsdk.HealthSettings
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "health settings should not be empty")
|
||||
case "deployment/stats.json":
|
||||
var v codersdk.DeploymentStats
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "deployment stats should not be nil")
|
||||
case "deployment/workspaces.json":
|
||||
var v codersdk.Workspace
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "deployment workspaces should not be nil")
|
||||
case "deployment/prometheus.txt":
|
||||
bs := readBytesFromZip(t, f)
|
||||
require.NotEmpty(t, bs, "prometheus metrics should not be empty")
|
||||
require.Contains(t, string(bs), "go_goroutines", "prometheus metrics should contain go runtime metrics")
|
||||
case "network/connection_info.json":
|
||||
var v workspacesdk.AgentConnectionInfo
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ OPTIONS:
|
||||
configured in the workspace template is used.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+4
-1
@@ -49,8 +49,11 @@ OPTIONS:
|
||||
--template-version string, $CODER_TEMPLATE_VERSION
|
||||
Specify a template version name.
|
||||
|
||||
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
|
||||
Automatically accept parameter defaults when no value is provided.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
resources.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ OPTIONS:
|
||||
empty, will use $HOME.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
Reads stdin for the json role definition to upload.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -23,7 +23,7 @@ OPTIONS:
|
||||
Reads stdin for the json role definition to upload.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ OPTIONS:
|
||||
services it's registered with.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ OPTIONS:
|
||||
pairs for the parameters.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+19
@@ -121,6 +121,10 @@ AI BRIDGE OPTIONS:
|
||||
See
|
||||
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
|
||||
--aibridge-circuit-breaker-enabled bool, $CODER_AIBRIDGE_CIRCUIT_BREAKER_ENABLED (default: false)
|
||||
Enable the circuit breaker to protect against cascading failures from
|
||||
upstream AI provider rate limits (429, 503, 529 overloaded).
|
||||
|
||||
--aibridge-retention duration, $CODER_AIBRIDGE_RETENTION (default: 60d)
|
||||
Length of time to retain data such as interceptions and all related
|
||||
records (token, prompt, tool use).
|
||||
@@ -147,6 +151,10 @@ AI BRIDGE OPTIONS:
|
||||
Maximum number of AI Bridge requests per second per replica. Set to 0
|
||||
to disable (unlimited).
|
||||
|
||||
--aibridge-structured-logging bool, $CODER_AIBRIDGE_STRUCTURED_LOGGING (default: false)
|
||||
Emit structured logs for AI Bridge interception records. Use this for
|
||||
exporting these records to external SIEM or observability systems.
|
||||
|
||||
AI BRIDGE PROXY OPTIONS:
|
||||
--aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE
|
||||
Path to the CA certificate file for AI Bridge Proxy.
|
||||
@@ -161,6 +169,17 @@ AI BRIDGE PROXY OPTIONS:
|
||||
--aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888)
|
||||
The address the AI Bridge Proxy will listen on.
|
||||
|
||||
--aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM
|
||||
URL of an upstream HTTP proxy to chain tunneled (non-allowlisted)
|
||||
requests through. Format: http://[user:pass@]host:port or
|
||||
https://[user:pass@]host:port.
|
||||
|
||||
--aibridge-proxy-upstream-ca string, $CODER_AIBRIDGE_PROXY_UPSTREAM_CA
|
||||
Path to a PEM-encoded CA certificate to trust for the upstream proxy's
|
||||
TLS connection. Only needed for HTTPS upstream proxies with
|
||||
certificates not trusted by the system. If not provided, the system
|
||||
certificate pool is used.
|
||||
|
||||
CLIENT OPTIONS:
|
||||
These options change the behavior of how clients interact with the Coder.
|
||||
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ OPTIONS:
|
||||
pairs for the parameters.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+14
-1
@@ -14,12 +14,25 @@ OPTIONS:
|
||||
File path for writing the generated support bundle. Defaults to
|
||||
coder-support-$(date +%s).zip.
|
||||
|
||||
--pprof bool, $CODER_SUPPORT_BUNDLE_PPROF
|
||||
Collect pprof profiling data from the Coder server and agent. Requires
|
||||
Coder server version 2.28.0 or newer.
|
||||
|
||||
--template string, $CODER_SUPPORT_BUNDLE_TEMPLATE
|
||||
Template name to include in the support bundle. Use
|
||||
org_name/template_name if template name is reused across multiple
|
||||
organizations.
|
||||
|
||||
--url-override string, $CODER_SUPPORT_BUNDLE_URL_OVERRIDE
|
||||
Override the URL to your Coder deployment. This may be useful, for
|
||||
example, if you need to troubleshoot a specific Coder replica.
|
||||
|
||||
--workspaces-total-cap int, $CODER_SUPPORT_BUNDLE_WORKSPACES_TOTAL_CAP
|
||||
Maximum number of workspaces to include in the support bundle. Set to
|
||||
0 or negative value to disable the cap. Defaults to 10.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ OPTIONS:
|
||||
versions are archived.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ OPTIONS:
|
||||
Specify a file path with values for Terraform-managed variables.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ OPTIONS:
|
||||
for more details.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
the template version to pull.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
--zip bool
|
||||
Output the template as a zip archive to stdout.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ OPTIONS:
|
||||
Specify a file path with values for Terraform-managed variables.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
the user may have.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+30
@@ -773,6 +773,27 @@ aibridge:
|
||||
# (unlimited).
|
||||
# (default: 0, type: int)
|
||||
rateLimit: 0
|
||||
# Emit structured logs for AI Bridge interception records. Use this for exporting
|
||||
# these records to external SIEM or observability systems.
|
||||
# (default: false, type: bool)
|
||||
structuredLogging: false
|
||||
# Enable the circuit breaker to protect against cascading failures from upstream
|
||||
# AI provider rate limits (429, 503, 529 overloaded).
|
||||
# (default: false, type: bool)
|
||||
circuitBreakerEnabled: false
|
||||
# Number of consecutive failures that triggers the circuit breaker to open.
|
||||
# (default: 5, type: int)
|
||||
circuitBreakerFailureThreshold: 5
|
||||
# Cyclic period of the closed state for clearing internal failure counts.
|
||||
# (default: 10s, type: duration)
|
||||
circuitBreakerInterval: 10s
|
||||
# How long the circuit breaker stays open before transitioning to half-open state.
|
||||
# (default: 30s, type: duration)
|
||||
circuitBreakerTimeout: 30s
|
||||
# Maximum number of requests allowed in half-open state before deciding to close
|
||||
# or re-open the circuit.
|
||||
# (default: 3, type: int)
|
||||
circuitBreakerMaxRequests: 3
|
||||
aibridgeproxy:
|
||||
# Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider
|
||||
# requests.
|
||||
@@ -794,6 +815,15 @@ aibridgeproxy:
|
||||
domain_allowlist:
|
||||
- api.anthropic.com
|
||||
- api.openai.com
|
||||
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
|
||||
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
|
||||
# (default: <unset>, type: string)
|
||||
upstream_proxy: ""
|
||||
# Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS
|
||||
# connection. Only needed for HTTPS upstream proxies with certificates not trusted
|
||||
# by the system. If not provided, the system certificate pool is used.
|
||||
# (default: <unset>, type: string)
|
||||
upstream_proxy_ca: ""
|
||||
# Configure data retention policies for various database tables. Retention
|
||||
# policies automatically purge old data to reduce database size and improve
|
||||
# performance. Setting a retention duration to 0 disables automatic purging for
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
@@ -68,6 +69,7 @@ type Options struct {
|
||||
AgentID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
|
||||
AuthenticatedCtx context.Context
|
||||
@@ -84,6 +86,7 @@ type Options struct {
|
||||
PublishWorkspaceUpdateFn func(ctx context.Context, userID uuid.UUID, event wspubsub.WorkspaceEvent)
|
||||
PublishWorkspaceAgentLogsUpdateFn func(ctx context.Context, workspaceAgentID uuid.UUID, msg agentsdk.LogsNotifyMessage)
|
||||
NetworkTelemetryHandler func(batch []*tailnetproto.TelemetryEvent)
|
||||
BoundaryTelemetryCollector *telemetry.BoundaryTelemetryCollector
|
||||
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
@@ -221,8 +224,11 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
}
|
||||
|
||||
api.BoundaryLogsAPI = &BoundaryLogsAPI{
|
||||
Log: opts.Log,
|
||||
WorkspaceID: opts.WorkspaceID,
|
||||
Log: opts.Log,
|
||||
OwnerID: opts.OwnerID,
|
||||
WorkspaceID: opts.WorkspaceID,
|
||||
TemplateID: opts.TemplateID,
|
||||
BoundaryTelemetryCollector: opts.BoundaryTelemetryCollector,
|
||||
}
|
||||
|
||||
// Start background cache refresh loop to handle workspace changes
|
||||
|
||||
@@ -8,14 +8,23 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
)
|
||||
|
||||
type BoundaryLogsAPI struct {
|
||||
Log slog.Logger
|
||||
WorkspaceID uuid.UUID
|
||||
Log slog.Logger
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
BoundaryTelemetryCollector *telemetry.BoundaryTelemetryCollector
|
||||
}
|
||||
|
||||
func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) {
|
||||
// Record boundary usage for telemetry if we have any logs.
|
||||
if len(req.Logs) > 0 && a.BoundaryTelemetryCollector != nil {
|
||||
a.BoundaryTelemetryCollector.RecordBoundaryUsage(a.OwnerID, a.WorkspaceID, a.TemplateID)
|
||||
}
|
||||
|
||||
for _, l := range req.Logs {
|
||||
var logTime time.Time
|
||||
if l.Time != nil {
|
||||
|
||||
Generated
+187
-20
@@ -2628,7 +2628,8 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"code"
|
||||
"code",
|
||||
"token"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Response type",
|
||||
@@ -2683,7 +2684,8 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"code"
|
||||
"code",
|
||||
"token"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Response type",
|
||||
@@ -2914,7 +2916,10 @@ const docTemplate = `{
|
||||
{
|
||||
"enum": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials",
|
||||
"implicit"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Grant type",
|
||||
@@ -4566,6 +4571,86 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/settings/workspace-sharing": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get workspace sharing settings for organization",
|
||||
"operationId": "get-workspace-sharing-settings-for-organization",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Update workspace sharing settings for organization",
|
||||
"operationId": "update-workspace-sharing-settings-for-organization",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Workspace sharing settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -11953,6 +12038,22 @@ const docTemplate = `{
|
||||
"bedrock": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeBedrockConfig"
|
||||
},
|
||||
"circuit_breaker_enabled": {
|
||||
"description": "Circuit breaker protects against cascading failures from upstream AI\nprovider rate limits (429, 503, 529 overloaded).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"circuit_breaker_failure_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_max_requests": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_timeout": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -11970,6 +12071,9 @@ const docTemplate = `{
|
||||
},
|
||||
"retention": {
|
||||
"type": "integer"
|
||||
},
|
||||
"structured_logging": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12069,6 +12173,12 @@ const docTemplate = `{
|
||||
},
|
||||
"listen_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstream_proxy": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstream_proxy_ca": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15761,13 +15871,13 @@ const docTemplate = `{
|
||||
"code_challenge_methods_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
|
||||
}
|
||||
},
|
||||
"grant_types_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"issuer": {
|
||||
@@ -15779,7 +15889,7 @@ const docTemplate = `{
|
||||
"response_types_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"revocation_endpoint": {
|
||||
@@ -15797,7 +15907,7 @@ const docTemplate = `{
|
||||
"token_endpoint_auth_methods_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15829,7 +15939,7 @@ const docTemplate = `{
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -15851,10 +15961,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"registration_access_token": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"registration_client_uri": {
|
||||
"type": "string"
|
||||
@@ -15862,7 +15969,7 @@ const docTemplate = `{
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -15875,7 +15982,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -15900,7 +16007,7 @@ const docTemplate = `{
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -15924,7 +16031,7 @@ const docTemplate = `{
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -15940,7 +16047,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -15977,7 +16084,7 @@ const docTemplate = `{
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -16007,7 +16114,7 @@ const docTemplate = `{
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -16020,7 +16127,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -16073,6 +16180,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OAuth2PKCECodeChallengeMethod": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"S256",
|
||||
"plain"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2PKCECodeChallengeMethodS256",
|
||||
"OAuth2PKCECodeChallengeMethodPlain"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2ProtectedResourceMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16152,6 +16270,47 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OAuth2ProviderGrantType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials",
|
||||
"implicit"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2ProviderGrantTypeAuthorizationCode",
|
||||
"OAuth2ProviderGrantTypeRefreshToken",
|
||||
"OAuth2ProviderGrantTypePassword",
|
||||
"OAuth2ProviderGrantTypeClientCredentials",
|
||||
"OAuth2ProviderGrantTypeImplicit"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2ProviderResponseType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"code",
|
||||
"token"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2ProviderResponseTypeCode",
|
||||
"OAuth2ProviderResponseTypeToken"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2TokenEndpointAuthMethod": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"none"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
|
||||
"OAuth2TokenEndpointAuthMethodClientSecretPost",
|
||||
"OAuth2TokenEndpointAuthMethodNone"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuthConversionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21451,6 +21610,14 @@ const docTemplate = `{
|
||||
"WorkspaceRoleDeleted"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sharing_disabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
Generated
+168
-20
@@ -2304,7 +2304,7 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"enum": ["code"],
|
||||
"enum": ["code", "token"],
|
||||
"type": "string",
|
||||
"description": "Response type",
|
||||
"name": "response_type",
|
||||
@@ -2355,7 +2355,7 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"enum": ["code"],
|
||||
"enum": ["code", "token"],
|
||||
"type": "string",
|
||||
"description": "Response type",
|
||||
"name": "response_type",
|
||||
@@ -2555,7 +2555,13 @@
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"enum": ["authorization_code", "refresh_token"],
|
||||
"enum": [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials",
|
||||
"implicit"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Grant type",
|
||||
"name": "grant_type",
|
||||
@@ -4036,6 +4042,76 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/settings/workspace-sharing": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get workspace sharing settings for organization",
|
||||
"operationId": "get-workspace-sharing-settings-for-organization",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Update workspace sharing settings for organization",
|
||||
"operationId": "update-workspace-sharing-settings-for-organization",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Workspace sharing settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10614,6 +10690,22 @@
|
||||
"bedrock": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeBedrockConfig"
|
||||
},
|
||||
"circuit_breaker_enabled": {
|
||||
"description": "Circuit breaker protects against cascading failures from upstream AI\nprovider rate limits (429, 503, 529 overloaded).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"circuit_breaker_failure_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_max_requests": {
|
||||
"type": "integer"
|
||||
},
|
||||
"circuit_breaker_timeout": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -10631,6 +10723,9 @@
|
||||
},
|
||||
"retention": {
|
||||
"type": "integer"
|
||||
},
|
||||
"structured_logging": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10730,6 +10825,12 @@
|
||||
},
|
||||
"listen_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstream_proxy": {
|
||||
"type": "string"
|
||||
},
|
||||
"upstream_proxy_ca": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -14280,13 +14381,13 @@
|
||||
"code_challenge_methods_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
|
||||
}
|
||||
},
|
||||
"grant_types_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"issuer": {
|
||||
@@ -14298,7 +14399,7 @@
|
||||
"response_types_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"revocation_endpoint": {
|
||||
@@ -14316,7 +14417,7 @@
|
||||
"token_endpoint_auth_methods_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14348,7 +14449,7 @@
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -14370,10 +14471,7 @@
|
||||
}
|
||||
},
|
||||
"registration_access_token": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"registration_client_uri": {
|
||||
"type": "string"
|
||||
@@ -14381,7 +14479,7 @@
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -14394,7 +14492,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -14419,7 +14517,7 @@
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -14443,7 +14541,7 @@
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -14459,7 +14557,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -14496,7 +14594,7 @@
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
|
||||
}
|
||||
},
|
||||
"jwks": {
|
||||
@@ -14526,7 +14624,7 @@
|
||||
"response_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
@@ -14539,7 +14637,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"token_endpoint_auth_method": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string"
|
||||
@@ -14592,6 +14690,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OAuth2PKCECodeChallengeMethod": {
|
||||
"type": "string",
|
||||
"enum": ["S256", "plain"],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2PKCECodeChallengeMethodS256",
|
||||
"OAuth2PKCECodeChallengeMethodPlain"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2ProtectedResourceMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14671,6 +14777,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OAuth2ProviderGrantType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials",
|
||||
"implicit"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2ProviderGrantTypeAuthorizationCode",
|
||||
"OAuth2ProviderGrantTypeRefreshToken",
|
||||
"OAuth2ProviderGrantTypePassword",
|
||||
"OAuth2ProviderGrantTypeClientCredentials",
|
||||
"OAuth2ProviderGrantTypeImplicit"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2ProviderResponseType": {
|
||||
"type": "string",
|
||||
"enum": ["code", "token"],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2ProviderResponseTypeCode",
|
||||
"OAuth2ProviderResponseTypeToken"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuth2TokenEndpointAuthMethod": {
|
||||
"type": "string",
|
||||
"enum": ["client_secret_basic", "client_secret_post", "none"],
|
||||
"x-enum-varnames": [
|
||||
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
|
||||
"OAuth2TokenEndpointAuthMethodClientSecretPost",
|
||||
"OAuth2TokenEndpointAuthMethodNone"
|
||||
]
|
||||
},
|
||||
"codersdk.OAuthConversionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19730,6 +19870,14 @@
|
||||
"WorkspaceRoleDeleted"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sharing_disabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
+11
-7
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -196,6 +197,7 @@ type Options struct {
|
||||
DERPMapUpdateFrequency time.Duration
|
||||
NetworkTelemetryBatchFrequency time.Duration
|
||||
NetworkTelemetryBatchMaxSize int
|
||||
BoundaryTelemetryCollector *telemetry.BoundaryTelemetryCollector
|
||||
SwaggerEndpoint bool
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
||||
@@ -205,7 +207,7 @@ type Options struct {
|
||||
// tokens issued by and passed to the coordinator DRPC API.
|
||||
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
|
||||
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport
|
||||
HealthcheckTimeout time.Duration
|
||||
HealthcheckRefresh time.Duration
|
||||
WorkspaceProxiesFetchUpdater *atomic.Pointer[healthcheck.WorkspaceProxiesFetchUpdater]
|
||||
@@ -334,6 +336,7 @@ func New(options *Options) *API {
|
||||
|
||||
if options.PrometheusRegistry == nil {
|
||||
options.PrometheusRegistry = prometheus.NewRegistry()
|
||||
options.PrometheusRegistry.MustRegister(collectors.NewGoCollector())
|
||||
}
|
||||
if options.Authorizer == nil {
|
||||
options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
|
||||
@@ -681,7 +684,7 @@ func New(options *Options) *API {
|
||||
}
|
||||
|
||||
if options.HealthcheckFunc == nil {
|
||||
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport {
|
||||
options.HealthcheckFunc = func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
// NOTE: dismissed healthchecks are marked in formatHealthcheck.
|
||||
// Not here, as this result gets cached.
|
||||
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
|
||||
@@ -709,6 +712,7 @@ func New(options *Options) *API {
|
||||
StaleInterval: provisionerdserver.StaleInterval,
|
||||
// TimeNow set to default, see healthcheck/provisioner.go
|
||||
},
|
||||
Progress: progress,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -881,6 +885,7 @@ func New(options *Options) *API {
|
||||
loggermw.Logger(api.Logger),
|
||||
singleSlashMW,
|
||||
rolestore.CustomRoleMW,
|
||||
httpmw.HTTPRoute, // NB: prometheusMW depends on this middleware.
|
||||
prometheusMW,
|
||||
// Build-Version is helpful for debugging.
|
||||
func(next http.Handler) http.Handler {
|
||||
@@ -1434,9 +1439,7 @@ func New(options *Options) *API {
|
||||
Optional: true,
|
||||
}),
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
|
||||
|
||||
httpmw.ExtractWorkspaceAgentParam(options.Database),
|
||||
httpmw.ExtractWorkspaceParam(options.Database),
|
||||
httpmw.ExtractWorkspaceAgentAndWorkspaceParam(options.Database),
|
||||
)
|
||||
r.Get("/", api.workspaceAgent)
|
||||
r.Get("/watch-metadata", api.watchWorkspaceAgentMetadataSSE)
|
||||
@@ -1859,8 +1862,9 @@ type API struct {
|
||||
// This is used to gate features that are not yet ready for production.
|
||||
Experiments codersdk.Experiments
|
||||
|
||||
healthCheckGroup *singleflight.Group[string, *healthsdk.HealthcheckReport]
|
||||
healthCheckCache atomic.Pointer[healthsdk.HealthcheckReport]
|
||||
healthCheckGroup *singleflight.Group[string, *healthsdk.HealthcheckReport]
|
||||
healthCheckCache atomic.Pointer[healthsdk.HealthcheckReport]
|
||||
healthCheckProgress healthcheck.Progress
|
||||
|
||||
statsReporter *workspacestats.Reporter
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/coderd/gitsshkey"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jobreaper"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
@@ -131,7 +132,7 @@ type Options struct {
|
||||
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
|
||||
ConnectionLogger connectionlog.ConnectionLogger
|
||||
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport
|
||||
HealthcheckTimeout time.Duration
|
||||
HealthcheckRefresh time.Duration
|
||||
|
||||
|
||||
@@ -569,7 +569,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa
|
||||
}.String()
|
||||
}
|
||||
|
||||
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp {
|
||||
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.WorkspaceTable) []codersdk.WorkspaceApp {
|
||||
sort.Slice(dbApps, func(i, j int) bool {
|
||||
if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder {
|
||||
return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder
|
||||
|
||||
@@ -1663,6 +1663,20 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u
|
||||
return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteBoundaryActiveUsersBefore(ctx context.Context, recordedAt time.Time) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteBoundaryActiveUsersBefore(ctx, recordedAt)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteBoundaryActiveWorkspacesBefore(ctx context.Context, recordedAt time.Time) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteBoundaryActiveWorkspacesBefore(ctx, recordedAt)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return err
|
||||
@@ -1965,6 +1979,14 @@ func (q *querier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) erro
|
||||
return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.DeleteWorkspaceACLByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
// This is a system-only function.
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteWorkspaceACLsByOrganization(ctx, organizationID)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
|
||||
if err != nil {
|
||||
@@ -2243,6 +2265,20 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
|
||||
return q.db.GetAuthorizationUserRoles(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetBoundaryActiveUsersSince(ctx context.Context, recordedAt time.Time) ([]uuid.UUID, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetBoundaryActiveUsersSince(ctx, recordedAt)
|
||||
}
|
||||
|
||||
func (q *querier) GetBoundaryActiveWorkspacesSince(ctx context.Context, recordedAt time.Time) ([]database.GetBoundaryActiveWorkspacesSinceRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetBoundaryActiveWorkspacesSince(ctx, recordedAt)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
// Just like with the audit logs query, shortcut if the user is an owner.
|
||||
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
|
||||
@@ -3592,7 +3628,7 @@ func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (databa
|
||||
if err != nil {
|
||||
return database.GetWorkspaceACLByIDRow{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionShare, workspace); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, workspace); err != nil {
|
||||
return database.GetWorkspaceACLByIDRow{}, err
|
||||
}
|
||||
return q.db.GetWorkspaceACLByID(ctx, id)
|
||||
@@ -3606,6 +3642,10 @@ func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context
|
||||
return q.db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceAgentAndWorkspaceByIDRow, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetWorkspaceAgentAndWorkspaceByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) {
|
||||
// Fast path: Check if we have a workspace RBAC object in context.
|
||||
// In the agent API this is set at agent connection time to avoid the expensive
|
||||
@@ -4141,6 +4181,20 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertBoundaryActiveUser(ctx context.Context, arg database.InsertBoundaryActiveUserParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.InsertBoundaryActiveUser(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertBoundaryActiveWorkspace(ctx context.Context, arg database.InsertBoundaryActiveWorkspaceParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.InsertBoundaryActiveWorkspace(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil {
|
||||
return database.CryptoKey{}, err
|
||||
@@ -5099,6 +5153,13 @@ func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg databas
|
||||
return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
|
||||
fetch := func(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
|
||||
return q.db.GetOrganizationByID(ctx, arg.ID)
|
||||
}
|
||||
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganizationWorkspaceSharingSettings)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
|
||||
// Prebuild operation for canceling pending prebuild jobs from non-active template versions
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourcePrebuiltWorkspace); err != nil {
|
||||
|
||||
@@ -880,6 +880,16 @@ func (s *MethodTestSuite) TestOrganization() {
|
||||
dbm.EXPECT().InsertOrganization(gomock.Any(), arg).Return(database.Organization{ID: arg.ID, Name: arg.Name}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceOrganization, policy.ActionCreate)
|
||||
}))
|
||||
s.Run("UpdateOrganizationWorkspaceSharingSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
org := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
arg := database.UpdateOrganizationWorkspaceSharingSettingsParams{
|
||||
ID: org.ID,
|
||||
WorkspaceSharingDisabled: true,
|
||||
}
|
||||
dbm.EXPECT().GetOrganizationByID(gomock.Any(), org.ID).Return(org, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateOrganizationWorkspaceSharingSettings(gomock.Any(), arg).Return(org, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(org, policy.ActionUpdate).Returns(org)
|
||||
}))
|
||||
s.Run("InsertOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
o := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
@@ -1784,7 +1794,7 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
ws := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
|
||||
dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes()
|
||||
check.Args(ws.ID).Asserts(ws, policy.ActionShare)
|
||||
check.Args(ws.ID).Asserts(ws, policy.ActionRead)
|
||||
}))
|
||||
s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
@@ -1799,6 +1809,11 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
dbm.EXPECT().DeleteWorkspaceACLByID(gomock.Any(), w.ID).Return(nil).AnyTimes()
|
||||
check.Args(w.ID).Asserts(w, policy.ActionShare)
|
||||
}))
|
||||
s.Run("DeleteWorkspaceACLsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
orgID := uuid.New()
|
||||
dbm.EXPECT().DeleteWorkspaceACLsByOrganization(gomock.Any(), orgID).Return(nil).AnyTimes()
|
||||
check.Args(orgID).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID})
|
||||
@@ -1813,6 +1828,11 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes()
|
||||
check.Args(agt.ID).Asserts(w, policy.ActionRead).Returns(agt)
|
||||
}))
|
||||
s.Run("GetWorkspaceAgentAndWorkspaceByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{})
|
||||
dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes()
|
||||
check.Args(aww.WorkspaceAgent.ID).Asserts(aww.WorkspaceTable, policy.ActionRead).Returns(aww)
|
||||
}))
|
||||
s.Run("GetWorkspaceAgentsByWorkspaceAndBuildNumber", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1055,6 +1055,20 @@ func (mr *MockStoreMockRecorder) DeleteWorkspaceACLByID(ctx, id any) *gomock.Cal
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLByID), ctx, id)
|
||||
}
|
||||
|
||||
// DeleteWorkspaceACLsByOrganization mocks base method.
|
||||
func (m *MockStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteWorkspaceACLsByOrganization", ctx, organizationID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteWorkspaceACLsByOrganization indicates an expected call of DeleteWorkspaceACLsByOrganization.
|
||||
func (mr *MockStoreMockRecorder) DeleteWorkspaceACLsByOrganization(ctx, organizationID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLsByOrganization", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLsByOrganization), ctx, organizationID)
|
||||
}
|
||||
|
||||
// DeleteWorkspaceAgentPortShare mocks base method.
|
||||
func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4123,6 +4137,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx,
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentAndLatestBuildByAuthToken", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentAndLatestBuildByAuthToken), ctx, authToken)
|
||||
}
|
||||
|
||||
// GetWorkspaceAgentAndWorkspaceByID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceAgentAndWorkspaceByIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWorkspaceAgentAndWorkspaceByID", ctx, id)
|
||||
ret0, _ := ret[0].(database.GetWorkspaceAgentAndWorkspaceByIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWorkspaceAgentAndWorkspaceByID indicates an expected call of GetWorkspaceAgentAndWorkspaceByID.
|
||||
func (mr *MockStoreMockRecorder) GetWorkspaceAgentAndWorkspaceByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentAndWorkspaceByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentAndWorkspaceByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetWorkspaceAgentByID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6645,6 +6674,21 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateOrganizationWorkspaceSharingSettings mocks base method.
|
||||
func (m *MockStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateOrganizationWorkspaceSharingSettings", ctx, arg)
|
||||
ret0, _ := ret[0].(database.Organization)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateOrganizationWorkspaceSharingSettings indicates an expected call of UpdateOrganizationWorkspaceSharingSettings.
|
||||
func (mr *MockStoreMockRecorder) UpdateOrganizationWorkspaceSharingSettings(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationWorkspaceSharingSettings", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationWorkspaceSharingSettings), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdatePrebuildProvisionerJobWithCancel mocks base method.
|
||||
func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -34,6 +34,9 @@ const (
|
||||
// long enough to cover the maximum interval of a heartbeat event (currently
|
||||
// 1 hour) plus some buffer.
|
||||
maxTelemetryHeartbeatAge = 24 * time.Hour
|
||||
// Boundary telemetry data only needs to persist for 24 hours since snapshots
|
||||
// are taken every 30 minutes.
|
||||
maxBoundaryTelemetryAge = 24 * time.Hour
|
||||
)
|
||||
|
||||
// New creates a new periodically purging database instance.
|
||||
@@ -127,6 +130,14 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
|
||||
return xerrors.Errorf("failed to delete old telemetry locks: %w", err)
|
||||
}
|
||||
|
||||
deleteOldBoundaryTelemetryBefore := start.Add(-maxBoundaryTelemetryAge)
|
||||
if err := tx.DeleteBoundaryActiveUsersBefore(ctx, deleteOldBoundaryTelemetryBefore); err != nil {
|
||||
return xerrors.Errorf("failed to delete old boundary active users: %w", err)
|
||||
}
|
||||
if err := tx.DeleteBoundaryActiveWorkspacesBefore(ctx, deleteOldBoundaryTelemetryBefore); err != nil {
|
||||
return xerrors.Errorf("failed to delete old boundary active workspaces: %w", err)
|
||||
}
|
||||
|
||||
deleteOldAuditLogConnectionEventsBefore := start.Add(-maxAuditLogConnectionEventAge)
|
||||
if err := tx.DeleteOldAuditLogConnectionEvents(ctx, database.DeleteOldAuditLogConnectionEventsParams{
|
||||
BeforeTime: deleteOldAuditLogConnectionEventsBefore,
|
||||
|
||||
Generated
+27
@@ -1182,6 +1182,19 @@ CREATE TABLE audit_logs (
|
||||
resource_icon text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE boundary_active_users (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
recorded_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE boundary_active_workspaces (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
template_id uuid NOT NULL,
|
||||
recorded_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE connection_logs (
|
||||
id uuid NOT NULL,
|
||||
connect_time timestamp with time zone NOT NULL,
|
||||
@@ -3019,6 +3032,12 @@ ALTER TABLE ONLY api_keys
|
||||
ALTER TABLE ONLY audit_logs
|
||||
ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY boundary_active_users
|
||||
ADD CONSTRAINT boundary_active_users_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY boundary_active_workspaces
|
||||
ADD CONSTRAINT boundary_active_workspaces_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY connection_logs
|
||||
ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -3350,6 +3369,14 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id);
|
||||
|
||||
CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC);
|
||||
|
||||
CREATE INDEX idx_boundary_active_users_recorded_at ON boundary_active_users USING btree (recorded_at);
|
||||
|
||||
CREATE INDEX idx_boundary_active_users_user_id ON boundary_active_users USING btree (user_id);
|
||||
|
||||
CREATE INDEX idx_boundary_active_workspaces_recorded_at ON boundary_active_workspaces USING btree (recorded_at);
|
||||
|
||||
CREATE INDEX idx_boundary_active_workspaces_workspace_id ON boundary_active_workspaces USING btree (workspace_id);
|
||||
|
||||
CREATE INDEX idx_connection_logs_connect_time_desc ON connection_logs USING btree (connect_time DESC);
|
||||
|
||||
CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS boundary_active_workspaces;
|
||||
DROP TABLE IF EXISTS boundary_active_users;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Tables to track boundary feature usage for telemetry reporting.
|
||||
-- Data is collected from boundary log streams and inserted periodically by each replica.
|
||||
|
||||
CREATE TABLE boundary_active_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_boundary_active_users_user_id ON boundary_active_users(user_id);
|
||||
CREATE INDEX idx_boundary_active_users_recorded_at ON boundary_active_users(recorded_at);
|
||||
|
||||
CREATE TABLE boundary_active_workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
template_id UUID NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_boundary_active_workspaces_workspace_id ON boundary_active_workspaces(workspace_id);
|
||||
CREATE INDEX idx_boundary_active_workspaces_recorded_at ON boundary_active_workspaces(recorded_at);
|
||||
+685
-685
File diff suppressed because one or more lines are too long
@@ -1,34 +1,34 @@
|
||||
-- This is a deleted user that shares the same username and linked_id as the existing user below.
|
||||
-- Any future migrations need to handle this case.
|
||||
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'githubuser@coder.com', 'githubuser', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', true) ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.organization_members VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
INSERT INTO organization_members VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
VALUES('a0061a8e-7db7-4585-838c-3116a003dd21', 'github', '100', '');
|
||||
|
||||
|
||||
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'githubuser@coder.com', 'githubuser', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.organization_members VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
INSERT INTO organization_members VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
VALUES('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'github', '100', '');
|
||||
|
||||
-- Additionally, there is no unique constraint on user_id. So also add another user_link for the same user.
|
||||
-- This has happened on a production database.
|
||||
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
VALUES('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'oidc', 'foo', '');
|
||||
|
||||
|
||||
-- Lastly, make 2 other users who have the same user link.
|
||||
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'dup_link_a@coder.com', 'dupe_a', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.organization_members VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
INSERT INTO organization_members VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
VALUES('580ed397-727d-4aaf-950a-51f89f556c24', 'github', '500', '');
|
||||
|
||||
|
||||
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
|
||||
VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'dup_link_b@coder.com', 'dupe_b', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.organization_members VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
INSERT INTO organization_members VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
|
||||
VALUES('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'github', '500', '');
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
INSERT INTO public.workspace_app_stats (
|
||||
INSERT INTO workspace_app_stats (
|
||||
id,
|
||||
user_id,
|
||||
workspace_id,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
INSERT INTO
|
||||
public.workspace_modules (
|
||||
workspace_modules (
|
||||
id,
|
||||
job_id,
|
||||
transition,
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
INSERT INTO public.organizations (id, name, description, created_at, updated_at, is_default, display_name, icon) VALUES ('20362772-802a-4a72-8e4f-3648b4bfd168', 'strange_hopper58', 'wizardly_stonebraker60', '2025-02-07 07:46:19.507551 +00:00', '2025-02-07 07:46:19.507552 +00:00', false, 'competent_rhodes59', '');
|
||||
INSERT INTO organizations (id, name, description, created_at, updated_at, is_default, display_name, icon) VALUES ('20362772-802a-4a72-8e4f-3648b4bfd168', 'strange_hopper58', 'wizardly_stonebraker60', '2025-02-07 07:46:19.507551 +00:00', '2025-02-07 07:46:19.507552 +00:00', false, 'competent_rhodes59', '');
|
||||
|
||||
INSERT INTO public.users (id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at) VALUES ('6c353aac-20de-467b-bdfb-3c30a37adcd2', 'vigorous_murdock61', 'affectionate_hawking62', 'lqTu9C5363AwD7NVNH6noaGjp91XIuZJ', '2025-02-07 07:46:19.510861 +00:00', '2025-02-07 07:46:19.512949 +00:00', 'active', '{}', 'password', '', false, '0001-01-01 00:00:00.000000', '', '', 'vigilant_hugle63', null, null, null);
|
||||
INSERT INTO users (id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at) VALUES ('6c353aac-20de-467b-bdfb-3c30a37adcd2', 'vigorous_murdock61', 'affectionate_hawking62', 'lqTu9C5363AwD7NVNH6noaGjp91XIuZJ', '2025-02-07 07:46:19.510861 +00:00', '2025-02-07 07:46:19.512949 +00:00', 'active', '{}', 'password', '', false, '0001-01-01 00:00:00.000000', '', '', 'vigilant_hugle63', null, null, null);
|
||||
|
||||
INSERT INTO public.templates (id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level) VALUES ('6b298946-7a4f-47ac-9158-b03b08740a41', '2025-02-07 07:46:19.513317 +00:00', '2025-02-07 07:46:19.513317 +00:00', '20362772-802a-4a72-8e4f-3648b4bfd168', false, 'modest_leakey64', 'echo', 'e6cfa2a4-e4cf-4182-9e19-08b975682a28', 'upbeat_wright65', 604800000000000, '6c353aac-20de-467b-bdfb-3c30a37adcd2', 'nervous_keller66', '{}', '{"20362772-802a-4a72-8e4f-3648b4bfd168": ["read", "use"]}', 'determined_aryabhata67', false, true, true, 0, 0, 0, 0, 0, 0, false, '', 3600000000000, 'owner');
|
||||
INSERT INTO public.template_versions (id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id) VALUES ('af58bd62-428c-4c33-849b-d43a3be07d93', '6b298946-7a4f-47ac-9158-b03b08740a41', '20362772-802a-4a72-8e4f-3648b4bfd168', '2025-02-07 07:46:19.514782 +00:00', '2025-02-07 07:46:19.514782 +00:00', 'distracted_shockley68', 'sleepy_turing69', 'f2e2ea1c-5aa3-4a1d-8778-2e5071efae59', '6c353aac-20de-467b-bdfb-3c30a37adcd2', '[]', '', false, null);
|
||||
INSERT INTO templates (id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level) VALUES ('6b298946-7a4f-47ac-9158-b03b08740a41', '2025-02-07 07:46:19.513317 +00:00', '2025-02-07 07:46:19.513317 +00:00', '20362772-802a-4a72-8e4f-3648b4bfd168', false, 'modest_leakey64', 'echo', 'e6cfa2a4-e4cf-4182-9e19-08b975682a28', 'upbeat_wright65', 604800000000000, '6c353aac-20de-467b-bdfb-3c30a37adcd2', 'nervous_keller66', '{}', '{"20362772-802a-4a72-8e4f-3648b4bfd168": ["read", "use"]}', 'determined_aryabhata67', false, true, true, 0, 0, 0, 0, 0, 0, false, '', 3600000000000, 'owner');
|
||||
INSERT INTO template_versions (id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id) VALUES ('af58bd62-428c-4c33-849b-d43a3be07d93', '6b298946-7a4f-47ac-9158-b03b08740a41', '20362772-802a-4a72-8e4f-3648b4bfd168', '2025-02-07 07:46:19.514782 +00:00', '2025-02-07 07:46:19.514782 +00:00', 'distracted_shockley68', 'sleepy_turing69', 'f2e2ea1c-5aa3-4a1d-8778-2e5071efae59', '6c353aac-20de-467b-bdfb-3c30a37adcd2', '[]', '', false, null);
|
||||
|
||||
INSERT INTO public.template_version_presets (id, template_version_id, name, created_at) VALUES ('28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'af58bd62-428c-4c33-849b-d43a3be07d93', 'test', '0001-01-01 00:00:00.000000 +00:00');
|
||||
INSERT INTO template_version_presets (id, template_version_id, name, created_at) VALUES ('28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'af58bd62-428c-4c33-849b-d43a3be07d93', 'test', '0001-01-01 00:00:00.000000 +00:00');
|
||||
|
||||
-- Add presets with the same template version ID and name
|
||||
-- to ensure they're correctly handled by the 00031*_preset_prebuilds migration.
|
||||
INSERT INTO public.template_version_presets (
|
||||
INSERT INTO template_version_presets (
|
||||
id, template_version_id, name, created_at
|
||||
)
|
||||
VALUES (
|
||||
@@ -19,7 +19,7 @@ VALUES (
|
||||
'0001-01-01 00:00:00.000000 +00:00'
|
||||
);
|
||||
|
||||
INSERT INTO public.template_version_presets (
|
||||
INSERT INTO template_version_presets (
|
||||
id, template_version_id, name, created_at
|
||||
)
|
||||
VALUES (
|
||||
@@ -29,4 +29,4 @@ VALUES (
|
||||
'0001-01-01 00:00:00.000000 +00:00'
|
||||
);
|
||||
|
||||
INSERT INTO public.template_version_preset_parameters (id, template_version_preset_id, name, value) VALUES ('ea90ccd2-5024-459e-87e4-879afd24de0f', '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'test', 'test');
|
||||
INSERT INTO template_version_preset_parameters (id, template_version_preset_id, name, value) VALUES ('ea90ccd2-5024-459e-87e4-879afd24de0f', '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'test', 'test');
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
INSERT INTO public.tasks VALUES (
|
||||
INSERT INTO tasks VALUES (
|
||||
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- id
|
||||
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', -- organization_id
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307', -- owner_id
|
||||
@@ -11,7 +11,7 @@ INSERT INTO public.tasks VALUES (
|
||||
NULL -- deleted_at
|
||||
) ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.task_workspace_apps VALUES (
|
||||
INSERT INTO task_workspace_apps VALUES (
|
||||
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id
|
||||
'a8c0b8c5-c9a8-4f33-93a4-8142e6858244', -- workspace_build_id
|
||||
'8fa17bbd-c48c-44c7-91ae-d4acbc755fad', -- workspace_agent_id
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
INSERT INTO public.task_workspace_apps VALUES (
|
||||
INSERT INTO task_workspace_apps VALUES (
|
||||
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id
|
||||
NULL, -- workspace_agent_id
|
||||
NULL, -- workspace_app_id
|
||||
|
||||
@@ -884,3 +884,8 @@ func WorkspaceIdentityFromWorkspace(w Workspace) WorkspaceIdentity {
|
||||
AutostartSchedule: w.AutostartSchedule,
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace agent belongs to the owner of the associated workspace.
|
||||
func (r GetWorkspaceAgentAndWorkspaceByIDRow) RBACObject() rbac.Object {
|
||||
return r.WorkspaceTable.RBACObject()
|
||||
}
|
||||
|
||||
@@ -3693,6 +3693,19 @@ type AuditLog struct {
|
||||
ResourceIcon string `db:"resource_icon" json:"resource_icon"`
|
||||
}
|
||||
|
||||
type BoundaryActiveUser struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
|
||||
}
|
||||
|
||||
type BoundaryActiveWorkspace struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
|
||||
}
|
||||
|
||||
type ConnectionLog struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ConnectTime time.Time `db:"connect_time" json:"connect_time"`
|
||||
|
||||
@@ -88,6 +88,8 @@ type sqlcQuerier interface {
|
||||
// be recreated.
|
||||
DeleteAllWebpushSubscriptions(ctx context.Context) error
|
||||
DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
|
||||
DeleteBoundaryActiveUsersBefore(ctx context.Context, recordedAt time.Time) error
|
||||
DeleteBoundaryActiveWorkspacesBefore(ctx context.Context, recordedAt time.Time) error
|
||||
DeleteCoordinator(ctx context.Context, id uuid.UUID) error
|
||||
DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error)
|
||||
DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error
|
||||
@@ -139,6 +141,7 @@ type sqlcQuerier interface {
|
||||
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
|
||||
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
|
||||
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
|
||||
DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error
|
||||
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
|
||||
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
|
||||
DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error
|
||||
@@ -192,6 +195,8 @@ type sqlcQuerier interface {
|
||||
// This function returns roles for authorization purposes. Implied member roles
|
||||
// are included.
|
||||
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
|
||||
GetBoundaryActiveUsersSince(ctx context.Context, recordedAt time.Time) ([]uuid.UUID, error)
|
||||
GetBoundaryActiveWorkspacesSince(ctx context.Context, recordedAt time.Time) ([]GetBoundaryActiveWorkspacesSinceRow, error)
|
||||
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
|
||||
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
@@ -466,6 +471,7 @@ type sqlcQuerier interface {
|
||||
GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error)
|
||||
GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error)
|
||||
GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error)
|
||||
GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentAndWorkspaceByIDRow, error)
|
||||
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentDevcontainer, error)
|
||||
@@ -543,6 +549,8 @@ type sqlcQuerier interface {
|
||||
// every member of the org.
|
||||
InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error)
|
||||
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
|
||||
InsertBoundaryActiveUser(ctx context.Context, arg InsertBoundaryActiveUserParams) error
|
||||
InsertBoundaryActiveWorkspace(ctx context.Context, arg InsertBoundaryActiveWorkspaceParams) error
|
||||
InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error)
|
||||
InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error)
|
||||
InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error
|
||||
@@ -677,6 +685,7 @@ type sqlcQuerier interface {
|
||||
UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error)
|
||||
UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error)
|
||||
UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error
|
||||
UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error)
|
||||
// Cancels all pending provisioner jobs for prebuilt workspaces on a specific preset from an
|
||||
// inactive template version.
|
||||
// This is an optimization to clean up stale pending jobs.
|
||||
|
||||
@@ -2304,6 +2304,94 @@ func TestDeleteCustomRoleDoesNotDeleteSystemRole(t *testing.T) {
|
||||
require.True(t, roles[0].IsSystem)
|
||||
}
|
||||
|
||||
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
updated, err := db.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{
|
||||
ID: org.ID,
|
||||
WorkspaceSharingDisabled: true,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated.WorkspaceSharingDisabled)
|
||||
|
||||
got, err := db.GetOrganizationByID(ctx, org.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.WorkspaceSharingDisabled)
|
||||
}
|
||||
|
||||
func TestDeleteWorkspaceACLsByOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org1 := dbgen.Organization(t, db, database.Organization{})
|
||||
org2 := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
owner1 := dbgen.User(t, db, database.User{})
|
||||
owner2 := dbgen.User(t, db, database.User{})
|
||||
sharedUser := dbgen.User(t, db, database.User{})
|
||||
sharedGroup := dbgen.Group(t, db, database.Group{
|
||||
OrganizationID: org1.ID,
|
||||
})
|
||||
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: owner1.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org2.ID,
|
||||
UserID: owner2.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: sharedUser.ID,
|
||||
})
|
||||
|
||||
ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner1.ID,
|
||||
OrganizationID: org1.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
sharedUser.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
GroupACL: database.WorkspaceACL{
|
||||
sharedGroup.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner2.ID,
|
||||
OrganizationID: org2.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
uuid.NewString(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := db.DeleteWorkspaceACLsByOrganization(ctx, org1.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
got1, err := db.GetWorkspaceByID(ctx, ws1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got1.UserACL)
|
||||
require.Empty(t, got1.GroupACL)
|
||||
|
||||
got2, err := db.GetWorkspaceByID(ctx, ws2.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, got2.UserACL)
|
||||
}
|
||||
|
||||
func TestAuthorizedAuditLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1980,6 +1980,116 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteBoundaryActiveUsersBefore = `-- name: DeleteBoundaryActiveUsersBefore :exec
|
||||
DELETE FROM boundary_active_users WHERE recorded_at < $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteBoundaryActiveUsersBefore(ctx context.Context, recordedAt time.Time) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteBoundaryActiveUsersBefore, recordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteBoundaryActiveWorkspacesBefore = `-- name: DeleteBoundaryActiveWorkspacesBefore :exec
|
||||
DELETE FROM boundary_active_workspaces WHERE recorded_at < $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteBoundaryActiveWorkspacesBefore(ctx context.Context, recordedAt time.Time) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteBoundaryActiveWorkspacesBefore, recordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const getBoundaryActiveUsersSince = `-- name: GetBoundaryActiveUsersSince :many
|
||||
SELECT DISTINCT user_id FROM boundary_active_users
|
||||
WHERE recorded_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetBoundaryActiveUsersSince(ctx context.Context, recordedAt time.Time) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getBoundaryActiveUsersSince, recordedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []uuid.UUID
|
||||
for rows.Next() {
|
||||
var user_id uuid.UUID
|
||||
if err := rows.Scan(&user_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, user_id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getBoundaryActiveWorkspacesSince = `-- name: GetBoundaryActiveWorkspacesSince :many
|
||||
SELECT DISTINCT workspace_id, template_id FROM boundary_active_workspaces
|
||||
WHERE recorded_at > $1
|
||||
`
|
||||
|
||||
type GetBoundaryActiveWorkspacesSinceRow struct {
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetBoundaryActiveWorkspacesSince(ctx context.Context, recordedAt time.Time) ([]GetBoundaryActiveWorkspacesSinceRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getBoundaryActiveWorkspacesSince, recordedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetBoundaryActiveWorkspacesSinceRow
|
||||
for rows.Next() {
|
||||
var i GetBoundaryActiveWorkspacesSinceRow
|
||||
if err := rows.Scan(&i.WorkspaceID, &i.TemplateID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertBoundaryActiveUser = `-- name: InsertBoundaryActiveUser :exec
|
||||
INSERT INTO boundary_active_users (user_id, recorded_at)
|
||||
VALUES ($1, $2)
|
||||
`
|
||||
|
||||
type InsertBoundaryActiveUserParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertBoundaryActiveUser(ctx context.Context, arg InsertBoundaryActiveUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertBoundaryActiveUser, arg.UserID, arg.RecordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertBoundaryActiveWorkspace = `-- name: InsertBoundaryActiveWorkspace :exec
|
||||
INSERT INTO boundary_active_workspaces (workspace_id, template_id, recorded_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertBoundaryActiveWorkspaceParams struct {
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertBoundaryActiveWorkspace(ctx context.Context, arg InsertBoundaryActiveWorkspaceParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertBoundaryActiveWorkspace, arg.WorkspaceID, arg.TemplateID, arg.RecordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const countConnectionLogs = `-- name: CountConnectionLogs :one
|
||||
SELECT
|
||||
COUNT(*) AS count
|
||||
@@ -8197,6 +8307,41 @@ func (q *sqlQuerier) UpdateOrganizationDeletedByID(ctx context.Context, arg Upda
|
||||
return err
|
||||
}
|
||||
|
||||
const updateOrganizationWorkspaceSharingSettings = `-- name: UpdateOrganizationWorkspaceSharingSettings :one
|
||||
UPDATE
|
||||
organizations
|
||||
SET
|
||||
workspace_sharing_disabled = $1,
|
||||
updated_at = $2
|
||||
WHERE
|
||||
id = $3
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
|
||||
`
|
||||
|
||||
type UpdateOrganizationWorkspaceSharingSettingsParams struct {
|
||||
WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateOrganizationWorkspaceSharingSettings, arg.WorkspaceSharingDisabled, arg.UpdatedAt, arg.ID)
|
||||
var i Organization
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsDefault,
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getParameterSchemasByJobID = `-- name: GetParameterSchemasByJobID :many
|
||||
SELECT
|
||||
id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type, index
|
||||
@@ -18120,6 +18265,99 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentAndWorkspaceByID = `-- name: GetWorkspaceAgentAndWorkspaceByID :one
|
||||
SELECT
|
||||
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted,
|
||||
workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl,
|
||||
users.username as owner_username
|
||||
FROM
|
||||
workspace_agents
|
||||
JOIN
|
||||
workspace_resources ON workspace_agents.resource_id = workspace_resources.id
|
||||
JOIN
|
||||
provisioner_jobs ON workspace_resources.job_id = provisioner_jobs.id
|
||||
JOIN
|
||||
workspace_builds ON provisioner_jobs.id = workspace_builds.job_id
|
||||
JOIN
|
||||
workspaces ON workspace_builds.workspace_id = workspaces.id
|
||||
JOIN
|
||||
users ON workspaces.owner_id = users.id
|
||||
WHERE
|
||||
workspace_agents.id = $1
|
||||
AND workspace_agents.deleted = FALSE
|
||||
AND provisioner_jobs.type = 'workspace_build'::provisioner_job_type
|
||||
AND workspaces.deleted = FALSE
|
||||
AND users.deleted = FALSE
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetWorkspaceAgentAndWorkspaceByIDRow struct {
|
||||
WorkspaceAgent WorkspaceAgent `db:"workspace_agent" json:"workspace_agent"`
|
||||
WorkspaceTable WorkspaceTable `db:"workspace_table" json:"workspace_table"`
|
||||
OwnerUsername string `db:"owner_username" json:"owner_username"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentAndWorkspaceByIDRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWorkspaceAgentAndWorkspaceByID, id)
|
||||
var i GetWorkspaceAgentAndWorkspaceByIDRow
|
||||
err := row.Scan(
|
||||
&i.WorkspaceAgent.ID,
|
||||
&i.WorkspaceAgent.CreatedAt,
|
||||
&i.WorkspaceAgent.UpdatedAt,
|
||||
&i.WorkspaceAgent.Name,
|
||||
&i.WorkspaceAgent.FirstConnectedAt,
|
||||
&i.WorkspaceAgent.LastConnectedAt,
|
||||
&i.WorkspaceAgent.DisconnectedAt,
|
||||
&i.WorkspaceAgent.ResourceID,
|
||||
&i.WorkspaceAgent.AuthToken,
|
||||
&i.WorkspaceAgent.AuthInstanceID,
|
||||
&i.WorkspaceAgent.Architecture,
|
||||
&i.WorkspaceAgent.EnvironmentVariables,
|
||||
&i.WorkspaceAgent.OperatingSystem,
|
||||
&i.WorkspaceAgent.InstanceMetadata,
|
||||
&i.WorkspaceAgent.ResourceMetadata,
|
||||
&i.WorkspaceAgent.Directory,
|
||||
&i.WorkspaceAgent.Version,
|
||||
&i.WorkspaceAgent.LastConnectedReplicaID,
|
||||
&i.WorkspaceAgent.ConnectionTimeoutSeconds,
|
||||
&i.WorkspaceAgent.TroubleshootingURL,
|
||||
&i.WorkspaceAgent.MOTDFile,
|
||||
&i.WorkspaceAgent.LifecycleState,
|
||||
&i.WorkspaceAgent.ExpandedDirectory,
|
||||
&i.WorkspaceAgent.LogsLength,
|
||||
&i.WorkspaceAgent.LogsOverflowed,
|
||||
&i.WorkspaceAgent.StartedAt,
|
||||
&i.WorkspaceAgent.ReadyAt,
|
||||
pq.Array(&i.WorkspaceAgent.Subsystems),
|
||||
pq.Array(&i.WorkspaceAgent.DisplayApps),
|
||||
&i.WorkspaceAgent.APIVersion,
|
||||
&i.WorkspaceAgent.DisplayOrder,
|
||||
&i.WorkspaceAgent.ParentID,
|
||||
&i.WorkspaceAgent.APIKeyScope,
|
||||
&i.WorkspaceAgent.Deleted,
|
||||
&i.WorkspaceTable.ID,
|
||||
&i.WorkspaceTable.CreatedAt,
|
||||
&i.WorkspaceTable.UpdatedAt,
|
||||
&i.WorkspaceTable.OwnerID,
|
||||
&i.WorkspaceTable.OrganizationID,
|
||||
&i.WorkspaceTable.TemplateID,
|
||||
&i.WorkspaceTable.Deleted,
|
||||
&i.WorkspaceTable.Name,
|
||||
&i.WorkspaceTable.AutostartSchedule,
|
||||
&i.WorkspaceTable.Ttl,
|
||||
&i.WorkspaceTable.LastUsedAt,
|
||||
&i.WorkspaceTable.DormantAt,
|
||||
&i.WorkspaceTable.DeletingAt,
|
||||
&i.WorkspaceTable.AutomaticUpdates,
|
||||
&i.WorkspaceTable.Favorite,
|
||||
&i.WorkspaceTable.NextStartAt,
|
||||
&i.WorkspaceTable.GroupACL,
|
||||
&i.WorkspaceTable.UserACL,
|
||||
&i.OwnerUsername,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope, deleted
|
||||
@@ -22151,6 +22389,21 @@ func (q *sqlQuerier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) e
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWorkspaceACLsByOrganization = `-- name: DeleteWorkspaceACLsByOrganization :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
group_acl = '{}'::jsonb,
|
||||
user_acl = '{}'::jsonb
|
||||
WHERE
|
||||
organization_id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWorkspaceACLsByOrganization, organizationID)
|
||||
return err
|
||||
}
|
||||
|
||||
const favoriteWorkspace = `-- name: FavoriteWorkspace :exec
|
||||
UPDATE workspaces SET favorite = true WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- name: InsertBoundaryActiveUser :exec
|
||||
INSERT INTO boundary_active_users (user_id, recorded_at)
|
||||
VALUES ($1, $2);
|
||||
|
||||
-- name: InsertBoundaryActiveWorkspace :exec
|
||||
INSERT INTO boundary_active_workspaces (workspace_id, template_id, recorded_at)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: GetBoundaryActiveUsersSince :many
|
||||
SELECT DISTINCT user_id FROM boundary_active_users
|
||||
WHERE recorded_at > $1;
|
||||
|
||||
-- name: GetBoundaryActiveWorkspacesSince :many
|
||||
SELECT DISTINCT workspace_id, template_id FROM boundary_active_workspaces
|
||||
WHERE recorded_at > $1;
|
||||
|
||||
-- name: DeleteBoundaryActiveUsersBefore :exec
|
||||
DELETE FROM boundary_active_users WHERE recorded_at < $1;
|
||||
|
||||
-- name: DeleteBoundaryActiveWorkspacesBefore :exec
|
||||
DELETE FROM boundary_active_workspaces WHERE recorded_at < $1;
|
||||
@@ -143,3 +143,13 @@ WHERE
|
||||
id = @id AND
|
||||
is_default = false;
|
||||
|
||||
-- name: UpdateOrganizationWorkspaceSharingSettings :one
|
||||
UPDATE
|
||||
organizations
|
||||
SET
|
||||
workspace_sharing_disabled = @workspace_sharing_disabled,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @id
|
||||
RETURNING *;
|
||||
|
||||
|
||||
@@ -393,3 +393,28 @@ AND wb.build_number = (
|
||||
WHERE wb2.workspace_id = w.id
|
||||
)
|
||||
AND workspace_agents.deleted = FALSE;
|
||||
|
||||
-- name: GetWorkspaceAgentAndWorkspaceByID :one
|
||||
SELECT
|
||||
sqlc.embed(workspace_agents),
|
||||
sqlc.embed(workspaces),
|
||||
users.username as owner_username
|
||||
FROM
|
||||
workspace_agents
|
||||
JOIN
|
||||
workspace_resources ON workspace_agents.resource_id = workspace_resources.id
|
||||
JOIN
|
||||
provisioner_jobs ON workspace_resources.job_id = provisioner_jobs.id
|
||||
JOIN
|
||||
workspace_builds ON provisioner_jobs.id = workspace_builds.job_id
|
||||
JOIN
|
||||
workspaces ON workspace_builds.workspace_id = workspaces.id
|
||||
JOIN
|
||||
users ON workspaces.owner_id = users.id
|
||||
WHERE
|
||||
workspace_agents.id = @id
|
||||
AND workspace_agents.deleted = FALSE
|
||||
AND provisioner_jobs.type = 'workspace_build'::provisioner_job_type
|
||||
AND workspaces.deleted = FALSE
|
||||
AND users.deleted = FALSE
|
||||
LIMIT 1;
|
||||
|
||||
@@ -947,6 +947,15 @@ SET
|
||||
WHERE
|
||||
id = @id;
|
||||
|
||||
-- name: DeleteWorkspaceACLsByOrganization :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
group_acl = '{}'::jsonb,
|
||||
user_acl = '{}'::jsonb
|
||||
WHERE
|
||||
organization_id = @organization_id;
|
||||
|
||||
-- name: GetRegularWorkspaceCreateMetrics :many
|
||||
-- Count regular workspaces: only those whose first successful 'start' build
|
||||
-- was not initiated by the prebuild system user.
|
||||
|
||||
@@ -13,6 +13,8 @@ const (
|
||||
UniqueAibridgeUserPromptsPkey UniqueConstraint = "aibridge_user_prompts_pkey" // ALTER TABLE ONLY aibridge_user_prompts ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id);
|
||||
UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id);
|
||||
UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
|
||||
UniqueBoundaryActiveUsersPkey UniqueConstraint = "boundary_active_users_pkey" // ALTER TABLE ONLY boundary_active_users ADD CONSTRAINT boundary_active_users_pkey PRIMARY KEY (id);
|
||||
UniqueBoundaryActiveWorkspacesPkey UniqueConstraint = "boundary_active_workspaces_pkey" // ALTER TABLE ONLY boundary_active_workspaces ADD CONSTRAINT boundary_active_workspaces_pkey PRIMARY KEY (id);
|
||||
UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id);
|
||||
UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence);
|
||||
UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id);
|
||||
|
||||
+6
-2
@@ -83,17 +83,21 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), api.Options.HealthcheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
report := api.HealthcheckFunc(ctx, apiKey)
|
||||
// Create and store progress tracker for timeout diagnostics.
|
||||
report := api.HealthcheckFunc(ctx, apiKey, &api.healthCheckProgress)
|
||||
if report != nil { // Only store non-nil reports.
|
||||
api.healthCheckCache.Store(report)
|
||||
}
|
||||
api.healthCheckProgress.Reset()
|
||||
return report, nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
summary := api.healthCheckProgress.Summary()
|
||||
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
|
||||
Message: "Healthcheck is in progress and did not complete in time. Try again in a few seconds.",
|
||||
Message: "Healthcheck timed out.",
|
||||
Detail: summary,
|
||||
})
|
||||
return
|
||||
case res := <-resChan:
|
||||
|
||||
+20
-17
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -28,7 +30,7 @@ func TestDebugHealth(t *testing.T) {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
sessionToken string
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
calls.Add(1)
|
||||
assert.Equal(t, sessionToken, apiKey)
|
||||
return &healthsdk.HealthcheckReport{
|
||||
@@ -61,7 +63,7 @@ func TestDebugHealth(t *testing.T) {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
sessionToken string
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
calls.Add(1)
|
||||
assert.Equal(t, sessionToken, apiKey)
|
||||
return &healthsdk.HealthcheckReport{
|
||||
@@ -93,19 +95,14 @@ func TestDebugHealth(t *testing.T) {
|
||||
// Need to ignore errors due to ctx timeout
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
done = make(chan struct{})
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &logger,
|
||||
HealthcheckTimeout: time.Microsecond,
|
||||
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
|
||||
t := time.NewTimer(time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return &healthsdk.HealthcheckReport{}
|
||||
case <-t.C:
|
||||
return &healthsdk.HealthcheckReport{}
|
||||
}
|
||||
HealthcheckTimeout: time.Second,
|
||||
HealthcheckFunc: func(_ context.Context, _ string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
progress.Start("test")
|
||||
<-done
|
||||
return &healthsdk.HealthcheckReport{}
|
||||
},
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
@@ -115,8 +112,14 @@ func TestDebugHealth(t *testing.T) {
|
||||
res, err := client.Request(ctx, "GET", "/api/v2/debug/health", nil)
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
_, _ = io.ReadAll(res.Body)
|
||||
close(done)
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err, "reading body")
|
||||
require.Equal(t, http.StatusServiceUnavailable, res.StatusCode)
|
||||
var sdkResp codersdk.Response
|
||||
require.NoError(t, json.Unmarshal(bs, &sdkResp), "unmarshaling sdk response")
|
||||
require.Equal(t, "Healthcheck timed out.", sdkResp.Message)
|
||||
require.Contains(t, sdkResp.Detail, "Still running: test (elapsed:")
|
||||
})
|
||||
|
||||
t.Run("Refresh", func(t *testing.T) {
|
||||
@@ -128,7 +131,7 @@ func TestDebugHealth(t *testing.T) {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
HealthcheckRefresh: time.Microsecond,
|
||||
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
|
||||
HealthcheckFunc: func(context.Context, string, *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
calls <- struct{}{}
|
||||
return &healthsdk.HealthcheckReport{}
|
||||
},
|
||||
@@ -173,7 +176,7 @@ func TestDebugHealth(t *testing.T) {
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
HealthcheckRefresh: time.Hour,
|
||||
HealthcheckTimeout: time.Hour,
|
||||
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
|
||||
HealthcheckFunc: func(context.Context, string, *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
calls++
|
||||
return &healthsdk.HealthcheckReport{
|
||||
Time: time.Now(),
|
||||
@@ -207,7 +210,7 @@ func TestDebugHealth(t *testing.T) {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
sessionToken string
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
|
||||
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
assert.Equal(t, sessionToken, apiKey)
|
||||
return &healthsdk.HealthcheckReport{
|
||||
Time: time.Now(),
|
||||
|
||||
@@ -2,6 +2,9 @@ package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,8 +13,91 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// Progress tracks the progress of healthcheck components for timeout
|
||||
// diagnostics. It records which checks have started and completed, along with
|
||||
// their durations, to provide useful information when a healthcheck times out.
|
||||
// The zero value is usable.
|
||||
type Progress struct {
|
||||
Clock quartz.Clock
|
||||
mu sync.Mutex
|
||||
checks map[string]*checkStatus
|
||||
}
|
||||
|
||||
type checkStatus struct {
|
||||
startedAt time.Time
|
||||
completedAt time.Time
|
||||
}
|
||||
|
||||
// Start records that a check has started.
|
||||
func (p *Progress) Start(name string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.Clock == nil {
|
||||
p.Clock = quartz.NewReal()
|
||||
}
|
||||
if p.checks == nil {
|
||||
p.checks = make(map[string]*checkStatus)
|
||||
}
|
||||
p.checks[name] = &checkStatus{startedAt: p.Clock.Now()}
|
||||
}
|
||||
|
||||
// Complete records that a check has finished.
|
||||
func (p *Progress) Complete(name string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.Clock == nil {
|
||||
p.Clock = quartz.NewReal()
|
||||
}
|
||||
if p.checks == nil {
|
||||
p.checks = make(map[string]*checkStatus)
|
||||
}
|
||||
if p.checks[name] == nil {
|
||||
p.checks[name] = &checkStatus{startedAt: p.Clock.Now()}
|
||||
}
|
||||
p.checks[name].completedAt = p.Clock.Now()
|
||||
}
|
||||
|
||||
// Reset clears all recorded check statuses.
|
||||
func (p *Progress) Reset() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.checks = make(map[string]*checkStatus)
|
||||
}
|
||||
|
||||
// Summary returns a human-readable summary of check progress.
|
||||
// Example: "Completed: AccessURL (95ms), Database (120ms). Still running: DERP, Websocket"
|
||||
func (p *Progress) Summary() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var completed, running []string
|
||||
for name, status := range p.checks {
|
||||
if status.completedAt.IsZero() {
|
||||
elapsed := p.Clock.Now().Sub(status.startedAt).Round(time.Millisecond)
|
||||
running = append(running, fmt.Sprintf("%s (elapsed: %dms)", name, elapsed.Milliseconds()))
|
||||
continue
|
||||
}
|
||||
duration := status.completedAt.Sub(status.startedAt).Round(time.Millisecond)
|
||||
completed = append(completed, fmt.Sprintf("%s (%dms)", name, duration.Milliseconds()))
|
||||
}
|
||||
|
||||
// Sort for consistent output.
|
||||
slices.Sort(completed)
|
||||
slices.Sort(running)
|
||||
|
||||
var parts []string
|
||||
if len(completed) > 0 {
|
||||
parts = append(parts, "Completed: "+strings.Join(completed, ", "))
|
||||
}
|
||||
if len(running) > 0 {
|
||||
parts = append(parts, "Still running: "+strings.Join(running, ", "))
|
||||
}
|
||||
return strings.Join(parts, ". ")
|
||||
}
|
||||
|
||||
type Checker interface {
|
||||
DERP(ctx context.Context, opts *derphealth.ReportOptions) healthsdk.DERPHealthReport
|
||||
AccessURL(ctx context.Context, opts *AccessURLReportOptions) healthsdk.AccessURLReport
|
||||
@@ -30,6 +116,10 @@ type ReportOptions struct {
|
||||
ProvisionerDaemons ProvisionerDaemonsReportDeps
|
||||
|
||||
Checker Checker
|
||||
|
||||
// Progress tracks healthcheck progress for timeout diagnostics.
|
||||
// If set, each check will record its start and completion time.
|
||||
Progress *Progress
|
||||
}
|
||||
|
||||
type defaultChecker struct{}
|
||||
@@ -89,6 +179,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("DERP")
|
||||
defer opts.Progress.Complete("DERP")
|
||||
}
|
||||
report.DERP = opts.Checker.DERP(ctx, &opts.DerpHealth)
|
||||
}()
|
||||
|
||||
@@ -101,6 +195,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("AccessURL")
|
||||
defer opts.Progress.Complete("AccessURL")
|
||||
}
|
||||
report.AccessURL = opts.Checker.AccessURL(ctx, &opts.AccessURL)
|
||||
}()
|
||||
|
||||
@@ -113,6 +211,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("Websocket")
|
||||
defer opts.Progress.Complete("Websocket")
|
||||
}
|
||||
report.Websocket = opts.Checker.Websocket(ctx, &opts.Websocket)
|
||||
}()
|
||||
|
||||
@@ -125,6 +227,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("Database")
|
||||
defer opts.Progress.Complete("Database")
|
||||
}
|
||||
report.Database = opts.Checker.Database(ctx, &opts.Database)
|
||||
}()
|
||||
|
||||
@@ -137,6 +243,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("WorkspaceProxy")
|
||||
defer opts.Progress.Complete("WorkspaceProxy")
|
||||
}
|
||||
report.WorkspaceProxy = opts.Checker.WorkspaceProxy(ctx, &opts.WorkspaceProxy)
|
||||
}()
|
||||
|
||||
@@ -149,6 +259,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Progress != nil {
|
||||
opts.Progress.Start("ProvisionerDaemons")
|
||||
defer opts.Progress.Complete("ProvisionerDaemons")
|
||||
}
|
||||
report.ProvisionerDaemons = opts.Checker.ProvisionerDaemons(ctx, &opts.ProvisionerDaemons)
|
||||
}()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package healthcheck_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type testChecker struct {
|
||||
@@ -533,3 +535,69 @@ func TestHealthcheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Summary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
progress := healthcheck.Progress{Clock: mClock}
|
||||
|
||||
// Start some checks
|
||||
progress.Start("Database")
|
||||
progress.Start("DERP")
|
||||
progress.Start("AccessURL")
|
||||
|
||||
// Advance time to simulate check duration
|
||||
mClock.Advance(100 * time.Millisecond)
|
||||
|
||||
// Complete some checks
|
||||
progress.Complete("Database")
|
||||
progress.Complete("AccessURL")
|
||||
|
||||
summary := progress.Summary()
|
||||
|
||||
// Verify completed and running checks are listed with duration / elapsed
|
||||
assert.Equal(t, summary, "Completed: AccessURL (100ms), Database (100ms). Still running: DERP (elapsed: 100ms)")
|
||||
})
|
||||
|
||||
t.Run("EmptyProgress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
progress := healthcheck.Progress{Clock: mClock}
|
||||
summary := progress.Summary()
|
||||
|
||||
// Should be empty string when nothing tracked
|
||||
assert.Empty(t, summary)
|
||||
})
|
||||
|
||||
t.Run("AllCompleted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
progress := healthcheck.Progress{Clock: mClock}
|
||||
progress.Start("Database")
|
||||
progress.Start("DERP")
|
||||
mClock.Advance(50 * time.Millisecond)
|
||||
progress.Complete("Database")
|
||||
progress.Complete("DERP")
|
||||
|
||||
summary := progress.Summary()
|
||||
assert.Equal(t, summary, "Completed: DERP (50ms), Database (50ms)")
|
||||
})
|
||||
|
||||
t.Run("AllRunning", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
progress := healthcheck.Progress{Clock: mClock}
|
||||
progress.Start("Database")
|
||||
progress.Start("DERP")
|
||||
|
||||
summary := progress.Summary()
|
||||
assert.Equal(t, summary, "Still running: DERP (elapsed: 0ms), Database (elapsed: 0ms)")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -493,16 +493,10 @@ func OneWayWebSocketEventSender(rw http.ResponseWriter, r *http.Request) (
|
||||
return sendEvent, closed, nil
|
||||
}
|
||||
|
||||
// OAuth2Error represents an OAuth2-compliant error response per RFC 6749.
|
||||
type OAuth2Error struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// WriteOAuth2Error writes an OAuth2-compliant error response per RFC 6749.
|
||||
// This should be used for all OAuth2 endpoints (/oauth2/*) to ensure compliance.
|
||||
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
|
||||
Write(ctx, rw, status, OAuth2Error{
|
||||
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode codersdk.OAuth2ErrorCode, description string) {
|
||||
Write(ctx, rw, status, codersdk.OAuth2Error{
|
||||
Error: errorCode,
|
||||
ErrorDescription: description,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package httpmw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
httpRouteInfoKey struct{}
|
||||
)
|
||||
|
||||
type httpRouteInfo struct {
|
||||
Route string
|
||||
Method string
|
||||
}
|
||||
|
||||
// ExtractHTTPRoute retrieves just the HTTP route pattern from context.
|
||||
// Returns empty string if not set.
|
||||
func ExtractHTTPRoute(ctx context.Context) string {
|
||||
ri, _ := ctx.Value(httpRouteInfoKey{}).(httpRouteInfo)
|
||||
return ri.Route
|
||||
}
|
||||
|
||||
// ExtractHTTPMethod retrieves just the HTTP method from context.
|
||||
// Returns empty string if not set.
|
||||
func ExtractHTTPMethod(ctx context.Context) string {
|
||||
ri, _ := ctx.Value(httpRouteInfoKey{}).(httpRouteInfo)
|
||||
return ri.Method
|
||||
}
|
||||
|
||||
// HTTPRoute is middleware that stores the HTTP route pattern and method in
|
||||
// context for use by downstream handlers and services (e.g. prometheus).
|
||||
func HTTPRoute(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
route := getRoutePattern(r)
|
||||
ctx := context.WithValue(r.Context(), httpRouteInfoKey{}, httpRouteInfo{
|
||||
Route: route,
|
||||
Method: r.Method,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func getRoutePattern(r *http.Request) string {
|
||||
rctx := chi.RouteContext(r.Context())
|
||||
if rctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
routePath := r.URL.Path
|
||||
if r.URL.RawPath != "" {
|
||||
routePath = r.URL.RawPath
|
||||
}
|
||||
|
||||
tctx := chi.NewRouteContext()
|
||||
routes := rctx.Routes
|
||||
if routes != nil && !routes.Match(tctx, r.Method, routePath) {
|
||||
// No matching pattern. /api/* requests will be matched as "UNKNOWN"
|
||||
// All other ones will be matched as "STATIC".
|
||||
if strings.HasPrefix(routePath, "/api/") {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return "STATIC"
|
||||
}
|
||||
|
||||
// tctx has the updated pattern, since Match mutates it
|
||||
return tctx.RoutePattern()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package httpmw_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestHTTPRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reqFn func() *http.Request
|
||||
registerRoutes map[string]string
|
||||
mws []func(http.Handler) http.Handler
|
||||
expectedRoute string
|
||||
expectedMethod string
|
||||
}{
|
||||
{
|
||||
name: "without middleware",
|
||||
reqFn: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
},
|
||||
registerRoutes: map[string]string{http.MethodGet: "/"},
|
||||
mws: []func(http.Handler) http.Handler{},
|
||||
expectedRoute: "",
|
||||
expectedMethod: "",
|
||||
},
|
||||
{
|
||||
name: "root",
|
||||
reqFn: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
},
|
||||
registerRoutes: map[string]string{http.MethodGet: "/"},
|
||||
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
|
||||
expectedRoute: "/",
|
||||
expectedMethod: http.MethodGet,
|
||||
},
|
||||
{
|
||||
name: "parameterized route",
|
||||
reqFn: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodPut, "/users/123", nil)
|
||||
},
|
||||
registerRoutes: map[string]string{http.MethodPut: "/users/{id}"},
|
||||
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
|
||||
expectedRoute: "/users/{id}",
|
||||
expectedMethod: http.MethodPut,
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
reqFn: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/api/a", nil)
|
||||
},
|
||||
registerRoutes: map[string]string{http.MethodGet: "/api/b"},
|
||||
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
|
||||
expectedRoute: "UNKNOWN",
|
||||
expectedMethod: http.MethodGet,
|
||||
},
|
||||
{
|
||||
name: "static",
|
||||
reqFn: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/some/static/file.png", nil)
|
||||
},
|
||||
registerRoutes: map[string]string{http.MethodGet: "/"},
|
||||
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
|
||||
expectedRoute: "STATIC",
|
||||
expectedMethod: http.MethodGet,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
r := chi.NewRouter()
|
||||
done := make(chan string)
|
||||
for _, mw := range tc.mws {
|
||||
r.Use(mw)
|
||||
}
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
method := httpmw.ExtractHTTPMethod(r.Context())
|
||||
route := httpmw.ExtractHTTPRoute(r.Context())
|
||||
assert.Equal(t, tc.expectedMethod, method, "expected method mismatch")
|
||||
assert.Equal(t, tc.expectedRoute, route, "expected route mismatch")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
for method, route := range tc.registerRoutes {
|
||||
r.MethodFunc(method, route, func(w http.ResponseWriter, r *http.Request) {})
|
||||
}
|
||||
req := tc.reqFn()
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
_ = testutil.TryReceive(ctx, t, done)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -290,15 +290,15 @@ func (*codersdkErrorWriter) writeClientNotFound(ctx context.Context, rw http.Res
|
||||
type oauth2ErrorWriter struct{}
|
||||
|
||||
func (*oauth2ErrorWriter) writeMissingClientID(ctx context.Context, rw http.ResponseWriter) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing client_id parameter")
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Missing client_id parameter")
|
||||
}
|
||||
|
||||
func (*oauth2ErrorWriter) writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, _ error) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
|
||||
}
|
||||
|
||||
func (*oauth2ErrorWriter) writeClientNotFound(ctx context.Context, rw http.ResponseWriter) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
|
||||
}
|
||||
|
||||
// extractOAuth2ProviderAppBase is the internal implementation that uses the strategy pattern
|
||||
|
||||
@@ -3,10 +3,8 @@ package httpmw
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
@@ -71,7 +69,7 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
|
||||
var (
|
||||
dist *prometheus.HistogramVec
|
||||
distOpts []string
|
||||
path = getRoutePattern(r)
|
||||
path = ExtractHTTPRoute(r.Context())
|
||||
)
|
||||
|
||||
// We want to count WebSockets separately.
|
||||
@@ -98,29 +96,3 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getRoutePattern(r *http.Request) string {
|
||||
rctx := chi.RouteContext(r.Context())
|
||||
if rctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
routePath := r.URL.Path
|
||||
if r.URL.RawPath != "" {
|
||||
routePath = r.URL.RawPath
|
||||
}
|
||||
|
||||
tctx := chi.NewRouteContext()
|
||||
routes := rctx.Routes
|
||||
if routes != nil && !routes.Match(tctx, r.Method, routePath) {
|
||||
// No matching pattern. /api/* requests will be matched as "UNKNOWN"
|
||||
// All other ones will be matched as "STATIC".
|
||||
if strings.HasPrefix(routePath, "/api/") {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return "STATIC"
|
||||
}
|
||||
|
||||
// tctx has the updated pattern, since Match mutates it
|
||||
return tctx.RoutePattern()
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ func TestPrometheus(t *testing.T) {
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
|
||||
res := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
||||
reg := prometheus.NewRegistry()
|
||||
httpmw.Prometheus(reg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpmw.HTTPRoute(httpmw.Prometheus(reg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(res, req)
|
||||
}))).ServeHTTP(res, req)
|
||||
metrics, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(metrics), 0)
|
||||
@@ -57,7 +57,7 @@ func TestPrometheus(t *testing.T) {
|
||||
wrappedHandler := promMW(testHandler)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(tracing.StatusWriterMiddleware, promMW)
|
||||
r.Use(tracing.StatusWriterMiddleware, httpmw.HTTPRoute, promMW)
|
||||
r.Get("/api/v2/build/{build}/logs", func(rw http.ResponseWriter, r *http.Request) {
|
||||
wrappedHandler.ServeHTTP(rw, r)
|
||||
})
|
||||
@@ -85,7 +85,7 @@ func TestPrometheus(t *testing.T) {
|
||||
promMW := httpmw.Prometheus(reg)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.With(promMW).Get("/api/v2/users/{user}", func(w http.ResponseWriter, r *http.Request) {})
|
||||
r.With(httpmw.HTTPRoute).With(promMW).Get("/api/v2/users/{user}", func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v2/users/john", nil)
|
||||
|
||||
@@ -115,6 +115,7 @@ func TestPrometheus(t *testing.T) {
|
||||
promMW := httpmw.Prometheus(reg)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(httpmw.HTTPRoute)
|
||||
r.Use(promMW)
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -145,6 +146,7 @@ func TestPrometheus(t *testing.T) {
|
||||
promMW := httpmw.Prometheus(reg)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(httpmw.HTTPRoute)
|
||||
r.Use(promMW)
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -173,6 +175,7 @@ func TestPrometheus(t *testing.T) {
|
||||
promMW := httpmw.Prometheus(reg)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(httpmw.HTTPRoute)
|
||||
r.Use(promMW)
|
||||
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user