diff --git a/CLAUDE.md b/CLAUDE.md index 8f0f5a8a99a4e..5462f9c3018e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,5 +103,4 @@ Read [cursor rules](.cursorrules). The frontend is contained in the site folder. -For building Frontend refer to [this document](docs/contributing/frontend.md) For building Frontend refer to [this document](docs/about/contributing/frontend.md) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 8896c3217558f..4a8413906e8ce 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -24,6 +24,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentcontainers/watcher" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -57,6 +58,7 @@ type API struct { logger slog.Logger watcher watcher.Watcher execer agentexec.Execer + commandEnv CommandEnv ccli ContainerCLI containerLabelIncludeFilter map[string]string // Labels to filter containers by. dccli DevcontainerCLI @@ -109,6 +111,29 @@ func WithExecer(execer agentexec.Execer) Option { } } +// WithCommandEnv sets the CommandEnv implementation to use. +func WithCommandEnv(ce CommandEnv) Option { + return func(api *API) { + api.commandEnv = func(ei usershell.EnvInfoer, preEnv []string) (string, string, []string, error) { + shell, dir, env, err := ce(ei, preEnv) + if err != nil { + return shell, dir, env, err + } + env = slices.DeleteFunc(env, func(s string) bool { + // Ensure we filter out environment variables that come + // from the parent agent and are incorrect or not + // relevant for the devcontainer. + return strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_NAME=") || + strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_URL=") || + strings.HasPrefix(s, "CODER_AGENT_TOKEN=") || + strings.HasPrefix(s, "CODER_AGENT_AUTH=") || + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=") + }) + return shell, dir, env, nil + } + } +} + // WithContainerCLI sets the agentcontainers.ContainerCLI implementation // to use. The default implementation uses the Docker CLI. func WithContainerCLI(ccli ContainerCLI) Option { @@ -151,7 +176,7 @@ func WithSubAgentURL(url string) Option { } } -// WithSubAgent sets the environment variables for the sub-agent. +// WithSubAgentEnv sets the environment variables for the sub-agent. func WithSubAgentEnv(env ...string) Option { return func(api *API) { api.subAgentEnv = env @@ -259,6 +284,13 @@ func NewAPI(logger slog.Logger, options ...Option) *API { for _, opt := range options { opt(api) } + if api.commandEnv != nil { + api.execer = newCommandEnvExecer( + api.logger, + api.commandEnv, + api.execer, + ) + } if api.ccli == nil { api.ccli = NewDockerCLI(api.execer) } @@ -346,7 +378,11 @@ func (api *API) updaterLoop() { // and anyone looking to interact with the API. api.logger.Debug(api.ctx, "performing initial containers update") if err := api.updateContainers(api.ctx); err != nil { - api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err)) + if errors.Is(err, context.Canceled) { + api.logger.Warn(api.ctx, "initial containers update canceled", slog.Error(err)) + } else { + api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err)) + } } else { api.logger.Debug(api.ctx, "initial containers update complete") } @@ -367,7 +403,11 @@ func (api *API) updaterLoop() { case api.updateTrigger <- done: err := <-done if err != nil { - api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err)) + if errors.Is(err, context.Canceled) { + api.logger.Warn(api.ctx, "updater loop ticker canceled", slog.Error(err)) + } else { + api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err)) + } } default: api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress") diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index a59a3bfd6731e..a5541399f6437 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "runtime" "strings" "sync" @@ -26,7 +27,9 @@ import ( "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/agent/agentcontainers/watcher" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -291,6 +294,38 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error { return nil } +// fakeExecer implements agentexec.Execer for testing and tracks execution details. +type fakeExecer struct { + commands [][]string + createdCommands []*exec.Cmd +} + +func (f *fakeExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd { + f.commands = append(f.commands, append([]string{cmd}, args...)) + // Create a command that returns empty JSON for docker commands. + c := exec.CommandContext(ctx, "echo", "[]") + f.createdCommands = append(f.createdCommands, c) + return c +} + +func (f *fakeExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd { + f.commands = append(f.commands, append([]string{cmd}, args...)) + return &pty.Cmd{ + Context: ctx, + Path: cmd, + Args: append([]string{cmd}, args...), + Env: []string{}, + Dir: "", + } +} + +func (f *fakeExecer) getLastCommand() *exec.Cmd { + if len(f.createdCommands) == 0 { + return nil + } + return f.createdCommands[len(f.createdCommands)-1] +} + func TestAPI(t *testing.T) { t.Parallel() @@ -1970,6 +2005,57 @@ func TestAPI(t *testing.T) { // Then: We expected it to succeed require.Len(t, fSAC.created, 1) }) + + t.Run("CommandEnv", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + // Create fake execer to track execution details. + fakeExec := &fakeExecer{} + + // Custom CommandEnv that returns specific values. + testShell := "/bin/custom-shell" + testDir := t.TempDir() + testEnv := []string{"CUSTOM_VAR=test_value", "PATH=/custom/path"} + + commandEnv := func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) { + return testShell, testDir, testEnv, nil + } + + mClock := quartz.NewMock(t) // Stop time. + + // Create API with CommandEnv. + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithExecer(fakeExec), + agentcontainers.WithCommandEnv(commandEnv), + ) + defer api.Close() + + // Call RefreshContainers directly to trigger CommandEnv usage. + _ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail. + + // Verify commands were executed through the custom shell and environment. + require.NotEmpty(t, fakeExec.commands, "commands should be executed") + + // Want: /bin/custom-shell -c "docker ps --all --quiet --no-trunc" + require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used") + if runtime.GOOS == "windows" { + require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows") + } else { + require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c") + } + require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments") + require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments") + + // Verify the environment was set on the command. + lastCmd := fakeExec.getLastCommand() + require.NotNil(t, lastCmd, "command should be created") + require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used") + require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used") + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index 83463481c97f7..58ca3901e2f23 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -311,6 +311,10 @@ func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListCon // container IDs and returns the parsed output. // The stderr output is also returned for logging purposes. func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) (stdout, stderr []byte, err error) { + if ctx.Err() != nil { + // If the context is done, we don't want to run the command. + return []byte{}, []byte{}, ctx.Err() + } var stdoutBuf, stderrBuf bytes.Buffer cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...) cmd.Stdout = &stdoutBuf @@ -319,6 +323,12 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin stdout = bytes.TrimSpace(stdoutBuf.Bytes()) stderr = bytes.TrimSpace(stderrBuf.Bytes()) if err != nil { + if ctx.Err() != nil { + // If the context was canceled while running the command, + // return the context error instead of the command error, + // which is likely to be "signal: killed". + return stdout, stderr, ctx.Err() + } if bytes.Contains(stderr, []byte("No such object:")) { // This can happen if a container is deleted between the time we check for its existence and the time we inspect it. return stdout, stderr, nil diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index e87c3362c6e54..850dc84d5ac7d 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "io" - "os" "golang.org/x/xerrors" @@ -280,7 +279,6 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi } c := d.execer.CommandContext(ctx, "devcontainer", args...) - c.Env = append(c.Env, "PATH="+os.Getenv("PATH")) c.Env = append(c.Env, env...) var stdoutBuf bytes.Buffer diff --git a/agent/agentcontainers/execer.go b/agent/agentcontainers/execer.go new file mode 100644 index 0000000000000..8bdb7f24390f3 --- /dev/null +++ b/agent/agentcontainers/execer.go @@ -0,0 +1,77 @@ +package agentcontainers + +import ( + "context" + "os/exec" + "runtime" + + "github.com/kballard/go-shellquote" + + "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/pty" +) + +// CommandEnv is a function that returns the shell, working directory, +// and environment variables to use when executing a command. It takes +// an EnvInfoer and a pre-existing environment slice as arguments. +// This signature matches agentssh.Server.CommandEnv. +type CommandEnv func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) + +// commandEnvExecer is an agentexec.Execer that uses a CommandEnv to +// determine the shell, working directory, and environment variables +// for commands. It wraps another agentexec.Execer to provide the +// necessary context. +type commandEnvExecer struct { + logger slog.Logger + commandEnv CommandEnv + execer agentexec.Execer +} + +func newCommandEnvExecer( + logger slog.Logger, + commandEnv CommandEnv, + execer agentexec.Execer, +) *commandEnvExecer { + return &commandEnvExecer{ + logger: logger, + commandEnv: commandEnv, + execer: execer, + } +} + +// Ensure commandEnvExecer implements agentexec.Execer. +var _ agentexec.Execer = (*commandEnvExecer)(nil) + +func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs ...string) (name string, args []string, dir string, env []string) { + shell, dir, env, err := e.commandEnv(nil, nil) + if err != nil { + e.logger.Error(ctx, "get command environment failed", slog.Error(err)) + return inName, inArgs, "", nil + } + + caller := "-c" + if runtime.GOOS == "windows" { + caller = "/c" + } + name = shell + args = []string{caller, shellquote.Join(append([]string{inName}, inArgs...)...)} + return name, args, dir, env +} + +func (e *commandEnvExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd { + name, args, dir, env := e.prepare(ctx, cmd, args...) + c := e.execer.CommandContext(ctx, name, args...) + c.Dir = dir + c.Env = env + return c +} + +func (e *commandEnvExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd { + name, args, dir, env := e.prepare(ctx, cmd, args...) + c := e.execer.PTYCommandContext(ctx, name, args...) + c.Dir = dir + c.Env = env + return c +} diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index a2fec79debcf1..fb4cc055e31da 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -816,6 +816,49 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error { return xerrors.Errorf("sftp server closed with error: %w", err) } +func (s *Server) CommandEnv(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) { + if ei == nil { + ei = &usershell.SystemEnvInfo{} + } + + currentUser, err := ei.User() + if err != nil { + return "", "", nil, xerrors.Errorf("get current user: %w", err) + } + username := currentUser.Username + + shell, err = ei.Shell(username) + if err != nil { + return "", "", nil, xerrors.Errorf("get user shell: %w", err) + } + + dir = s.config.WorkingDirectory() + + // If the metadata directory doesn't exist, we run the command + // in the users home directory. + _, err = os.Stat(dir) + if dir == "" || err != nil { + // Default to user home if a directory is not set. + homedir, err := ei.HomeDir() + if err != nil { + return "", "", nil, xerrors.Errorf("get home dir: %w", err) + } + dir = homedir + } + env = append(ei.Environ(), addEnv...) + // Set login variables (see `man login`). + env = append(env, fmt.Sprintf("USER=%s", username)) + env = append(env, fmt.Sprintf("LOGNAME=%s", username)) + env = append(env, fmt.Sprintf("SHELL=%s", shell)) + + env, err = s.config.UpdateEnv(env) + if err != nil { + return "", "", nil, xerrors.Errorf("apply env: %w", err) + } + + return shell, dir, env, nil +} + // CreateCommand processes raw command input with OpenSSH-like behavior. // If the script provided is empty, it will default to the users shell. // This injects environment variables specified by the user at launch too. @@ -827,15 +870,10 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, if ei == nil { ei = &usershell.SystemEnvInfo{} } - currentUser, err := ei.User() - if err != nil { - return nil, xerrors.Errorf("get current user: %w", err) - } - username := currentUser.Username - shell, err := ei.Shell(username) + shell, dir, env, err := s.CommandEnv(ei, env) if err != nil { - return nil, xerrors.Errorf("get user shell: %w", err) + return nil, xerrors.Errorf("prepare command env: %w", err) } // OpenSSH executes all commands with the users current shell. @@ -893,24 +931,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, ) } cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...) - cmd.Dir = s.config.WorkingDirectory() - - // If the metadata directory doesn't exist, we run the command - // in the users home directory. - _, err = os.Stat(cmd.Dir) - if cmd.Dir == "" || err != nil { - // Default to user home if a directory is not set. - homedir, err := ei.HomeDir() - if err != nil { - return nil, xerrors.Errorf("get home dir: %w", err) - } - cmd.Dir = homedir - } - cmd.Env = append(ei.Environ(), env...) - // Set login variables (see `man login`). - cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username)) - cmd.Env = append(cmd.Env, fmt.Sprintf("LOGNAME=%s", username)) - cmd.Env = append(cmd.Env, fmt.Sprintf("SHELL=%s", shell)) + cmd.Dir = dir + cmd.Env = env // Set SSH connection environment variables (these are also set by OpenSSH // and thus expected to be present by SSH clients). Since the agent does @@ -921,11 +943,6 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %s %s", srcAddr, srcPort, dstPort)) cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", srcAddr, srcPort, dstAddr, dstPort)) - cmd.Env, err = s.config.UpdateEnv(cmd.Env) - if err != nil { - return nil, xerrors.Errorf("apply env: %w", err) - } - return cmd, nil } diff --git a/agent/api.go b/agent/api.go index 464c5fc5dab30..fa761988b2f21 100644 --- a/agent/api.go +++ b/agent/api.go @@ -43,6 +43,7 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e if a.experimentalDevcontainersEnabled { containerAPIOpts := []agentcontainers.Option{ agentcontainers.WithExecer(a.execer), + agentcontainers.WithCommandEnv(a.sshServer.CommandEnv), agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { return a.logSender.GetScriptLogger(logSourceID) }), diff --git a/cli/cliutil/license.go b/cli/cliutil/license.go new file mode 100644 index 0000000000000..f4012ba665845 --- /dev/null +++ b/cli/cliutil/license.go @@ -0,0 +1,87 @@ +package cliutil + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +// NewLicenseFormatter returns a new license formatter. +// The formatter will return a table and JSON output. +func NewLicenseFormatter() *cliui.OutputFormatter { + type tableLicense struct { + ID int32 `table:"id,default_sort"` + UUID uuid.UUID `table:"uuid" format:"uuid"` + UploadedAt time.Time `table:"uploaded at" format:"date-time"` + // Features is the formatted string for the license claims. + // Used for the table view. + Features string `table:"features"` + ExpiresAt time.Time `table:"expires at" format:"date-time"` + Trial bool `table:"trial"` + } + + return cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]tableLicense{}, []string{"ID", "UUID", "Expires At", "Uploaded At", "Features"}), + func(data any) (any, error) { + list, ok := data.([]codersdk.License) + if !ok { + return nil, xerrors.Errorf("invalid data type %T", data) + } + out := make([]tableLicense, 0, len(list)) + for _, lic := range list { + var formattedFeatures string + features, err := lic.FeaturesClaims() + if err != nil { + formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error() + } else { + var strs []string + if lic.AllFeaturesClaim() { + // If all features are enabled, just include that + strs = append(strs, "all features") + } else { + for k, v := range features { + if v > 0 { + // Only include claims > 0 + strs = append(strs, fmt.Sprintf("%s=%v", k, v)) + } + } + } + formattedFeatures = strings.Join(strs, ", ") + } + // If this returns an error, a zero time is returned. + exp, _ := lic.ExpiresAt() + + out = append(out, tableLicense{ + ID: lic.ID, + UUID: lic.UUID, + UploadedAt: lic.UploadedAt, + Features: formattedFeatures, + ExpiresAt: exp, + Trial: lic.Trial(), + }) + } + return out, nil + }), + cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) { + list, ok := data.([]codersdk.License) + if !ok { + return nil, xerrors.Errorf("invalid data type %T", data) + } + for i := range list { + humanExp, err := list[i].ExpiresAt() + if err == nil { + list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339) + } + } + + return list, nil + }), + ) +} diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 923bf09bb0e15..04c8798c166af 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -87,6 +87,8 @@ func TestExpRpty(t *testing.T) { t.Skip("Skipping test on non-Linux platform") } + wantLabel := "coder.devcontainers.TestExpRpty.Container" + client, workspace, agentToken := setupWorkspaceForAgent(t) ctx := testutil.Context(t, testutil.WaitLong) pool, err := dockertest.NewPool("") @@ -94,7 +96,10 @@ func TestExpRpty(t *testing.T) { ct, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "busybox", Tag: "latest", - Cmd: []string{"sleep", "infnity"}, + Cmd: []string{"sleep", "infinity"}, + Labels: map[string]string{ + wantLabel: "true", + }, }, func(config *docker.HostConfig) { config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} @@ -113,7 +118,7 @@ func TestExpRpty(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true o.ContainerAPIOptions = append(o.ContainerAPIOptions, - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + agentcontainers.WithContainerLabelIncludeFilter(wantLabel, "true"), ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/support.go b/cli/support.go index fa7c58261bd41..70fadc3994580 100644 --- a/cli/support.go +++ b/cli/support.go @@ -3,6 +3,7 @@ package cli import ( "archive/zip" "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,6 +14,8 @@ import ( "text/tabwriter" "time" + "github.com/coder/coder/v2/cli/cliutil" + "github.com/google/uuid" "golang.org/x/xerrors" @@ -48,6 +51,7 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information - Agent details (with environment variable sanitized) - Agent network diagnostics - Agent logs + - License status ` + 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") + @@ -302,6 +306,11 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { return xerrors.Errorf("decode template zip from base64") } + licenseStatus, err := humanizeLicenses(src.Deployment.Licenses) + if err != nil { + return xerrors.Errorf("format license status: %w", err) + } + // The below we just write as we have them: for k, v := range map[string]string{ "agent/logs.txt": string(src.Agent.Logs), @@ -315,6 +324,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { "network/tailnet_debug.html": src.Network.TailnetDebug, "workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), "workspace/template_file.zip": string(templateVersionBytes), + "license-status.txt": licenseStatus, } { f, err := dest.Create(k) if err != nil { @@ -359,3 +369,13 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { _ = tw.Flush() return buf.String() } + +func humanizeLicenses(licenses []codersdk.License) (string, error) { + formatter := cliutil.NewLicenseFormatter() + + if len(licenses) == 0 { + return "No licenses found", nil + } + + return formatter.Format(context.Background(), licenses) +} diff --git a/cli/support_test.go b/cli/support_test.go index e1ad7fca7b0a4..46be69caa3bfd 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -386,6 +386,9 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge case "cli_logs.txt": bs := readBytesFromZip(t, f) require.NotEmpty(t, bs, "CLI logs should not be empty") + case "license-status.txt": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "license status should not be empty") default: require.Failf(t, "unexpected file in bundle", f.Name) } diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 5304f2ce262ee..e97894c4afb21 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -68,7 +68,8 @@ "available": 0, "most_recently_seen": null }, - "template_version_preset_id": null + "template_version_preset_id": null, + "has_ai_task": false }, "latest_app_status": null, "outdated": false, diff --git a/coderd/aitasks.go b/coderd/aitasks.go new file mode 100644 index 0000000000000..a982ccc39b26b --- /dev/null +++ b/coderd/aitasks.go @@ -0,0 +1,63 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// This endpoint is experimental and not guaranteed to be stable, so we're not +// generating public-facing documentation for it. +func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + buildIDsParam := r.URL.Query().Get("build_ids") + if buildIDsParam == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "build_ids query parameter is required", + }) + return + } + + // Parse build IDs + buildIDStrings := strings.Split(buildIDsParam, ",") + buildIDs := make([]uuid.UUID, 0, len(buildIDStrings)) + for _, idStr := range buildIDStrings { + id, err := uuid.Parse(strings.TrimSpace(idStr)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid build ID format: %s", idStr), + Detail: err.Error(), + }) + return + } + buildIDs = append(buildIDs, id) + } + + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace build parameters.", + Detail: err.Error(), + }) + return + } + + promptsByBuildID := make(map[string]string, len(parameters)) + for _, param := range parameters { + if param.Name != codersdk.AITaskPromptParameterName { + continue + } + buildID := param.WorkspaceBuildID.String() + promptsByBuildID[buildID] = param.Value + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{ + Prompts: promptsByBuildID, + }) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go new file mode 100644 index 0000000000000..53f0174d6f03d --- /dev/null +++ b/coderd/aitasks_test.go @@ -0,0 +1,141 @@ +package coderd_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestAITasksPrompts(t *testing.T) { + t.Parallel() + + t.Run("EmptyBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + experimentalClient := codersdk.NewExperimentalClient(client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with empty build IDs + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) + + t.Run("MultipleBuilds", func(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test checks RBAC, which is not supported in the in-memory database") + } + + adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + first := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a template with parameters + version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "param1", + Type: "string", + DefaultValue: "default1", + }, + { + Name: codersdk.AITaskPromptParameterName, + Type: "string", + DefaultValue: "default2", + }, + }, + }, + }, + }}, + ProvisionApply: echo.ApplyComplete, + }) + template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) + + // Create two workspaces with different parameters + workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1a"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2a"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID) + + workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1b"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2b"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID) + + workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1c"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2c"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID) + allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID} + + experimentalMemberClient := codersdk.NewExperimentalClient(memberClient) + // Test parameters endpoint as member + prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + // we expect 2 prompts because the member client does not have access to workspace3 + // since it was created by the admin client + require.Len(t, prompts.Prompts, 2) + + // Check workspace1 parameters + build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()] + require.Equal(t, "value2a", build1Prompt) + + // Check workspace2 parameters + build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()] + require.Equal(t, "value2b", build2Prompt) + + experimentalAdminClient := codersdk.NewExperimentalClient(adminClient) + // Test parameters endpoint as admin + // we expect 3 prompts because the admin client has access to all workspaces + prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + require.Len(t, prompts.Prompts, 3) + + // Check workspace3 parameters + build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()] + require.Equal(t, "value2c", build3Prompt) + }) + + t.Run("NonExistentBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with non-existent build IDs + nonExistentID := uuid.New() + experimentalClient := codersdk.NewExperimentalClient(client) + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ec11a67fcf0be..9e20f3e268f90 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14519,6 +14519,9 @@ const docTemplate = `{ "codersdk.Preset": { "type": "object", "properties": { + "default": { + "type": "boolean" + }, "id": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d346b166580f7..ddf5fb0d40156 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13145,6 +13145,9 @@ "codersdk.Preset": { "type": "object", "properties": { + "default": { + "type": "boolean" + }, "id": { "type": "string" }, diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f71d267028270..1846b1ea18284 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "net/http" + "slices" + "strings" "sync" "sync/atomic" "time" @@ -155,6 +157,22 @@ func (e *Executor) runOnce(t time.Time) Stats { return stats } + // Sort the workspaces by build template version ID so that we can group + // identical template versions together. This is a slight (and imperfect) + // optimization. + // + // `wsbuilder` needs to load the terraform files for a given template version + // into memory. If 2 workspaces are using the same template version, they will + // share the same files in the FileCache. This only happens if the builds happen + // in parallel. + // TODO: Actually make sure the cache has the files in the cache for the full + // set of identical template versions. Then unload the files when the builds + // are done. Right now, this relies on luck for the 10 goroutine workers to + // overlap and keep the file reference in the cache alive. + slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int { + return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String()) + }) + // We only use errgroup here for convenience of API, not for early // cancellation. This means we only return nil errors in th eg.Go. eg := errgroup.Group{} diff --git a/coderd/coderd.go b/coderd/coderd.go index 4507cd1dd7605..bf10573b2888d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -939,6 +939,14 @@ func New(options *Options) *API { }) }) + // Experimental routes are not guaranteed to be stable and may change at any time. + r.Route("/api/experimental", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/aitasks", func(r chi.Router) { + r.Get("/prompts", api.aiTasksPrompts) + }) + }) + r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r @@ -972,7 +980,7 @@ func New(options *Options) *API { }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) - r.Get("/available", handleExperimentsSafe) + r.Get("/available", handleExperimentsAvailable) r.Get("/", api.handleExperimentsGet) }) r.Get("/updatecheck", api.updateCheck) @@ -1895,7 +1903,9 @@ func ReadExperiments(log slog.Logger, raw []string) codersdk.Experiments { exps = append(exps, codersdk.ExperimentsSafe...) default: ex := codersdk.Experiment(strings.ToLower(v)) - if !slice.Contains(codersdk.ExperimentsSafe, ex) { + if !slice.Contains(codersdk.ExperimentsKnown, ex) { + log.Warn(context.Background(), "ignoring unknown experiment", slog.F("experiment", ex)) + } else if !slice.Contains(codersdk.ExperimentsSafe, ex) { log.Warn(context.Background(), "🐉 HERE BE DRAGONS: opting into hidden experiment", slog.F("experiment", ex)) } exps = append(exps, ex) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3c6f691616d52..50f175a69499d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3281,6 +3281,15 @@ func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuil return q.db.GetWorkspaceBuildParameters(ctx, workspaceBuildID) } +func (q *querier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + + return q.db.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prep) +} + func (q *querier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -4518,6 +4527,28 @@ func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.U return update(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) } +func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + // An actor is allowed to update the template version AI task flag if they are authorized to update the template. + tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) + if err != nil { + return err + } + var obj rbac.Objecter + if !tv.TemplateID.Valid { + obj = rbac.ResourceTemplate.InOrg(tv.OrganizationID) + } else { + tpl, err := q.db.GetTemplateByID(ctx, tv.TemplateID.UUID) + if err != nil { + return err + } + obj = tpl + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { + return err + } + return q.db.UpdateTemplateVersionAITaskByJobID(ctx, arg) +} + func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { // An actor is allowed to update the template version if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByID(ctx, arg.ID) @@ -4874,6 +4905,24 @@ func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.Upd return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceAutostart)(ctx, arg) } +func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildAITaskByID(ctx, arg) +} + // UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. func (q *querier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { @@ -5226,6 +5275,10 @@ func (q *querier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, return q.GetWorkspacesAndAgentsByOwnerID(ctx, ownerID) } +func (q *querier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, _ rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + return q.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs) +} + // GetAuthorizedUsers is not required for dbauthz since GetUsers is already // authenticated. func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, _ rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f5077b7bdcda9..a790bcabb3832 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1391,6 +1391,24 @@ func (s *MethodTestSuite) TestTemplate() { ID: t1.ID, }).Asserts(t1, policy.ActionUpdate) })) + s.Run("UpdateTemplateVersionAITaskByJobID", s.Subtest(func(db database.Store, check *expects) { + dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) + t := dbgen.Template(s.T(), db, database.Template{OrganizationID: o.ID, CreatedBy: u.ID}) + job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) + _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + OrganizationID: o.ID, + CreatedBy: u.ID, + JobID: job.ID, + TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, + }) + check.Args(database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: job.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + }).Asserts(t, policy.ActionUpdate) + })) s.Run("UpdateTemplateWorkspacesLastUsedAt", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) t1 := dbgen.Template(s.T(), db, database.Template{}) @@ -1994,6 +2012,14 @@ func (s *MethodTestSuite) TestWorkspace() { // No asserts here because SQLFilter. check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}).Asserts() + })) + s.Run("GetAuthorizedWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -3050,6 +3076,40 @@ func (s *MethodTestSuite) TestWorkspace() { Deadline: b.Deadline, }).Asserts(w, policy.ActionUpdate) })) + s.Run("UpdateWorkspaceBuildAITaskByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + TemplateID: tpl.ID, + OrganizationID: o.ID, + OwnerID: u.ID, + }) + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ + JobID: j.ID, + WorkspaceID: w.ID, + TemplateVersionID: tv.ID, + }) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: b.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) + check.Args(database.UpdateWorkspaceBuildAITaskByIDParams{ + HasAITask: sql.NullBool{Bool: true, Valid: true}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + ID: b.ID, + }).Asserts(w, policy.ActionUpdate) + })) s.Run("SoftDeleteWorkspaceByID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 335f0a7a1cb92..98e98122e74e5 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -416,6 +416,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { DesiredInstances: preset.DesiredInstances, InvalidateAfterSecs: preset.InvalidateAfterSecs, SchedulingTimezone: preset.SchedulingTimezone, + IsDefault: false, }) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5b94b2e789c5f..2be9c28f1cc47 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -390,6 +390,9 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil t.Helper() buildID := takeFirst(orig.ID, uuid.New()) + jobID := takeFirst(orig.JobID, uuid.New()) + hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + sidebarAppID := takeFirst(orig.AITaskSidebarAppID, uuid.NullUUID{}) var build database.WorkspaceBuild err := db.InTx(func(db database.Store) error { err := db.InsertWorkspaceBuild(genCtx, database.InsertWorkspaceBuildParams{ @@ -401,7 +404,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil BuildNumber: takeFirst(orig.BuildNumber, 1), Transition: takeFirst(orig.Transition, database.WorkspaceTransitionStart), InitiatorID: takeFirst(orig.InitiatorID, uuid.New()), - JobID: takeFirst(orig.JobID, uuid.New()), + JobID: jobID, ProvisionerState: takeFirstSlice(orig.ProvisionerState, []byte{}), Deadline: takeFirst(orig.Deadline, dbtime.Now().Add(time.Hour)), MaxDeadline: takeFirst(orig.MaxDeadline, time.Time{}), @@ -410,7 +413,6 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil UUID: uuid.UUID{}, Valid: false, }), - HasAITask: orig.HasAITask, }) if err != nil { return err @@ -424,6 +426,15 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil require.NoError(t, err) } + if hasAITask.Valid { + require.NoError(t, db.UpdateWorkspaceBuildAITaskByID(genCtx, database.UpdateWorkspaceBuildAITaskByIDParams{ + HasAITask: hasAITask, + SidebarAppID: sidebarAppID, + UpdatedAt: dbtime.Now(), + ID: buildID, + })) + } + build, err = db.GetWorkspaceBuildByID(genCtx, buildID) if err != nil { return err @@ -971,6 +982,8 @@ func ExternalAuthLink(t testing.TB, db database.Store, orig database.ExternalAut func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVersion) database.TemplateVersion { var version database.TemplateVersion + hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + jobID := takeFirst(orig.JobID, uuid.New()) err := db.InTx(func(db database.Store) error { versionID := takeFirst(orig.ID, uuid.New()) err := db.InsertTemplateVersion(genCtx, database.InsertTemplateVersionParams{ @@ -982,15 +995,22 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers Name: takeFirst(orig.Name, testutil.GetRandomName(t)), Message: orig.Message, Readme: takeFirst(orig.Readme, testutil.GetRandomName(t)), - JobID: takeFirst(orig.JobID, uuid.New()), + JobID: jobID, CreatedBy: takeFirst(orig.CreatedBy, uuid.New()), SourceExampleID: takeFirst(orig.SourceExampleID, sql.NullString{}), - HasAITask: orig.HasAITask, }) if err != nil { return err } + if hasAITask.Valid { + require.NoError(t, db.UpdateTemplateVersionAITaskByJobID(genCtx, database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: jobID, + HasAITask: hasAITask, + UpdatedAt: dbtime.Now(), + })) + } + version, err = db.GetTemplateVersionByID(genCtx, versionID) if err != nil { return err @@ -1303,6 +1323,7 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d DesiredInstances: seed.DesiredInstances, InvalidateAfterSecs: seed.InvalidateAfterSecs, SchedulingTimezone: seed.SchedulingTimezone, + IsDefault: seed.IsDefault, }) require.NoError(t, err, "insert preset") return preset diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 117148a908979..cfcbc060e90a4 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7960,6 +7960,11 @@ func (q *FakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu return q.getWorkspaceBuildParametersNoLock(workspaceBuildID) } +func (q *FakeQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + // No auth filter. + return q.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, nil) +} + func (q *FakeQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -9198,6 +9203,7 @@ func (q *FakeQuerier) InsertPreset(_ context.Context, arg database.InsertPresetP Valid: true, }, PrebuildStatus: database.PrebuildStatusHealthy, + IsDefault: arg.IsDefault, } q.presets = append(q.presets, preset) return preset, nil @@ -9464,7 +9470,6 @@ func (q *FakeQuerier) InsertTemplateVersion(_ context.Context, arg database.Inse JobID: arg.JobID, CreatedBy: arg.CreatedBy, SourceExampleID: arg.SourceExampleID, - HasAITask: arg.HasAITask, } q.templateVersions = append(q.templateVersions, version) return nil @@ -10102,7 +10107,6 @@ func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser MaxDeadline: arg.MaxDeadline, Reason: arg.Reason, TemplateVersionPresetID: arg.TemplateVersionPresetID, - HasAITask: arg.HasAITask, } q.workspaceBuilds = append(q.workspaceBuilds, workspaceBuild) return nil @@ -11307,6 +11311,26 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateVersionAITaskByJobID(_ context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, templateVersion := range q.templateVersions { + if templateVersion.JobID != arg.JobID { + continue + } + templateVersion.HasAITask = arg.HasAITask + templateVersion.UpdatedAt = arg.UpdatedAt + q.templateVersions[index] = templateVersion + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database.UpdateTemplateVersionByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -12002,6 +12026,35 @@ func (q *FakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U return sql.ErrNoRows } +func (q *FakeQuerier) UpdateWorkspaceBuildAITaskByID(_ context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + if arg.HasAITask.Bool && !arg.SidebarAppID.Valid { + return xerrors.Errorf("ai_task_sidebar_app_id is required when has_ai_task is true") + } + if !arg.HasAITask.Valid && arg.SidebarAppID.Valid { + return xerrors.Errorf("ai_task_sidebar_app_id is can only be set when has_ai_task is true") + } + + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspaceBuild := range q.workspaceBuilds { + if workspaceBuild.ID != arg.ID { + continue + } + workspaceBuild.HasAITask = arg.HasAITask + workspaceBuild.AITaskSidebarAppID = arg.SidebarAppID + workspaceBuild.UpdatedAt = dbtime.Now() + q.workspaceBuilds[index] = workspaceBuild + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateWorkspaceBuildCostByID(_ context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -13853,6 +13906,30 @@ func (q *FakeQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Cont return out, nil } +func (q *FakeQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if prepared != nil { + // Call this to match the same function calls as the SQL implementation. + _, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL()) + if err != nil { + return nil, err + } + } + + filteredParameters := make([]database.WorkspaceBuildParameter, 0) + for _, buildID := range workspaceBuildIDs { + parameters, err := q.GetWorkspaceBuildParameters(ctx, buildID) + if err != nil { + return nil, err + } + filteredParameters = append(filteredParameters, parameters...) + } + + return filteredParameters, nil +} + func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 3a5bfac1ae57b..38e662ce444ac 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1873,6 +1873,13 @@ func (m queryMetricsStore) GetWorkspaceBuildParameters(ctx context.Context, work return params, err } +func (m queryMetricsStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds) + m.queryLatencies.WithLabelValues("GetWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceBuildStatsByTemplates(ctx, since) @@ -2825,6 +2832,13 @@ func (m queryMetricsStore) UpdateTemplateScheduleByID(ctx context.Context, arg d return err } +func (m queryMetricsStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionAITaskByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateVersionByID(ctx, arg) @@ -3028,6 +3042,13 @@ func (m queryMetricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg dat return err } +func (m queryMetricsStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildAITaskByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceBuildCostByID(ctx, arg) @@ -3329,6 +3350,13 @@ func (m queryMetricsStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context return r0, r1 } +func (m queryMetricsStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedUsers(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 067ad062e1dd8..586ebf9f60fab 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1247,6 +1247,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedUsers(ctx, arg, prepared any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedUsers", reflect.TypeOf((*MockStore)(nil).GetAuthorizedUsers), ctx, arg, prepared) } +// GetAuthorizedWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIDs, prepared) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedWorkspaceBuildParametersByBuildIDs indicates an expected call of GetAuthorizedWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIDs, prepared) +} + // GetAuthorizedWorkspaces mocks base method. func (m *MockStore) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { m.ctrl.T.Helper() @@ -3932,6 +3947,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceBuildParameters(ctx, workspaceBuild return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParameters), ctx, workspaceBuildID) } +// GetWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIds) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceBuildParametersByBuildIDs indicates an expected call of GetWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIds) +} + // GetWorkspaceBuildStatsByTemplates mocks base method. func (m *MockStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { m.ctrl.T.Helper() @@ -5977,6 +6007,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateScheduleByID(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateScheduleByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateScheduleByID), ctx, arg) } +// UpdateTemplateVersionAITaskByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskByJobID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateVersionAITaskByJobID indicates an expected call of UpdateTemplateVersionAITaskByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskByJobID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskByJobID), ctx, arg) +} + // UpdateTemplateVersionByID mocks base method. func (m *MockStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { m.ctrl.T.Helper() @@ -6395,6 +6439,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAutostart(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutostart", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutostart), ctx, arg) } +// UpdateWorkspaceBuildAITaskByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildAITaskByID indicates an expected call of UpdateWorkspaceBuildAITaskByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskByID), ctx, arg) +} + // UpdateWorkspaceBuildCostByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 17b16abc5715c..522f24537e576 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1513,7 +1513,8 @@ CREATE TABLE template_version_presets ( desired_instances integer, invalidate_after_secs integer DEFAULT 0, prebuild_status prebuild_status DEFAULT 'healthy'::prebuild_status NOT NULL, - scheduling_timezone text DEFAULT ''::text NOT NULL + scheduling_timezone text DEFAULT ''::text NOT NULL, + is_default boolean DEFAULT false NOT NULL ); CREATE TABLE template_version_terraform_values ( @@ -2098,7 +2099,8 @@ CREATE TABLE workspace_builds ( max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, template_version_preset_id uuid, has_ai_task boolean, - ai_tasks_sidebar_app_id uuid + ai_task_sidebar_app_id uuid, + CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))) ); CREATE VIEW workspace_build_with_user AS @@ -2118,7 +2120,7 @@ CREATE VIEW workspace_build_with_user AS workspace_builds.max_deadline, workspace_builds.template_version_preset_id, workspace_builds.has_ai_task, - workspace_builds.ai_tasks_sidebar_app_id, + workspace_builds.ai_task_sidebar_app_id, COALESCE(visible_users.avatar_url, ''::text) AS initiator_by_avatar_url, COALESCE(visible_users.username, ''::text) AS initiator_by_username, COALESCE(visible_users.name, ''::text) AS initiator_by_name @@ -2690,6 +2692,8 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id); CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); +CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); + CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree (has_ai_task); CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); @@ -3092,7 +3096,7 @@ ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; ALTER TABLE ONLY workspace_builds - ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index ea1ffaf4c8064..c0720bcb5fcdd 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -83,7 +83,7 @@ const ( ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildsAiTasksSidebarAppID ForeignKeyConstraint = "workspace_builds_ai_tasks_sidebar_app_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + ForeignKeyWorkspaceBuildsAiTaskSidebarAppID ForeignKeyConstraint = "workspace_builds_ai_task_sidebar_app_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; diff --git a/coderd/database/migrations/000341_template_version_preset_default.down.sql b/coderd/database/migrations/000341_template_version_preset_default.down.sql new file mode 100644 index 0000000000000..a48a6dc44bab8 --- /dev/null +++ b/coderd/database/migrations/000341_template_version_preset_default.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_template_version_presets_default; +ALTER TABLE template_version_presets DROP COLUMN IF EXISTS is_default; \ No newline at end of file diff --git a/coderd/database/migrations/000341_template_version_preset_default.up.sql b/coderd/database/migrations/000341_template_version_preset_default.up.sql new file mode 100644 index 0000000000000..9a58d0b7dd778 --- /dev/null +++ b/coderd/database/migrations/000341_template_version_preset_default.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE template_version_presets ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add a unique constraint to ensure only one default preset per template version +CREATE UNIQUE INDEX idx_template_version_presets_default +ON template_version_presets (template_version_id) +WHERE is_default = TRUE; \ No newline at end of file diff --git a/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql new file mode 100644 index 0000000000000..613e17ed20933 --- /dev/null +++ b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql @@ -0,0 +1,52 @@ +-- Drop the check constraint first +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required; + +-- Revert ai_task_sidebar_app_id back to ai_tasks_sidebar_app_id in workspace_builds table +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey; + +ALTER TABLE workspace_builds RENAME COLUMN ai_task_sidebar_app_id TO ai_tasks_sidebar_app_id; + +ALTER TABLE workspace_builds ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + +-- Revert the workspace_build_with_user view to use the original column name +DROP VIEW workspace_build_with_user; + +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_tasks_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql new file mode 100644 index 0000000000000..3577b9396b0df --- /dev/null +++ b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql @@ -0,0 +1,66 @@ +-- Rename ai_tasks_sidebar_app_id to ai_task_sidebar_app_id in workspace_builds table +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey; + +ALTER TABLE workspace_builds RENAME COLUMN ai_tasks_sidebar_app_id TO ai_task_sidebar_app_id; + +ALTER TABLE workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); + +-- if has_ai_task is true, ai_task_sidebar_app_id MUST be set +-- ai_task_sidebar_app_id can ONLY be set if has_ai_task is true +-- +-- has_ai_task | ai_task_sidebar_app_id | Result +-- ------------|------------------------|--------------- +-- NULL | NULL | TRUE (passes) +-- NULL | NOT NULL | FALSE (fails) +-- FALSE | NULL | TRUE (passes) +-- FALSE | NOT NULL | FALSE (fails) +-- TRUE | NULL | FALSE (fails) +-- TRUE | NOT NULL | TRUE (passes) +ALTER TABLE workspace_builds + ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK ( + ((has_ai_task IS NULL OR has_ai_task = false) AND ai_task_sidebar_app_id IS NULL) + OR (has_ai_task = true AND ai_task_sidebar_app_id IS NOT NULL) + ); + +-- Update the workspace_build_with_user view to use the new column name +DROP VIEW workspace_build_with_user; + +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 931412204d780..eaf73af07f6d5 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -226,6 +226,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) + GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -372,6 +373,35 @@ func (q *sqlQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Conte return items, nil } +func (q *sqlQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getWorkspaceBuildParametersByBuildIDs, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceBuildParametersByBuildIDs :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, pq.Array(workspaceBuildIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + type userQuerier interface { GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error) } diff --git a/coderd/database/models.go b/coderd/database/models.go index ce65c4c559d65..d964b0e2e57ad 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3414,6 +3414,7 @@ type TemplateVersionPreset struct { InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` } type TemplateVersionPresetParameter struct { @@ -3864,7 +3865,7 @@ type WorkspaceBuild struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - AITasksSidebarAppID uuid.NullUUID `db:"ai_tasks_sidebar_app_id" json:"ai_tasks_sidebar_app_id"` + AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` InitiatorByAvatarUrl string `db:"initiator_by_avatar_url" json:"initiator_by_avatar_url"` InitiatorByUsername string `db:"initiator_by_username" json:"initiator_by_username"` InitiatorByName string `db:"initiator_by_name" json:"initiator_by_name"` @@ -3895,7 +3896,7 @@ type WorkspaceBuildTable struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - AITasksSidebarAppID uuid.NullUUID `db:"ai_tasks_sidebar_app_id" json:"ai_tasks_sidebar_app_id"` + AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` } type WorkspaceLatestBuild struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 06f51785aa1af..2300402c27d07 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -428,6 +428,7 @@ type sqlcQuerier interface { GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) + GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]GetWorkspaceBuildStatsByTemplatesRow, error) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) @@ -596,6 +597,7 @@ type sqlcQuerier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error + UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error @@ -625,6 +627,7 @@ type sqlcQuerier interface { UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error + UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3291e85b7576d..cf98b03245099 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6629,7 +6629,7 @@ func (q *sqlQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]Te } const getPresetByID = `-- name: GetPresetByID :one -SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tv.template_id, tv.organization_id FROM +SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tvp.is_default, tv.template_id, tv.organization_id FROM template_version_presets tvp INNER JOIN template_versions tv ON tvp.template_version_id = tv.id WHERE tvp.id = $1 @@ -6644,6 +6644,7 @@ type GetPresetByIDRow struct { InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } @@ -6660,6 +6661,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, &i.TemplateID, &i.OrganizationID, ) @@ -6668,7 +6670,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get const getPresetByWorkspaceBuildID = `-- name: GetPresetByWorkspaceBuildID :one SELECT - template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone + template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone, template_version_presets.is_default FROM template_version_presets INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id @@ -6688,6 +6690,7 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ) return i, err } @@ -6769,7 +6772,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context, const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many SELECT - id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone + id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default FROM template_version_presets WHERE @@ -6794,6 +6797,7 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ); err != nil { return nil, err } @@ -6816,7 +6820,8 @@ INSERT INTO template_version_presets ( created_at, desired_instances, invalidate_after_secs, - scheduling_timezone + scheduling_timezone, + is_default ) VALUES ( $1, @@ -6825,8 +6830,9 @@ VALUES ( $4, $5, $6, - $7 -) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone + $7, + $8 +) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default ` type InsertPresetParams struct { @@ -6837,6 +6843,7 @@ type InsertPresetParams struct { DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` } func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) { @@ -6848,6 +6855,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( arg.DesiredInstances, arg.InvalidateAfterSecs, arg.SchedulingTimezone, + arg.IsDefault, ) var i TemplateVersionPreset err := row.Scan( @@ -6859,6 +6867,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ) return i, err } @@ -11917,11 +11926,10 @@ INSERT INTO readme, job_id, created_by, - source_example_id, - has_ai_task + source_example_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ` type InsertTemplateVersionParams struct { @@ -11936,7 +11944,6 @@ type InsertTemplateVersionParams struct { JobID uuid.UUID `db:"job_id" json:"job_id"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` } func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error { @@ -11952,7 +11959,6 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla arg.JobID, arg.CreatedBy, arg.SourceExampleID, - arg.HasAITask, ) return err } @@ -11978,6 +11984,27 @@ func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg Unarchive return err } +const updateTemplateVersionAITaskByJobID = `-- name: UpdateTemplateVersionAITaskByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + updated_at = $3 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionAITaskByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskByJobID, arg.JobID, arg.HasAITask, arg.UpdatedAt) + return err +} + const updateTemplateVersionByID = `-- name: UpdateTemplateVersionByID :exec UPDATE template_versions @@ -14303,7 +14330,7 @@ const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAn SELECT 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, 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, - workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_tasks_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name + workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name FROM workspace_agents JOIN @@ -14413,7 +14440,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont &i.WorkspaceBuild.MaxDeadline, &i.WorkspaceBuild.TemplateVersionPresetID, &i.WorkspaceBuild.HasAITask, - &i.WorkspaceBuild.AITasksSidebarAppID, + &i.WorkspaceBuild.AITaskSidebarAppID, &i.WorkspaceBuild.InitiatorByAvatarUrl, &i.WorkspaceBuild.InitiatorByUsername, &i.WorkspaceBuild.InitiatorByName, @@ -17008,6 +17035,44 @@ func (q *sqlQuerier) GetWorkspaceBuildParameters(ctx context.Context, workspaceB return items, nil } +const getWorkspaceBuildParametersByBuildIDs = `-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.workspace_build_id, workspace_build_parameters.name, workspace_build_parameters.value +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY($1 :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +` + +func (q *sqlQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildParametersByBuildIDs, pq.Array(workspaceBuildIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); 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 insertWorkspaceBuildParameters = `-- name: InsertWorkspaceBuildParameters :exec INSERT INTO workspace_build_parameters (workspace_build_id, name, value) @@ -17030,7 +17095,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins } const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17086,7 +17151,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17186,7 +17251,7 @@ func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, a const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17217,7 +17282,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17226,7 +17291,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17266,7 +17331,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17285,7 +17350,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17327,7 +17392,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17347,7 +17412,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17376,7 +17441,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17386,7 +17451,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17415,7 +17480,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17425,7 +17490,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17458,7 +17523,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17535,7 +17600,7 @@ func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, sinc const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17607,7 +17672,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17626,7 +17691,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -17655,7 +17720,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17689,11 +17754,10 @@ INSERT INTO deadline, max_deadline, reason, - template_version_preset_id, - has_ai_task + template_version_preset_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ` type InsertWorkspaceBuildParams struct { @@ -17711,7 +17775,6 @@ type InsertWorkspaceBuildParams struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` Reason BuildReason `db:"reason" json:"reason"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` } func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error { @@ -17730,7 +17793,33 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.MaxDeadline, arg.Reason, arg.TemplateVersionPresetID, + ) + return err +} + +const updateWorkspaceBuildAITaskByID = `-- name: UpdateWorkspaceBuildAITaskByID :exec +UPDATE + workspace_builds +SET + has_ai_task = $1, + ai_task_sidebar_app_id = $2, + updated_at = $3::timestamptz +WHERE id = $4::uuid +` + +type UpdateWorkspaceBuildAITaskByIDParams struct { + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskByID, arg.HasAITask, + arg.SidebarAppID, + arg.UpdatedAt, + arg.ID, ) return err } @@ -19354,7 +19443,8 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT workspaces.id, - workspaces.name + workspaces.name, + workspace_builds.template_version_id as build_template_version_id FROM workspaces LEFT JOIN @@ -19473,8 +19563,9 @@ WHERE ` type GetWorkspacesEligibleForTransitionRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"` } func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) { @@ -19486,7 +19577,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now var items []GetWorkspacesEligibleForTransitionRow for rows.Next() { var i GetWorkspacesEligibleForTransitionRow - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/presets.sql b/coderd/database/queries/presets.sql index 13cba4c57e173..d275e4744c729 100644 --- a/coderd/database/queries/presets.sql +++ b/coderd/database/queries/presets.sql @@ -6,7 +6,8 @@ INSERT INTO template_version_presets ( created_at, desired_instances, invalidate_after_secs, - scheduling_timezone + scheduling_timezone, + is_default ) VALUES ( @id, @@ -15,7 +16,8 @@ VALUES ( @created_at, @desired_instances, @invalidate_after_secs, - @scheduling_timezone + @scheduling_timezone, + @is_default ) RETURNING *; -- name: InsertPresetParameters :many diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index ac88a8b493152..4a37413d2f439 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -88,11 +88,10 @@ INSERT INTO readme, job_id, created_by, - source_example_id, - has_ai_task + source_example_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); -- name: UpdateTemplateVersionByID :exec UPDATE @@ -123,6 +122,15 @@ SET WHERE job_id = $1; +-- name: UpdateTemplateVersionAITaskByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + updated_at = $3 +WHERE + job_id = $1; + -- name: GetPreviousTemplateVersion :one SELECT * diff --git a/coderd/database/queries/workspacebuildparameters.sql b/coderd/database/queries/workspacebuildparameters.sql index 5cda9c020641f..b639a553ef273 100644 --- a/coderd/database/queries/workspacebuildparameters.sql +++ b/coderd/database/queries/workspacebuildparameters.sql @@ -41,3 +41,18 @@ FROM ( ) q1 ORDER BY created_at DESC, name LIMIT 100; + +-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.* +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY(@workspace_build_ids :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index b380e5423c21c..be76b6642df1f 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -121,11 +121,10 @@ INSERT INTO deadline, max_deadline, reason, - template_version_preset_id, - has_ai_task + template_version_preset_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); -- name: UpdateWorkspaceBuildCostByID :exec UPDATE @@ -152,6 +151,15 @@ SET updated_at = @updated_at::timestamptz WHERE id = @id::uuid; +-- name: UpdateWorkspaceBuildAITaskByID :exec +UPDATE + workspace_builds +SET + has_ai_task = @has_ai_task, + ai_task_sidebar_app_id = @sidebar_app_id, + updated_at = @updated_at::timestamptz +WHERE id = @id::uuid; + -- name: GetActiveWorkspaceBuildsByTemplateID :many SELECT wb.* FROM ( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f6ee14ae0ac7d..25e4d4f97a46b 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -642,7 +642,8 @@ FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspa -- name: GetWorkspacesEligibleForTransition :many SELECT workspaces.id, - workspaces.name + workspaces.name, + workspace_builds.template_version_id as build_template_version_id FROM workspaces LEFT JOIN diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 85bb286881f97..b96dabd1fc187 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -148,7 +148,7 @@ sql: crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert stale_interval_ms: StaleIntervalMS has_ai_task: HasAITask - ai_tasks_sidebar_app_id: AITasksSidebarAppID + ai_task_sidebar_app_id: AITaskSidebarAppID latest_build_has_ai_task: LatestBuildHasAITask rules: - name: do-not-use-public-schema-in-queries diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9a109c2fcab70..f8a4821fa7640 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -106,6 +106,7 @@ const ( UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); + UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); diff --git a/coderd/devtunnel/tunnel_test.go b/coderd/devtunnel/tunnel_test.go index 50573c19e5f27..e8f526fed7db0 100644 --- a/coderd/devtunnel/tunnel_test.go +++ b/coderd/devtunnel/tunnel_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "runtime" "strconv" "strings" "testing" @@ -33,6 +34,10 @@ import ( func TestTunnel(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("these tests are flaky on windows and cause the tests to fail with '(unknown)' and no output, see https://github.com/coder/internal/issues/579") + } + cases := []struct { name string version tunnelsdk.TunnelVersion @@ -173,6 +178,7 @@ func newTunnelServer(t *testing.T) *tunnelServer { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if handler != nil { handler.ServeHTTP(w, r) + return } w.WriteHeader(http.StatusBadGateway) @@ -207,6 +213,7 @@ func newTunnelServer(t *testing.T) *tunnelServer { if err == nil { break } + td = nil t.Logf("failed to create tunnel server on port %d: %s", wireguardPort, err) } if td == nil { diff --git a/coderd/experiments.go b/coderd/experiments.go index 6f03daa4e9d88..a0949e9411664 100644 --- a/coderd/experiments.go +++ b/coderd/experiments.go @@ -26,7 +26,7 @@ func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) { // @Tags General // @Success 200 {array} codersdk.Experiment // @Router /experiments/available [get] -func handleExperimentsSafe(rw http.ResponseWriter, r *http.Request) { +func handleExperimentsAvailable(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() httpapi.Write(ctx, rw, http.StatusOK, codersdk.AvailableExperiments{ Safe: codersdk.ExperimentsSafe, diff --git a/coderd/files/cache.go b/coderd/files/cache.go index 32e03e0b8f209..3698aac9286c8 100644 --- a/coderd/files/cache.go +++ b/coderd/files/cache.go @@ -71,12 +71,12 @@ func (c *Cache) registerMetrics(registerer prometheus.Registerer) *Cache { Help: "The count of file references currently open in the file cache. Multiple references can be held for the same file.", }) - c.totalOpenFileReferences = f.NewCounter(prometheus.CounterOpts{ + c.totalOpenFileReferences = f.NewCounterVec(prometheus.CounterOpts{ Namespace: "coderd", Subsystem: subsystem, Name: "open_file_refs_total", - Help: "The total number of file references ever opened in the file cache.", - }) + Help: "The total number of file references ever opened in the file cache. The 'hit' label indicates if the file was loaded from the cache.", + }, []string{"hit"}) return c } @@ -97,7 +97,7 @@ type Cache struct { type cacheMetrics struct { currentOpenFileReferences prometheus.Gauge - totalOpenFileReferences prometheus.Counter + totalOpenFileReferences *prometheus.CounterVec currentOpenFiles prometheus.Gauge totalOpenedFiles prometheus.Counter @@ -173,6 +173,7 @@ func (c *Cache) prepare(ctx context.Context, db database.Store, fileID uuid.UUID c.lock.Lock() defer c.lock.Unlock() + hitLabel := "true" entry, ok := c.data[fileID] if !ok { value := lazy.NewWithError(func() (CacheEntryValue, error) { @@ -194,10 +195,11 @@ func (c *Cache) prepare(ctx context.Context, db database.Store, fileID uuid.UUID c.data[fileID] = entry c.currentOpenFiles.Inc() c.totalOpenedFiles.Inc() + hitLabel = "false" } c.currentOpenFileReferences.Inc() - c.totalOpenFileReferences.Inc() + c.totalOpenFileReferences.WithLabelValues(hitLabel).Inc() entry.refCount++ return entry.value } diff --git a/coderd/files/cache_test.go b/coderd/files/cache_test.go index a5a5dfae268ca..8a8acfbc0772f 100644 --- a/coderd/files/cache_test.go +++ b/coderd/files/cache_test.go @@ -161,7 +161,9 @@ func TestConcurrency(t *testing.T) { require.Equal(t, batches, promhelp.GaugeValue(t, reg, cachePromMetricName("open_files_current"), nil)) require.Equal(t, batches, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_total"), nil)) require.Equal(t, batches*batchSize, promhelp.GaugeValue(t, reg, cachePromMetricName("open_file_refs_current"), nil)) - require.Equal(t, batches*batchSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), nil)) + hit, miss := promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), prometheus.Labels{"hit": "false"}), + promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), prometheus.Labels{"hit": "true"}) + require.Equal(t, batches*batchSize, hit+miss) } func TestRelease(t *testing.T) { @@ -245,7 +247,6 @@ func TestRelease(t *testing.T) { // Total counts remain require.Equal(t, batches*fileSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_size_bytes_total"), nil)) require.Equal(t, batches, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_total"), nil)) - require.Equal(t, batches*batchSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), nil)) } func cacheAuthzSetup(t *testing.T) (database.Store, *files.Cache, *coderdtest.RecordingAuthorizer) { diff --git a/coderd/presets.go b/coderd/presets.go index 1b5f646438339..151a1e7d5a904 100644 --- a/coderd/presets.go +++ b/coderd/presets.go @@ -41,8 +41,9 @@ func (api *API) templateVersionPresets(rw http.ResponseWriter, r *http.Request) var res []codersdk.Preset for _, preset := range presets { sdkPreset := codersdk.Preset{ - ID: preset.ID, - Name: preset.Name, + ID: preset.ID, + Name: preset.Name, + Default: preset.IsDefault, } for _, presetParam := range presetParams { if presetParam.TemplateVersionPresetID != preset.ID { diff --git a/coderd/presets_test.go b/coderd/presets_test.go index 1239cb655a63f..99472a013600d 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -1,8 +1,10 @@ package coderd_test import ( + "slices" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" @@ -137,3 +139,93 @@ func TestTemplateVersionPresets(t *testing.T) { }) } } + +func TestTemplateVersionPresetsDefault(t *testing.T) { + t.Parallel() + + type expectedPreset struct { + name string + isDefault bool + } + + cases := []struct { + name string + presets []database.InsertPresetParams + expected []expectedPreset + }{ + { + name: "no presets", + presets: nil, + expected: nil, + }, + { + name: "single default preset", + presets: []database.InsertPresetParams{ + {Name: "Default Preset", IsDefault: true}, + }, + expected: []expectedPreset{ + {name: "Default Preset", isDefault: true}, + }, + }, + { + name: "single non-default preset", + presets: []database.InsertPresetParams{ + {Name: "Regular Preset", IsDefault: false}, + }, + expected: []expectedPreset{ + {name: "Regular Preset", isDefault: false}, + }, + }, + { + name: "mixed presets", + presets: []database.InsertPresetParams{ + {Name: "Default Preset", IsDefault: true}, + {Name: "Regular Preset", IsDefault: false}, + }, + expected: []expectedPreset{ + {name: "Default Preset", isDefault: true}, + {name: "Regular Preset", isDefault: false}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + // Create presets + for _, preset := range tc.presets { + preset.TemplateVersionID = version.ID + _ = dbgen.Preset(t, db, preset) + } + + // Get presets via API + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll) + require.NoError(t, err) + userCtx := dbauthz.As(ctx, userSubject) + + gotPresets, err := client.TemplateVersionPresets(userCtx, version.ID) + require.NoError(t, err) + + // Verify results + require.Len(t, gotPresets, len(tc.expected)) + + for _, expected := range tc.expected { + found := slices.ContainsFunc(gotPresets, func(preset codersdk.Preset) bool { + if preset.Name != expected.name { + return false + } + + return assert.Equal(t, expected.isDefault, preset.Default) + }) + require.True(t, found, "Expected preset %s not found", expected.name) + } + }) + } +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 580520e6e4b4b..f545169c93b31 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1655,6 +1655,17 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro if err != nil { return xerrors.Errorf("update template version external auth providers: %w", err) } + err = db.UpdateTemplateVersionAITaskByJobID(ctx, database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: jobID, + HasAITask: sql.NullBool{ + Bool: jobType.TemplateImport.HasAiTasks, + Valid: true, + }, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update template version external auth providers: %w", err) + } // Process terraform values plan := jobType.TemplateImport.Plan @@ -1867,6 +1878,37 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } } + var sidebarAppID uuid.NullUUID + hasAITask := len(jobType.WorkspaceBuild.AiTasks) == 1 + if hasAITask { + task := jobType.WorkspaceBuild.AiTasks[0] + if task.SidebarApp == nil { + return xerrors.Errorf("update ai task: sidebar app is nil") + } + + id, err := uuid.Parse(task.SidebarApp.Id) + if err != nil { + return xerrors.Errorf("parse sidebar app id: %w", err) + } + + sidebarAppID = uuid.NullUUID{UUID: id, Valid: true} + } + + // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it + // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. + err = db.UpdateWorkspaceBuildAITaskByID(ctx, database.UpdateWorkspaceBuildAITaskByIDParams{ + ID: workspaceBuild.ID, + HasAITask: sql.NullBool{ + Bool: hasAITask, + Valid: true, + }, + SidebarAppID: sidebarAppID, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update workspace build ai tasks flag: %w", err) + } + // Insert timings inside the transaction now // nolint:exhaustruct // The other fields are set further down. params := database.InsertProvisionerJobTimingsParams{ @@ -2230,6 +2272,7 @@ func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store, DesiredInstances: desiredInstances, InvalidateAfterSecs: ttl, SchedulingTimezone: schedulingTimezone, + IsDefault: protoPreset.GetDefault(), }) if err != nil { return xerrors.Errorf("insert preset: %w", err) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index dfa767b297f4c..66684835650a8 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -2435,6 +2435,233 @@ func TestCompleteJob(t *testing.T) { testutil.RequireReceive(ctx, t, done) require.Equal(t, replacements, orchestrator.replacements) }) + + t.Run("AITasks", func(t *testing.T) { + t.Parallel() + + // has_ai_task has a default value of nil, but once the template import completes it will have a value; + // it is set to "true" if the template has any coder_ai_task resources defined. + t.Run("TemplateImport", func(t *testing.T) { + type testcase struct { + name string + input *proto.CompletedJob_TemplateImport + expected bool + } + + for _, tc := range []testcase{ + { + name: "has_ai_task is false by default", + input: &proto.CompletedJob_TemplateImport{ + // HasAiTasks is not set. + Plan: []byte("{}"), + }, + expected: false, + }, + { + name: "has_ai_task gets set to true", + input: &proto.CompletedJob_TemplateImport{ + HasAiTasks: true, + Plan: []byte("{}"), + }, + expected: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, db, _, pd := setup(t, false, &overrides{}) + + importJobID := uuid.New() + tvID := uuid.New() + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: tvID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: importJobID, + }) + _ = version + + ctx := testutil.Context(t, testutil.WaitShort) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: importJobID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: pd.OrganizationID, + InitiatorID: uuid.New(), + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: tvID, + })), + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, + }) + require.NoError(t, err) + + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, + }) + require.NoError(t, err) + + version, err = db.GetTemplateVersionByID(ctx, tvID) + require.NoError(t, err) + require.False(t, version.HasAITask.Valid) // Value should be nil (i.e. valid = false). + + completedJob := proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateImport_{ + TemplateImport: tc.input, + }, + } + _, err = srv.CompleteJob(ctx, &completedJob) + require.NoError(t, err) + + version, err = db.GetTemplateVersionByID(ctx, tvID) + require.NoError(t, err) + require.True(t, version.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. + require.Equal(t, tc.expected, version.HasAITask.Bool) + }) + } + }) + + // has_ai_task has a default value of nil, but once the workspace build completes it will have a value; + // it is set to "true" if the related template has any coder_ai_task resources defined, and its sidebar app ID + // will be set as well in that case. + t.Run("WorkspaceBuild", func(t *testing.T) { + type testcase struct { + name string + input *proto.CompletedJob_WorkspaceBuild + expected bool + } + + sidebarAppID := uuid.NewString() + for _, tc := range []testcase{ + { + name: "has_ai_task is false by default", + input: &proto.CompletedJob_WorkspaceBuild{ + // No AiTasks defined. + }, + expected: false, + }, + { + name: "has_ai_task is set to true", + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &sdkproto.AITaskSidebarApp{ + Id: sidebarAppID, + }, + }, + }, + }, + expected: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, db, _, pd := setup(t, false, &overrides{}) + + importJobID := uuid.New() + tvID := uuid.New() + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: tvID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: importJobID, + }) + user := dbgen.User(t, db, database.User{}) + workspaceTable := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: template.ID, + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + InitiatorID: user.ID, + Transition: database.WorkspaceTransitionStart, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: importJobID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: pd.OrganizationID, + InitiatorID: uuid.New(), + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + LogLevel: "DEBUG", + })), + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: pd.Tags, + }) + require.NoError(t, err) + + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, + }) + require.NoError(t, err) + + build, err = db.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + require.False(t, build.HasAITask.Valid) // Value should be nil (i.e. valid = false). + + completedJob := proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: tc.input, + }, + } + _, err = srv.CompleteJob(ctx, &completedJob) + require.NoError(t, err) + + build, err = db.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + require.True(t, build.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. + require.Equal(t, tc.expected, build.HasAITask.Bool) + + if tc.expected { + require.Equal(t, sidebarAppID, build.AITaskSidebarAppID.UUID.String()) + } + }) + } + }) + }) } type mockPrebuildsOrchestrator struct { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index d9f9c3db42dd6..d79f86f1f6626 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1730,12 +1730,6 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht String: req.ExampleID, Valid: req.ExampleID != "", }, - // appease the exhaustruct linter - // TODO: set this to whether the template version defines a `coder_ai_task` tf resource - HasAITask: sql.NullBool{ - Bool: false, - Valid: false, - }, }) if err != nil { if database.IsUniqueViolation(err, database.UniqueTemplateVersionsTemplateIDNameKey) { diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 850e9dd457dd8..76e271ff3aee6 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1108,8 +1108,8 @@ func (api *API) convertWorkspaceBuild( hasAITask = &build.HasAITask.Bool } var aiTasksSidebarAppID *uuid.UUID - if build.AITasksSidebarAppID.Valid { - aiTasksSidebarAppID = &build.AITasksSidebarAppID.UUID + if build.AITaskSidebarAppID.Valid { + aiTasksSidebarAppID = &build.AITaskSidebarAppID.UUID } apiJob := convertProvisionerJob(job) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 74554962fbd1e..d51a228a3f7a1 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4509,7 +4509,10 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { // Helper function to create workspace with AI task configuration createWorkspaceWithAIConfig := func(hasAITask sql.NullBool, jobCompleted bool, aiTaskPrompt *string) database.WorkspaceTable { - // When a provisioner job uses these tags, no provisioner will match it + // When a provisioner job uses these tags, no provisioner will match it. + // We do this so jobs will always be stuck in "pending", allowing us to exercise the intermediary state when + // has_ai_task is nil and we compensate by looking at pending provisioning jobs. + // See GetWorkspaces clauses. unpickableTags := database.StringMap{"custom": "true"} ws := dbgen.Workspace(t, db, database.WorkspaceTable{ @@ -4528,20 +4531,30 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { } job := dbgen.ProvisionerJob(t, db, pubsub, jobConfig) + res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID}) + agnt := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID}) + + var sidebarAppID uuid.UUID + if hasAITask.Bool { + sidebarApp := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agnt.ID}) + sidebarAppID = sidebarApp.ID + } + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: ws.ID, - TemplateVersionID: version.ID, - InitiatorID: user.UserID, - JobID: job.ID, - BuildNumber: 1, - HasAITask: hasAITask, + WorkspaceID: ws.ID, + TemplateVersionID: version.ID, + InitiatorID: user.UserID, + JobID: job.ID, + BuildNumber: 1, + HasAITask: hasAITask, + AITaskSidebarAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: sidebarAppID != uuid.Nil}, }) if aiTaskPrompt != nil { //nolint:gocritic // unit test err := db.InsertWorkspaceBuildParameters(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: build.ID, - Name: []string{"AI Prompt"}, + Name: []string{provider.TaskPromptParameterName}, Value: []string{*aiTaskPrompt}, }) require.NoError(t, err) @@ -4551,7 +4564,7 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { } // Create test workspaces with different AI task configurations - wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, false, nil) + wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, true, nil) wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil) aiTaskPrompt := "Build me a web app" @@ -4699,3 +4712,51 @@ func TestWorkspaceAppUpsertRestart(t *testing.T) { require.Equal(t, codersdk.ProvisionerJobSucceeded, workspace.LatestBuild.Job.Status) require.Empty(t, workspace.LatestBuild.Job.Error) } + +func TestMultipleAITasksDisallowed(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + HasAiTasks: true, + AiTasks: []*proto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &proto.AITaskSidebarApp{ + Id: uuid.NewString(), + }, + }, + { + Id: uuid.NewString(), + SidebarApp: &proto.AITaskSidebarApp{ + Id: uuid.NewString(), + }, + }, + }, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + //nolint: gocritic // testing + ctx := dbauthz.AsSystemRestricted(t.Context()) + pj, err := db.GetProvisionerJobByID(ctx, ws.LatestBuild.Job.ID) + require.NoError(t, err) + require.Contains(t, pj.Error.String, "only one 'coder_ai_task' resource can be provisioned per template") +} diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index f327e071c3946..0b11240e19db0 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -426,12 +426,6 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object UUID: b.templateVersionPresetID, Valid: b.templateVersionPresetID != uuid.Nil, }, - // appease the exhaustruct linter - // TODO: set this to whether the build included a `coder_ai_task` tf resource - HasAITask: sql.NullBool{ - Bool: false, - Valid: false, - }, }) if err != nil { code := http.StatusInternalServerError diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go new file mode 100644 index 0000000000000..89ca9c948f272 --- /dev/null +++ b/codersdk/aitasks.go @@ -0,0 +1,46 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/terraform-provider-coder/v2/provider" +) + +const AITaskPromptParameterName = provider.TaskPromptParameterName + +type AITasksPromptsResponse struct { + // Prompts is a map of workspace build IDs to prompts. + Prompts map[string]string `json:"prompts"` +} + +// AITaskPrompts returns prompts for multiple workspace builds by their IDs. +func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) { + if len(buildIDs) == 0 { + return AITasksPromptsResponse{ + Prompts: make(map[string]string), + }, nil + } + + // Convert UUIDs to strings and join them + buildIDStrings := make([]string, len(buildIDs)) + for i, id := range buildIDs { + buildIDStrings[i] = id.String() + } + buildIDsParam := strings.Join(buildIDStrings, ",") + + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/aitasks/prompts", nil, WithQueryParam("build_ids", buildIDsParam)) + if err != nil { + return AITasksPromptsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AITasksPromptsResponse{}, ReadBodyAsError(res) + } + var prompts AITasksPromptsResponse + return prompts, json.NewDecoder(res.Body).Decode(&prompts) +} diff --git a/codersdk/client_experimental.go b/codersdk/client_experimental.go new file mode 100644 index 0000000000000..e37b4d0c86a4f --- /dev/null +++ b/codersdk/client_experimental.go @@ -0,0 +1,14 @@ +package codersdk + +// ExperimentalClient is a client for the experimental API. +// Its interface is not guaranteed to be stable and may change at any time. +// @typescript-ignore ExperimentalClient +type ExperimentalClient struct { + *Client +} + +func NewExperimentalClient(client *Client) *ExperimentalClient { + return &ExperimentalClient{ + Client: client, + } +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 90e8a4c879ec5..ce15ee407a8f3 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3372,6 +3372,18 @@ const ( ExperimentAITasks Experiment = "ai-tasks" // Enables the new AI tasks feature. ) +// ExperimentsKnown should include all experiments defined above. +var ExperimentsKnown = Experiments{ + ExperimentExample, + ExperimentAutoFillParameters, + ExperimentNotifications, + ExperimentWorkspaceUsage, + ExperimentWebPush, + ExperimentWorkspacePrebuilds, + ExperimentAgenticChat, + ExperimentAITasks, +} + // ExperimentsSafe should include all experiments that are safe for // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should @@ -3384,6 +3396,9 @@ var ExperimentsSafe = Experiments{ // Multiple experiments may be enabled at the same time. // Experiments are not safe for production use, and are not guaranteed to // be backwards compatible. They may be removed or renamed at any time. +// The below typescript-ignore annotation allows our typescript generator +// to generate an enum list, which is used in the frontend. +// @typescript-ignore Experiments type Experiments []Experiment // Returns a list of experiments that are enabled for the deployment. diff --git a/codersdk/presets.go b/codersdk/presets.go index 110f6c605f026..60253d6595c51 100644 --- a/codersdk/presets.go +++ b/codersdk/presets.go @@ -14,6 +14,7 @@ type Preset struct { ID uuid.UUID Name string Parameters []PresetParameter + Default bool } type PresetParameter struct { diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 7b0b852419f21..01b3d8e61d595 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -31,7 +31,7 @@ We track the following resources: | Usercreate, write, delete |