Merge branch 'main' into fix/prebuild-validation-retry
@@ -235,6 +235,10 @@ type FakeAgentAPI struct {
|
||||
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
|
||||
}
|
||||
|
||||
func (*FakeAgentAPI) UpdateAppStatus(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
||||
return f.manifest, nil
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ message CreateSubAgentRequest {
|
||||
}
|
||||
|
||||
repeated DisplayApp display_apps = 6;
|
||||
|
||||
|
||||
optional bytes id = 7;
|
||||
}
|
||||
|
||||
@@ -494,6 +494,24 @@ message ReportBoundaryLogsRequest {
|
||||
|
||||
message ReportBoundaryLogsResponse {}
|
||||
|
||||
// UpdateAppStatusRequest updates the given Workspace App's status. c.f. agentsdk.PatchAppStatus
|
||||
message UpdateAppStatusRequest {
|
||||
string slug = 1;
|
||||
|
||||
enum AppStatusState {
|
||||
WORKING = 0;
|
||||
IDLE = 1;
|
||||
COMPLETE = 2;
|
||||
FAILURE = 3;
|
||||
}
|
||||
AppStatusState state = 2;
|
||||
|
||||
string message = 3;
|
||||
string uri = 4;
|
||||
}
|
||||
|
||||
message UpdateAppStatusResponse {}
|
||||
|
||||
service Agent {
|
||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||
@@ -512,4 +530,5 @@ service Agent {
|
||||
rpc DeleteSubAgent(DeleteSubAgentRequest) returns (DeleteSubAgentResponse);
|
||||
rpc ListSubAgents(ListSubAgentsRequest) returns (ListSubAgentsResponse);
|
||||
rpc ReportBoundaryLogs(ReportBoundaryLogsRequest) returns (ReportBoundaryLogsResponse);
|
||||
rpc UpdateAppStatus(UpdateAppStatusRequest) returns (UpdateAppStatusResponse);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type DRPCAgentClient interface {
|
||||
DeleteSubAgent(ctx context.Context, in *DeleteSubAgentRequest) (*DeleteSubAgentResponse, error)
|
||||
ListSubAgents(ctx context.Context, in *ListSubAgentsRequest) (*ListSubAgentsResponse, error)
|
||||
ReportBoundaryLogs(ctx context.Context, in *ReportBoundaryLogsRequest) (*ReportBoundaryLogsResponse, error)
|
||||
UpdateAppStatus(ctx context.Context, in *UpdateAppStatusRequest) (*UpdateAppStatusResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentClient struct {
|
||||
@@ -221,6 +222,15 @@ func (c *drpcAgentClient) ReportBoundaryLogs(ctx context.Context, in *ReportBoun
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) UpdateAppStatus(ctx context.Context, in *UpdateAppStatusRequest) (*UpdateAppStatusResponse, error) {
|
||||
out := new(UpdateAppStatusResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateAppStatus", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentServer interface {
|
||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
@@ -239,6 +249,7 @@ type DRPCAgentServer interface {
|
||||
DeleteSubAgent(context.Context, *DeleteSubAgentRequest) (*DeleteSubAgentResponse, error)
|
||||
ListSubAgents(context.Context, *ListSubAgentsRequest) (*ListSubAgentsResponse, error)
|
||||
ReportBoundaryLogs(context.Context, *ReportBoundaryLogsRequest) (*ReportBoundaryLogsResponse, error)
|
||||
UpdateAppStatus(context.Context, *UpdateAppStatusRequest) (*UpdateAppStatusResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentUnimplementedServer struct{}
|
||||
@@ -311,9 +322,13 @@ func (s *DRPCAgentUnimplementedServer) ReportBoundaryLogs(context.Context, *Repo
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) UpdateAppStatus(context.Context, *UpdateAppStatusRequest) (*UpdateAppStatusResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentDescription struct{}
|
||||
|
||||
func (DRPCAgentDescription) NumMethods() int { return 17 }
|
||||
func (DRPCAgentDescription) NumMethods() int { return 18 }
|
||||
|
||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
@@ -470,6 +485,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
|
||||
in1.(*ReportBoundaryLogsRequest),
|
||||
)
|
||||
}, DRPCAgentServer.ReportBoundaryLogs, true
|
||||
case 17:
|
||||
return "/coder.agent.v2.Agent/UpdateAppStatus", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
UpdateAppStatus(
|
||||
ctx,
|
||||
in1.(*UpdateAppStatusRequest),
|
||||
)
|
||||
}, DRPCAgentServer.UpdateAppStatus, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
@@ -750,3 +774,19 @@ func (x *drpcAgent_ReportBoundaryLogsStream) SendAndClose(m *ReportBoundaryLogsR
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_UpdateAppStatusStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*UpdateAppStatusResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_UpdateAppStatusStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_UpdateAppStatusStream) SendAndClose(m *UpdateAppStatusResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
@@ -73,9 +73,13 @@ type DRPCAgentClient27 interface {
|
||||
ReportBoundaryLogs(ctx context.Context, in *ReportBoundaryLogsRequest) (*ReportBoundaryLogsResponse, error)
|
||||
}
|
||||
|
||||
// DRPCAgentClient28 is the Agent API at v2.8. It adds a SubagentId field to the
|
||||
// WorkspaceAgentDevcontainer message, and a Id field to the CreateSubAgentRequest
|
||||
// message. Compatible with Coder v2.31+
|
||||
// DRPCAgentClient28 is the Agent API at v2.8. It adds
|
||||
// - a SubagentId field to the WorkspaceAgentDevcontainer message
|
||||
// - an Id field to the CreateSubAgentRequest message.
|
||||
// - UpdateAppStatus RPC.
|
||||
//
|
||||
// Compatible with Coder v2.31+
|
||||
type DRPCAgentClient28 interface {
|
||||
DRPCAgentClient27
|
||||
UpdateAppStatus(ctx context.Context, in *UpdateAppStatusRequest) (*UpdateAppStatusResponse, error)
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil
|
||||
return xerrors.Errorf("ephemeral parameter %q can be used only with --prompt-ephemeral-parameters or --ephemeral-parameter flag", r.Name)
|
||||
}
|
||||
|
||||
if !tvp.Mutable && action != WorkspaceCreate {
|
||||
if !tvp.Mutable && action != WorkspaceCreate && !pr.isFirstTimeUse(r.Name) {
|
||||
return xerrors.Errorf("parameter %q is immutable and cannot be updated", r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestStatePull(t *testing.T) {
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{ProvisionerState: wantState}).
|
||||
Seed(database.WorkspaceBuild{}).ProvisionerState(wantState).
|
||||
Do()
|
||||
statefilePath := filepath.Join(t.TempDir(), "state")
|
||||
inv, root := clitest.New(t, "state", "pull", r.Workspace.Name, statefilePath)
|
||||
@@ -54,7 +54,7 @@ func TestStatePull(t *testing.T) {
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{ProvisionerState: wantState}).
|
||||
Seed(database.WorkspaceBuild{}).ProvisionerState(wantState).
|
||||
Do()
|
||||
inv, root := clitest.New(t, "state", "pull", r.Workspace.Name)
|
||||
var gotState bytes.Buffer
|
||||
@@ -74,7 +74,7 @@ func TestStatePull(t *testing.T) {
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{ProvisionerState: wantState}).
|
||||
Seed(database.WorkspaceBuild{}).ProvisionerState(wantState).
|
||||
Do()
|
||||
inv, root := clitest.New(t, "state", "pull", taUser.Username+"/"+r.Workspace.Name,
|
||||
"--build", fmt.Sprintf("%d", r.Build.BuildNumber))
|
||||
@@ -170,7 +170,7 @@ func TestStatePush(t *testing.T) {
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: taUser.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{ProvisionerState: initialState}).
|
||||
Seed(database.WorkspaceBuild{}).ProvisionerState(initialState).
|
||||
Do()
|
||||
wantState := []byte("updated state")
|
||||
stateFile, err := os.CreateTemp(t.TempDir(), "")
|
||||
|
||||
@@ -241,26 +241,13 @@ func (r *RootCmd) listTokens() *serpent.Command {
|
||||
}
|
||||
|
||||
tokens, err := client.Tokens(inv.Context(), codersdk.Me, codersdk.TokensFilter{
|
||||
IncludeAll: all,
|
||||
IncludeAll: all,
|
||||
IncludeExpired: includeExpired,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list tokens: %w", err)
|
||||
}
|
||||
|
||||
// Filter out expired tokens unless --include-expired is set
|
||||
// TODO(Cian): This _could_ get too big for client-side filtering.
|
||||
// If it causes issues, we can filter server-side.
|
||||
if !includeExpired {
|
||||
now := time.Now()
|
||||
filtered := make([]codersdk.APIKeyWithOwner, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.ExpiresAt.After(now) {
|
||||
filtered = append(filtered, token)
|
||||
}
|
||||
}
|
||||
tokens = filtered
|
||||
}
|
||||
|
||||
displayTokens = make([]tokenListRow, len(tokens))
|
||||
|
||||
for i, token := range tokens {
|
||||
|
||||
@@ -990,4 +990,74 @@ func TestUpdateValidateRichParameters(t *testing.T) {
|
||||
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
|
||||
t.Run("NewImmutableParameterViaFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create template and workspace with only a mutable parameter.
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
templateParameters := []*proto.RichParameter{
|
||||
{Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{
|
||||
{Name: "First option", Description: "This is first option", Value: "1st"},
|
||||
{Name: "Second option", Description: "This is second option", Value: "2nd"},
|
||||
}},
|
||||
}
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(templateParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", stringParameterName, "1st"))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update template: add a new immutable parameter.
|
||||
updatedTemplateParameters := []*proto.RichParameter{
|
||||
templateParameters[0],
|
||||
{Name: immutableParameterName, Type: "string", Mutable: false, Required: true, Options: []*proto.RichParameterOption{
|
||||
{Name: "fir", Description: "First option for immutable parameter", Value: "I"},
|
||||
{Name: "sec", Description: "Second option for immutable parameter", Value: "II"},
|
||||
}},
|
||||
}
|
||||
|
||||
updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(updatedTemplateParameters), template.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)
|
||||
err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: updatedVersion.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update workspace, supplying the new immutable parameter via
|
||||
// the --parameter flag. This should succeed because it's the
|
||||
// first time this parameter is being set.
|
||||
inv, root = clitest.New(t, "update", "my-workspace",
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, "II"))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Planning workspace")
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
|
||||
// Verify the immutable parameter was set correctly.
|
||||
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{
|
||||
Name: immutableParameterName,
|
||||
Value: "II",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,6 +179,8 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
PublishWorkspaceUpdateFn: api.publishWorkspaceUpdate,
|
||||
Clock: opts.Clock,
|
||||
NotificationsEnqueuer: opts.NotificationsEnqueuer,
|
||||
}
|
||||
|
||||
api.MetadataAPI = &MetadataAPI{
|
||||
|
||||
@@ -2,6 +2,10 @@ package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -9,7 +13,14 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
strutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type AppsAPI struct {
|
||||
@@ -17,6 +28,8 @@ type AppsAPI struct {
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
PublishWorkspaceUpdateFn func(context.Context, *database.WorkspaceAgent, wspubsub.WorkspaceEventKind) error
|
||||
NotificationsEnqueuer notifications.Enqueuer
|
||||
Clock quartz.Clock
|
||||
}
|
||||
|
||||
func (a *AppsAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
|
||||
@@ -104,3 +117,230 @@ func (a *AppsAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.Bat
|
||||
}
|
||||
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
||||
}
|
||||
|
||||
func (a *AppsAPI) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
|
||||
if len(req.Message) > 160 {
|
||||
return nil, codersdk.NewError(http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Message is too long.",
|
||||
Detail: "Message must be less than 160 characters.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "message", Detail: "Message must be less than 160 characters."},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var dbState database.WorkspaceAppStatusState
|
||||
switch req.State {
|
||||
case agentproto.UpdateAppStatusRequest_COMPLETE:
|
||||
dbState = database.WorkspaceAppStatusStateComplete
|
||||
case agentproto.UpdateAppStatusRequest_FAILURE:
|
||||
dbState = database.WorkspaceAppStatusStateFailure
|
||||
case agentproto.UpdateAppStatusRequest_WORKING:
|
||||
dbState = database.WorkspaceAppStatusStateWorking
|
||||
case agentproto.UpdateAppStatusRequest_IDLE:
|
||||
dbState = database.WorkspaceAppStatusStateIdle
|
||||
default:
|
||||
return nil, codersdk.NewError(http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid state provided.",
|
||||
Detail: fmt.Sprintf("invalid state: %q", req.State),
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "state", Detail: "State must be one of: complete, failure, working, idle."},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, err := a.Database.GetWorkspaceAppByAgentIDAndSlug(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{
|
||||
AgentID: workspaceAgent.ID,
|
||||
Slug: req.Slug,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, codersdk.NewError(http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to get workspace app.",
|
||||
Detail: fmt.Sprintf("No app found with slug %q", req.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
workspace, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, codersdk.NewError(http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Treat the message as untrusted input.
|
||||
cleaned := strutil.UISanitize(req.Message)
|
||||
|
||||
// Get the latest status for the workspace app to detect no-op updates
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
latestAppStatus, err := a.Database.GetLatestWorkspaceAppStatusByAppID(dbauthz.AsSystemRestricted(ctx), app.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return nil, codersdk.NewError(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get latest workspace app status.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
// If no rows found, latestAppStatus will be a zero-value struct (ID == uuid.Nil)
|
||||
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
_, err = a.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: workspaceAgent.ID,
|
||||
AppID: app.ID,
|
||||
State: dbState,
|
||||
Message: cleaned,
|
||||
Uri: sql.NullString{
|
||||
String: req.Uri,
|
||||
Valid: req.Uri != "",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, codersdk.NewError(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to insert workspace app status.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if a.PublishWorkspaceUpdateFn != nil {
|
||||
err = a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent, wspubsub.WorkspaceEventKindAgentAppStatusUpdate)
|
||||
if err != nil {
|
||||
return nil, codersdk.NewError(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to publish workspace update.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Notify on state change to Working/Idle for AI tasks
|
||||
a.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, dbState, workspace, workspaceAgent)
|
||||
|
||||
if shouldBump(dbState, latestAppStatus) {
|
||||
// We pass time.Time{} for nextAutostart since we don't have access to
|
||||
// TemplateScheduleStore here. The activity bump logic handles this by
|
||||
// defaulting to the template's activity_bump duration (typically 1 hour).
|
||||
workspacestats.ActivityBumpWorkspace(ctx, a.Log, a.Database, workspace.ID, time.Time{})
|
||||
}
|
||||
// just return a blank response because it doesn't contain any settable fields at present.
|
||||
return new(agentproto.UpdateAppStatusResponse), nil
|
||||
}
|
||||
|
||||
func shouldBump(dbState database.WorkspaceAppStatusState, latestAppStatus database.WorkspaceAppStatus) bool {
|
||||
// Bump deadline when agent reports working or transitions away from working.
|
||||
// This prevents auto-pause during active work and gives users time to interact
|
||||
// after work completes.
|
||||
|
||||
// Bump if reporting working state.
|
||||
if dbState == database.WorkspaceAppStatusStateWorking {
|
||||
return true
|
||||
}
|
||||
|
||||
// Bump if transitioning away from working state.
|
||||
if latestAppStatus.ID != uuid.Nil {
|
||||
prevState := latestAppStatus.State
|
||||
if prevState == database.WorkspaceAppStatusStateWorking {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// enqueueAITaskStateNotification enqueues a notification when an AI task's app
|
||||
// transitions to Working or Idle.
|
||||
// No-op if:
|
||||
// - the workspace agent app isn't configured as an AI task,
|
||||
// - the new state equals the latest persisted state,
|
||||
// - the workspace agent is not ready (still starting up).
|
||||
func (a *AppsAPI) enqueueAITaskStateNotification(
|
||||
ctx context.Context,
|
||||
appID uuid.UUID,
|
||||
latestAppStatus database.WorkspaceAppStatus,
|
||||
newAppStatus database.WorkspaceAppStatusState,
|
||||
workspace database.Workspace,
|
||||
agent database.WorkspaceAgent,
|
||||
) {
|
||||
var notificationTemplate uuid.UUID
|
||||
switch newAppStatus {
|
||||
case database.WorkspaceAppStatusStateWorking:
|
||||
notificationTemplate = notifications.TemplateTaskWorking
|
||||
case database.WorkspaceAppStatusStateIdle:
|
||||
notificationTemplate = notifications.TemplateTaskIdle
|
||||
case database.WorkspaceAppStatusStateComplete:
|
||||
notificationTemplate = notifications.TemplateTaskCompleted
|
||||
case database.WorkspaceAppStatusStateFailure:
|
||||
notificationTemplate = notifications.TemplateTaskFailed
|
||||
default:
|
||||
// Not a notifiable state, do nothing
|
||||
return
|
||||
}
|
||||
|
||||
if !workspace.TaskID.Valid {
|
||||
// Workspace has no task ID, do nothing.
|
||||
return
|
||||
}
|
||||
|
||||
// Only send notifications when the agent is ready. We want to skip
|
||||
// any state transitions that occur whilst the workspace is starting
|
||||
// up as it doesn't make sense to receive them.
|
||||
if agent.LifecycleState != database.WorkspaceAgentLifecycleStateReady {
|
||||
a.Log.Debug(ctx, "skipping AI task notification because agent is not ready",
|
||||
slog.F("agent_id", agent.ID),
|
||||
slog.F("lifecycle_state", agent.LifecycleState),
|
||||
slog.F("new_app_status", newAppStatus),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := a.Database.GetTaskByID(ctx, workspace.TaskID.UUID)
|
||||
if err != nil {
|
||||
a.Log.Warn(ctx, "failed to get task", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !task.WorkspaceAppID.Valid || task.WorkspaceAppID.UUID != appID {
|
||||
// Non-task app, do nothing.
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if the latest persisted state equals the new state (no new transition)
|
||||
// Note: uuid.Nil check is valid here. If no previous status exists,
|
||||
// GetLatestWorkspaceAppStatusByAppID returns sql.ErrNoRows and we get a zero-value struct.
|
||||
if latestAppStatus.ID != uuid.Nil && latestAppStatus.State == newAppStatus {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the initial "Working" notification when the task first starts.
|
||||
// This is obvious to the user since they just created the task.
|
||||
// We still notify on the first "Idle" status and all subsequent transitions.
|
||||
if latestAppStatus.ID == uuid.Nil && newAppStatus == database.WorkspaceAppStatusStateWorking {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.NotificationsEnqueuer.EnqueueWithData(
|
||||
// nolint:gocritic // Need notifier actor to enqueue notifications
|
||||
dbauthz.AsNotifier(ctx),
|
||||
workspace.OwnerID,
|
||||
notificationTemplate,
|
||||
map[string]string{
|
||||
"task": task.Name,
|
||||
"workspace": workspace.Name,
|
||||
},
|
||||
map[string]any{
|
||||
// Use a 1-minute bucketed timestamp to bypass per-day dedupe,
|
||||
// allowing identical content to resend within the same day
|
||||
// (but not more than once every 10s).
|
||||
"dedupe_bypass_ts": a.Clock.Now().UTC().Truncate(time.Minute),
|
||||
},
|
||||
"api-workspace-agent-app-status",
|
||||
// Associate this notification with related entities
|
||||
workspace.ID, workspace.OwnerID, workspace.OrganizationID, appID,
|
||||
); err != nil {
|
||||
a.Log.Warn(ctx, "failed to notify of task state", slog.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
)
|
||||
|
||||
func TestShouldBump(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prevState *database.WorkspaceAppStatusState // nil means no previous state
|
||||
newState database.WorkspaceAppStatusState
|
||||
shouldBump bool
|
||||
}{
|
||||
{
|
||||
name: "FirstStatusBumps",
|
||||
prevState: nil,
|
||||
newState: database.WorkspaceAppStatusStateWorking,
|
||||
shouldBump: true,
|
||||
},
|
||||
{
|
||||
name: "WorkingToIdleBumps",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateWorking),
|
||||
newState: database.WorkspaceAppStatusStateIdle,
|
||||
shouldBump: true,
|
||||
},
|
||||
{
|
||||
name: "WorkingToCompleteBumps",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateWorking),
|
||||
newState: database.WorkspaceAppStatusStateComplete,
|
||||
shouldBump: true,
|
||||
},
|
||||
{
|
||||
name: "CompleteToIdleNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateComplete),
|
||||
newState: database.WorkspaceAppStatusStateIdle,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "CompleteToCompleteNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateComplete),
|
||||
newState: database.WorkspaceAppStatusStateComplete,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "FailureToIdleNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateFailure),
|
||||
newState: database.WorkspaceAppStatusStateIdle,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "FailureToFailureNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateFailure),
|
||||
newState: database.WorkspaceAppStatusStateFailure,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "CompleteToWorkingBumps",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateComplete),
|
||||
newState: database.WorkspaceAppStatusStateWorking,
|
||||
shouldBump: true,
|
||||
},
|
||||
{
|
||||
name: "FailureToCompleteNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateFailure),
|
||||
newState: database.WorkspaceAppStatusStateComplete,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "WorkingToFailureBumps",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateWorking),
|
||||
newState: database.WorkspaceAppStatusStateFailure,
|
||||
shouldBump: true,
|
||||
},
|
||||
{
|
||||
name: "IdleToIdleNoBump",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateIdle),
|
||||
newState: database.WorkspaceAppStatusStateIdle,
|
||||
shouldBump: false,
|
||||
},
|
||||
{
|
||||
name: "IdleToWorkingBumps",
|
||||
prevState: ptr.Ref(database.WorkspaceAppStatusStateIdle),
|
||||
newState: database.WorkspaceAppStatusStateWorking,
|
||||
shouldBump: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var prevAppStatus database.WorkspaceAppStatus
|
||||
// If there's a previous state, report it first.
|
||||
if tt.prevState != nil {
|
||||
prevAppStatus.ID = uuid.UUID{1}
|
||||
prevAppStatus.State = *tt.prevState
|
||||
}
|
||||
|
||||
didBump := shouldBump(tt.newState, prevAppStatus)
|
||||
if tt.shouldBump {
|
||||
require.True(t, didBump, "wanted deadline to bump but it didn't")
|
||||
} else {
|
||||
require.False(t, didBump, "wanted deadline not to bump but it did")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ package agentapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
@@ -12,8 +16,12 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestBatchUpdateAppHealths(t *testing.T) {
|
||||
@@ -253,3 +261,183 @@ func TestBatchUpdateAppHealths(t *testing.T) {
|
||||
require.Nil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentAppStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
fEnq := ¬ificationstest.FakeEnqueuer{}
|
||||
mClock := quartz.NewMock(t)
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: uuid.UUID{2},
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
}
|
||||
workspaceUpdates := make(chan wspubsub.WorkspaceEventKind, 100)
|
||||
|
||||
api := &agentapi.AppsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Database: mDB,
|
||||
Log: testutil.Logger(t),
|
||||
PublishWorkspaceUpdateFn: func(_ context.Context, agnt *database.WorkspaceAgent, kind wspubsub.WorkspaceEventKind) error {
|
||||
assert.Equal(t, *agnt, agent)
|
||||
testutil.AssertSend(ctx, t, workspaceUpdates, kind)
|
||||
return nil
|
||||
},
|
||||
NotificationsEnqueuer: fEnq,
|
||||
Clock: mClock,
|
||||
}
|
||||
|
||||
app := database.WorkspaceApp{
|
||||
ID: uuid.UUID{8},
|
||||
}
|
||||
mDB.EXPECT().GetWorkspaceAppByAgentIDAndSlug(gomock.Any(), database.GetWorkspaceAppByAgentIDAndSlugParams{
|
||||
AgentID: agent.ID,
|
||||
Slug: "vscode",
|
||||
}).Times(1).Return(app, nil)
|
||||
task := database.Task{
|
||||
ID: uuid.UUID{7},
|
||||
WorkspaceAppID: uuid.NullUUID{
|
||||
Valid: true,
|
||||
UUID: app.ID,
|
||||
},
|
||||
}
|
||||
mDB.EXPECT().GetTaskByID(gomock.Any(), task.ID).Times(1).Return(task, nil)
|
||||
workspace := database.Workspace{
|
||||
ID: uuid.UUID{9},
|
||||
TaskID: uuid.NullUUID{
|
||||
Valid: true,
|
||||
UUID: task.ID,
|
||||
},
|
||||
}
|
||||
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Times(1).Return(workspace, nil)
|
||||
appStatus := database.WorkspaceAppStatus{
|
||||
ID: uuid.UUID{6},
|
||||
}
|
||||
mDB.EXPECT().GetLatestWorkspaceAppStatusByAppID(gomock.Any(), app.ID).Times(1).Return(appStatus, nil)
|
||||
mDB.EXPECT().InsertWorkspaceAppStatus(
|
||||
gomock.Any(),
|
||||
gomock.Cond(func(params database.InsertWorkspaceAppStatusParams) bool {
|
||||
if params.AgentID == agent.ID && params.AppID == app.ID {
|
||||
assert.Equal(t, "testing", params.Message)
|
||||
assert.Equal(t, database.WorkspaceAppStatusStateComplete, params.State)
|
||||
assert.True(t, params.Uri.Valid)
|
||||
assert.Equal(t, "https://example.com", params.Uri.String)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})).Times(1).Return(database.WorkspaceAppStatus{}, nil)
|
||||
|
||||
_, err := api.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
|
||||
Slug: "vscode",
|
||||
Message: "testing",
|
||||
Uri: "https://example.com",
|
||||
State: agentproto.UpdateAppStatusRequest_COMPLETE,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
kind := testutil.RequireReceive(ctx, t, workspaceUpdates)
|
||||
require.Equal(t, wspubsub.WorkspaceEventKindAgentAppStatusUpdate, kind)
|
||||
sent := fEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskCompleted))
|
||||
require.Len(t, sent, 1)
|
||||
})
|
||||
|
||||
t.Run("FailUnknownApp", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: uuid.UUID{2},
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
}
|
||||
|
||||
mDB.EXPECT().GetWorkspaceAppByAgentIDAndSlug(gomock.Any(), gomock.Any()).
|
||||
Times(1).
|
||||
Return(database.WorkspaceApp{}, sql.ErrNoRows)
|
||||
|
||||
api := &agentapi.AppsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Database: mDB,
|
||||
Log: testutil.Logger(t),
|
||||
}
|
||||
_, err := api.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
|
||||
Slug: "unknown",
|
||||
Message: "testing",
|
||||
Uri: "https://example.com",
|
||||
State: agentproto.UpdateAppStatusRequest_COMPLETE,
|
||||
})
|
||||
require.ErrorContains(t, err, "No app found with slug")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("FailUnknownState", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: uuid.UUID{2},
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
}
|
||||
|
||||
api := &agentapi.AppsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Database: mDB,
|
||||
Log: testutil.Logger(t),
|
||||
}
|
||||
|
||||
_, err := api.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
|
||||
Slug: "vscode",
|
||||
Message: "testing",
|
||||
Uri: "https://example.com",
|
||||
State: 77,
|
||||
})
|
||||
require.ErrorContains(t, err, "Invalid state")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("FailTooLong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: uuid.UUID{2},
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
}
|
||||
|
||||
api := &agentapi.AppsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Database: mDB,
|
||||
Log: testutil.Logger(t),
|
||||
}
|
||||
|
||||
_, err := api.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
|
||||
Slug: "vscode",
|
||||
Message: strings.Repeat("a", 161),
|
||||
Uri: "https://example.com",
|
||||
State: agentproto.UpdateAppStatusRequest_COMPLETE,
|
||||
})
|
||||
require.ErrorContains(t, err, "Message is too long")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8238,6 +8238,12 @@ const docTemplate = `{
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include expired tokens in the list",
|
||||
"name": "include_expired",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -9545,6 +9551,7 @@ const docTemplate = `{
|
||||
],
|
||||
"summary": "Patch workspace agent app status",
|
||||
"operationId": "patch-workspace-agent-app-status",
|
||||
"deprecated": true,
|
||||
"parameters": [
|
||||
{
|
||||
"description": "app status",
|
||||
|
||||
@@ -7285,6 +7285,12 @@
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include expired tokens in the list",
|
||||
"name": "include_expired",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -8444,6 +8450,7 @@
|
||||
"tags": ["Agents"],
|
||||
"summary": "Patch workspace agent app status",
|
||||
"operationId": "patch-workspace-agent-app-status",
|
||||
"deprecated": true,
|
||||
"parameters": [
|
||||
{
|
||||
"description": "app status",
|
||||
|
||||
@@ -307,20 +307,26 @@ func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Tags Users
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Success 200 {array} codersdk.APIKey
|
||||
// @Param include_expired query bool false "Include expired tokens in the list"
|
||||
// @Router /users/{user}/keys/tokens [get]
|
||||
func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
user = httpmw.UserParam(r)
|
||||
keys []database.APIKey
|
||||
err error
|
||||
queryStr = r.URL.Query().Get("include_all")
|
||||
includeAll, _ = strconv.ParseBool(queryStr)
|
||||
ctx = r.Context()
|
||||
user = httpmw.UserParam(r)
|
||||
keys []database.APIKey
|
||||
err error
|
||||
queryStr = r.URL.Query().Get("include_all")
|
||||
includeAll, _ = strconv.ParseBool(queryStr)
|
||||
expiredStr = r.URL.Query().Get("include_expired")
|
||||
includeExpired, _ = strconv.ParseBool(expiredStr)
|
||||
)
|
||||
|
||||
if includeAll {
|
||||
// get tokens for all users
|
||||
keys, err = api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken)
|
||||
keys, err = api.Database.GetAPIKeysByLoginType(ctx, database.GetAPIKeysByLoginTypeParams{
|
||||
LoginType: database.LoginTypeToken,
|
||||
IncludeExpired: includeExpired,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching API keys.",
|
||||
@@ -330,7 +336,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
// get user's tokens only
|
||||
keys, err = api.Database.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: user.ID})
|
||||
keys, err = api.Database.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: user.ID, IncludeExpired: includeExpired})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching API keys.",
|
||||
|
||||
@@ -69,6 +69,44 @@ func TestTokenCRUD(t *testing.T) {
|
||||
require.Equal(t, database.AuditActionDelete, auditor.AuditLogs()[numLogs-1].Action)
|
||||
}
|
||||
|
||||
func TestTokensFilterExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
adminClient := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, adminClient)
|
||||
|
||||
// Create a token.
|
||||
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// List tokens without including expired - should see the token.
|
||||
keys, err := adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
|
||||
// Expire the token.
|
||||
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List tokens without including expired - should NOT see expired token.
|
||||
keys, err = adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, keys)
|
||||
|
||||
// List tokens WITH including expired - should see expired token.
|
||||
keys, err = adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{
|
||||
IncludeExpired: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
require.Equal(t, keyID, keys[0].ID)
|
||||
}
|
||||
|
||||
func TestTokenScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -500,7 +500,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
"task": task.Name,
|
||||
"task_id": task.ID.String(),
|
||||
"workspace": ws.Name,
|
||||
"pause_reason": "inactivity exceeded the dormancy threshold",
|
||||
"pause_reason": "idle timeout",
|
||||
},
|
||||
"lifecycle_executor",
|
||||
ws.ID, ws.OwnerID, ws.OrganizationID,
|
||||
|
||||
@@ -2082,6 +2082,6 @@ func TestExecutorTaskWorkspace(t *testing.T) {
|
||||
require.Equal(t, task.Name, sent[0].Labels["task"])
|
||||
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
|
||||
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
|
||||
require.Equal(t, "inactivity exceeded the dormancy threshold", sent[0].Labels["pause_reason"])
|
||||
require.Equal(t, "idle timeout", sent[0].Labels["pause_reason"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -668,6 +668,31 @@ var (
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectWorkspaceBuilder = rbac.Subject{
|
||||
Type: rbac.SubjectTypeWorkspaceBuilder,
|
||||
FriendlyName: "Workspace Builder",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "workspace-builder"},
|
||||
DisplayName: "Workspace Builder",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
// Reading provisioner daemons to check eligibility.
|
||||
rbac.ResourceProvisionerDaemon.Type: {policy.ActionRead},
|
||||
// Updating provisioner jobs (e.g. marking prebuild
|
||||
// jobs complete).
|
||||
rbac.ResourceProvisionerJobs.Type: {policy.ActionUpdate},
|
||||
// Reading provisioner state requires template update
|
||||
// permission.
|
||||
rbac.ResourceTemplate.Type: {policy.ActionUpdate},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
)
|
||||
|
||||
// AsProvisionerd returns a context with an actor that has permissions required
|
||||
@@ -774,6 +799,14 @@ func AsBoundaryUsageTracker(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectBoundaryUsageTracker)
|
||||
}
|
||||
|
||||
// AsWorkspaceBuilder returns a context with an actor that has permissions
|
||||
// required for the workspace builder to prepare workspace builds. This
|
||||
// includes reading provisioner daemons, updating provisioner jobs, and
|
||||
// reading provisioner state (which requires template update permission).
|
||||
func AsWorkspaceBuilder(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectWorkspaceBuilder)
|
||||
}
|
||||
|
||||
var AsRemoveActor = rbac.Subject{
|
||||
ID: "remove-actor",
|
||||
}
|
||||
@@ -2161,12 +2194,12 @@ func (q *querier) GetAPIKeyByName(ctx context.Context, arg database.GetAPIKeyByN
|
||||
return fetch(q.log, q.auth, q.db.GetAPIKeyByName)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) {
|
||||
func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database.GetAPIKeysByLoginTypeParams) ([]database.APIKey, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysByLoginType)(ctx, loginType)
|
||||
}
|
||||
|
||||
func (q *querier) GetAPIKeysByUserID(ctx context.Context, params database.GetAPIKeysByUserIDParams) ([]database.APIKey, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysByUserID)(ctx, database.GetAPIKeysByUserIDParams{LoginType: params.LoginType, UserID: params.UserID})
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysByUserID)(ctx, params)
|
||||
}
|
||||
|
||||
func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]database.APIKey, error) {
|
||||
@@ -2257,7 +2290,7 @@ func (q *querier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditL
|
||||
}
|
||||
|
||||
func (q *querier) GetAuthenticatedWorkspaceAgentAndBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetAuthenticatedWorkspaceAgentAndBuildByAuthTokenRow, error) {
|
||||
// This is a system function
|
||||
// This is a system function.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return database.GetAuthenticatedWorkspaceAgentAndBuildByAuthTokenRow{}, err
|
||||
}
|
||||
@@ -3914,6 +3947,11 @@ func (q *querier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, wor
|
||||
return q.db.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prep)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceBuildProvisionerStateByID(ctx context.Context, buildID uuid.UUID) (database.GetWorkspaceBuildProvisionerStateByIDRow, error) {
|
||||
// Fetching the provisioner state requires Update permission on the template.
|
||||
return fetchWithAction(q.log, q.auth, policy.ActionUpdate, q.db.GetWorkspaceBuildProvisionerStateByID)(ctx, buildID)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -237,8 +237,8 @@ func (s *MethodTestSuite) TestAPIKey() {
|
||||
s.Run("GetAPIKeysByLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
a := testutil.Fake(s.T(), faker, database.APIKey{LoginType: database.LoginTypePassword})
|
||||
b := testutil.Fake(s.T(), faker, database.APIKey{LoginType: database.LoginTypePassword})
|
||||
dbm.EXPECT().GetAPIKeysByLoginType(gomock.Any(), database.LoginTypePassword).Return([]database.APIKey{a, b}, nil).AnyTimes()
|
||||
check.Args(database.LoginTypePassword).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b))
|
||||
dbm.EXPECT().GetAPIKeysByLoginType(gomock.Any(), database.GetAPIKeysByLoginTypeParams{LoginType: database.LoginTypePassword}).Return([]database.APIKey{a, b}, nil).AnyTimes()
|
||||
check.Args(database.GetAPIKeysByLoginTypeParams{LoginType: database.LoginTypePassword}).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b))
|
||||
}))
|
||||
s.Run("GetAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u1 := testutil.Fake(s.T(), faker, database.User{})
|
||||
@@ -1969,6 +1969,15 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
|
||||
check.Args(build.ID).Asserts(ws, policy.ActionRead).Returns(build)
|
||||
}))
|
||||
s.Run("GetWorkspaceBuildProvisionerStateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
row := database.GetWorkspaceBuildProvisionerStateByIDRow{
|
||||
ProvisionerState: []byte("state"),
|
||||
TemplateID: uuid.New(),
|
||||
TemplateOrganizationID: uuid.New(),
|
||||
}
|
||||
dbm.EXPECT().GetWorkspaceBuildProvisionerStateByID(gomock.Any(), gomock.Any()).Return(row, nil).AnyTimes()
|
||||
check.Args(uuid.New()).Asserts(row, policy.ActionUpdate).Returns(row)
|
||||
}))
|
||||
s.Run("GetWorkspaceBuildByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
ws := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID})
|
||||
|
||||
@@ -67,6 +67,8 @@ type WorkspaceBuildBuilder struct {
|
||||
|
||||
jobError string // Error message for failed jobs
|
||||
jobErrorCode string // Error code for failed jobs
|
||||
|
||||
provisionerState []byte
|
||||
}
|
||||
|
||||
// BuilderOption is a functional option for customizing job timestamps
|
||||
@@ -138,6 +140,15 @@ func (b WorkspaceBuildBuilder) Seed(seed database.WorkspaceBuild) WorkspaceBuild
|
||||
return b
|
||||
}
|
||||
|
||||
// ProvisionerState sets the provisioner state for the workspace build.
|
||||
// This is stored separately from the seed because ProvisionerState is
|
||||
// not part of the WorkspaceBuild view struct.
|
||||
func (b WorkspaceBuildBuilder) ProvisionerState(state []byte) WorkspaceBuildBuilder {
|
||||
//nolint: revive // returns modified struct
|
||||
b.provisionerState = state
|
||||
return b
|
||||
}
|
||||
|
||||
func (b WorkspaceBuildBuilder) Resource(resource ...*sdkproto.Resource) WorkspaceBuildBuilder {
|
||||
//nolint: revive // returns modified struct
|
||||
b.resources = append(b.resources, resource...)
|
||||
@@ -464,6 +475,14 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
|
||||
}
|
||||
|
||||
resp.Build = dbgen.WorkspaceBuild(b.t, b.db, b.seed)
|
||||
if len(b.provisionerState) > 0 {
|
||||
err = b.db.UpdateWorkspaceBuildProvisionerStateByID(ownerCtx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
||||
ID: resp.Build.ID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
ProvisionerState: b.provisionerState,
|
||||
})
|
||||
require.NoError(b.t, err, "update provisioner state")
|
||||
}
|
||||
b.logger.Debug(context.Background(), "created workspace build",
|
||||
slog.F("build_id", resp.Build.ID),
|
||||
slog.F("workspace_id", resp.Workspace.ID),
|
||||
|
||||
@@ -504,7 +504,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil
|
||||
Transition: takeFirst(orig.Transition, database.WorkspaceTransitionStart),
|
||||
InitiatorID: takeFirst(orig.InitiatorID, uuid.New()),
|
||||
JobID: jobID,
|
||||
ProvisionerState: takeFirstSlice(orig.ProvisionerState, []byte{}),
|
||||
ProvisionerState: []byte{},
|
||||
Deadline: takeFirst(orig.Deadline, dbtime.Now().Add(time.Hour)),
|
||||
MaxDeadline: takeFirst(orig.MaxDeadline, time.Time{}),
|
||||
Reason: takeFirst(orig.Reason, database.BuildReasonInitiator),
|
||||
|
||||
@@ -774,7 +774,7 @@ func (m queryMetricsStore) GetAPIKeyByName(ctx context.Context, arg database.Get
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) {
|
||||
func (m queryMetricsStore) GetAPIKeysByLoginType(ctx context.Context, loginType database.GetAPIKeysByLoginTypeParams) ([]database.APIKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAPIKeysByLoginType(ctx, loginType)
|
||||
m.queryLatencies.WithLabelValues("GetAPIKeysByLoginType").Observe(time.Since(start).Seconds())
|
||||
@@ -2430,6 +2430,14 @@ func (m queryMetricsStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceBuildProvisionerStateByID(ctx context.Context, workspaceBuildID uuid.UUID) (database.GetWorkspaceBuildProvisionerStateByIDRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuildID)
|
||||
m.queryLatencies.WithLabelValues("GetWorkspaceBuildProvisionerStateByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceBuildProvisionerStateByID").Inc()
|
||||
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)
|
||||
|
||||
@@ -1305,18 +1305,18 @@ func (mr *MockStoreMockRecorder) GetAPIKeyByName(ctx, arg any) *gomock.Call {
|
||||
}
|
||||
|
||||
// GetAPIKeysByLoginType mocks base method.
|
||||
func (m *MockStore) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) {
|
||||
func (m *MockStore) GetAPIKeysByLoginType(ctx context.Context, arg database.GetAPIKeysByLoginTypeParams) ([]database.APIKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAPIKeysByLoginType", ctx, loginType)
|
||||
ret := m.ctrl.Call(m, "GetAPIKeysByLoginType", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.APIKey)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAPIKeysByLoginType indicates an expected call of GetAPIKeysByLoginType.
|
||||
func (mr *MockStoreMockRecorder) GetAPIKeysByLoginType(ctx, loginType any) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) GetAPIKeysByLoginType(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAPIKeysByLoginType", reflect.TypeOf((*MockStore)(nil).GetAPIKeysByLoginType), ctx, loginType)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAPIKeysByLoginType", reflect.TypeOf((*MockStore)(nil).GetAPIKeysByLoginType), ctx, arg)
|
||||
}
|
||||
|
||||
// GetAPIKeysByUserID mocks base method.
|
||||
@@ -4544,6 +4544,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceBuildParametersByBuildIDs(ctx, work
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIds)
|
||||
}
|
||||
|
||||
// GetWorkspaceBuildProvisionerStateByID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceBuildProvisionerStateByID(ctx context.Context, workspaceBuildID uuid.UUID) (database.GetWorkspaceBuildProvisionerStateByIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWorkspaceBuildProvisionerStateByID", ctx, workspaceBuildID)
|
||||
ret0, _ := ret[0].(database.GetWorkspaceBuildProvisionerStateByIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWorkspaceBuildProvisionerStateByID indicates an expected call of GetWorkspaceBuildProvisionerStateByID.
|
||||
func (mr *MockStoreMockRecorder) GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuildID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildProvisionerStateByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildProvisionerStateByID), ctx, workspaceBuildID)
|
||||
}
|
||||
|
||||
// GetWorkspaceBuildStatsByTemplates mocks base method.
|
||||
func (m *MockStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -2708,7 +2708,6 @@ CREATE VIEW workspace_build_with_user AS
|
||||
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,
|
||||
|
||||
@@ -51,15 +51,34 @@ func TestViewSubsetTemplateVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewSubsetWorkspaceBuild ensures WorkspaceBuildTable is a subset of WorkspaceBuild
|
||||
// TestViewSubsetWorkspaceBuild ensures WorkspaceBuildTable is a subset of
|
||||
// WorkspaceBuild, with the exception of ProvisionerState which is
|
||||
// intentionally excluded from the workspace_build_with_user view to avoid
|
||||
// loading the large Terraform state blob on hot paths.
|
||||
func TestViewSubsetWorkspaceBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
table := reflect.TypeOf(database.WorkspaceBuildTable{})
|
||||
joined := reflect.TypeOf(database.WorkspaceBuild{})
|
||||
|
||||
tableFields := allFields(table)
|
||||
joinedFields := allFields(joined)
|
||||
if !assert.Subset(t, fieldNames(joinedFields), fieldNames(tableFields), "table is not subset") {
|
||||
tableFields := fieldNames(allFields(table))
|
||||
joinedFields := fieldNames(allFields(joined))
|
||||
|
||||
// ProvisionerState is intentionally excluded from the
|
||||
// workspace_build_with_user view to avoid loading multi-MB Terraform
|
||||
// state blobs on hot paths. Callers that need it use
|
||||
// GetWorkspaceBuildProvisionerStateByID instead.
|
||||
excludedFields := map[string]bool{
|
||||
"ProvisionerState": true,
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, name := range tableFields {
|
||||
if !excludedFields[name] {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
|
||||
if !assert.Subset(t, joinedFields, filtered, "table is not subset") {
|
||||
t.Log("Some fields were added to the WorkspaceBuild Table without updating the 'workspace_build_with_user' view.")
|
||||
t.Log("See migration 000141_join_users_build_version.up.sql to create the view.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Restore provisioner_state to workspace_build_with_user view.
|
||||
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.has_external_agent,
|
||||
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.';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Drop and recreate workspace_build_with_user to exclude provisioner_state.
|
||||
-- This avoids loading the large Terraform state blob (1-5 MB per workspace)
|
||||
-- on every query that uses this view. The callers that need provisioner_state
|
||||
-- now fetch it separately via GetWorkspaceBuildProvisionerStateByID.
|
||||
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.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.has_external_agent,
|
||||
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.';
|
||||
@@ -316,6 +316,14 @@ func (t GetFileTemplatesRow) RBACObject() rbac.Object {
|
||||
WithGroupACL(t.GroupACL)
|
||||
}
|
||||
|
||||
// RBACObject for a workspace build's provisioner state requires Update access of the template.
|
||||
func (t GetWorkspaceBuildProvisionerStateByIDRow) RBACObject() rbac.Object {
|
||||
return rbac.ResourceTemplate.WithID(t.TemplateID).
|
||||
InOrg(t.TemplateOrganizationID).
|
||||
WithACLUserList(t.UserACL).
|
||||
WithGroupACL(t.GroupACL)
|
||||
}
|
||||
|
||||
func (t Template) DeepCopy() Template {
|
||||
cpy := t
|
||||
cpy.UserACL = maps.Clone(t.UserACL)
|
||||
|
||||
@@ -4987,7 +4987,6 @@ type WorkspaceBuild struct {
|
||||
BuildNumber int32 `db:"build_number" json:"build_number"`
|
||||
Transition WorkspaceTransition `db:"transition" json:"transition"`
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
JobID uuid.UUID `db:"job_id" json:"job_id"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
Reason BuildReason `db:"reason" json:"reason"`
|
||||
|
||||
@@ -169,7 +169,7 @@ type sqlcQuerier interface {
|
||||
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
|
||||
// there is no unique constraint on empty token names
|
||||
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
|
||||
GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error)
|
||||
GetAPIKeysByLoginType(ctx context.Context, arg GetAPIKeysByLoginTypeParams) ([]APIKey, error)
|
||||
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
|
||||
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
|
||||
GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error)
|
||||
@@ -506,6 +506,11 @@ type sqlcQuerier interface {
|
||||
GetWorkspaceBuildMetricsByResourceID(ctx context.Context, id uuid.UUID) (GetWorkspaceBuildMetricsByResourceIDRow, error)
|
||||
GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error)
|
||||
GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error)
|
||||
// Fetches the provisioner state of a workspace build, joined through to the
|
||||
// template so that dbauthz can enforce policy.ActionUpdate on the template.
|
||||
// Provisioner state contains sensitive Terraform state and should only be
|
||||
// accessible to template administrators.
|
||||
GetWorkspaceBuildProvisionerStateByID(ctx context.Context, workspaceBuildID uuid.UUID) (GetWorkspaceBuildProvisionerStateByIDRow, 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)
|
||||
|
||||
@@ -8195,8 +8195,9 @@ func TestDeleteExpiredAPIKeys(t *testing.T) {
|
||||
|
||||
// All keys are present before deletion
|
||||
keys, err := db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
IncludeExpired: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, len(expiredTimes)+len(unexpiredTimes))
|
||||
@@ -8212,8 +8213,9 @@ func TestDeleteExpiredAPIKeys(t *testing.T) {
|
||||
|
||||
// Ensure it was deleted
|
||||
remaining, err := db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
IncludeExpired: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, len(expiredTimes)+len(unexpiredTimes)-1)
|
||||
@@ -8228,8 +8230,9 @@ func TestDeleteExpiredAPIKeys(t *testing.T) {
|
||||
|
||||
// Ensure only unexpired keys remain
|
||||
remaining, err = db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
LoginType: user.LoginType,
|
||||
UserID: user.ID,
|
||||
IncludeExpired: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, len(unexpiredTimes))
|
||||
|
||||
@@ -1270,10 +1270,16 @@ func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNamePar
|
||||
|
||||
const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many
|
||||
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE login_type = $1
|
||||
AND ($2::bool OR expires_at > now())
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAPIKeysByLoginType, loginType)
|
||||
type GetAPIKeysByLoginTypeParams struct {
|
||||
LoginType LoginType `db:"login_type" json:"login_type"`
|
||||
IncludeExpired bool `db:"include_expired" json:"include_expired"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, arg GetAPIKeysByLoginTypeParams) ([]APIKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAPIKeysByLoginType, arg.LoginType, arg.IncludeExpired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1311,15 +1317,17 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT
|
||||
|
||||
const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many
|
||||
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE login_type = $1 AND user_id = $2
|
||||
AND ($3::bool OR expires_at > now())
|
||||
`
|
||||
|
||||
type GetAPIKeysByUserIDParams struct {
|
||||
LoginType LoginType `db:"login_type" json:"login_type"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
LoginType LoginType `db:"login_type" json:"login_type"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
IncludeExpired bool `db:"include_expired" json:"include_expired"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAPIKeysByUserID, arg.LoginType, arg.UserID)
|
||||
rows, err := q.db.QueryContext(ctx, getAPIKeysByUserID, arg.LoginType, arg.UserID, arg.IncludeExpired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -17955,7 +17963,7 @@ const getAuthenticatedWorkspaceAgentAndBuildByAuthToken = `-- name: GetAuthentic
|
||||
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, workspaces.group_acl, workspaces.user_acl,
|
||||
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.has_external_agent, 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.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.has_external_agent, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name,
|
||||
tasks.id AS task_id
|
||||
FROM
|
||||
workspace_agents
|
||||
@@ -18093,7 +18101,6 @@ func (q *sqlQuerier) GetAuthenticatedWorkspaceAgentAndBuildByAuthToken(ctx conte
|
||||
&i.WorkspaceBuild.BuildNumber,
|
||||
&i.WorkspaceBuild.Transition,
|
||||
&i.WorkspaceBuild.InitiatorID,
|
||||
&i.WorkspaceBuild.ProvisionerState,
|
||||
&i.WorkspaceBuild.JobID,
|
||||
&i.WorkspaceBuild.Deadline,
|
||||
&i.WorkspaceBuild.Reason,
|
||||
@@ -21007,7 +21014,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.has_external_agent, 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.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
@@ -21055,7 +21062,6 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21163,7 +21169,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21186,7 +21192,6 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21205,7 +21210,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w
|
||||
const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many
|
||||
SELECT
|
||||
DISTINCT ON (workspace_id)
|
||||
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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21232,7 +21237,6 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21260,7 +21264,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21281,7 +21285,6 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21299,7 +21302,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21320,7 +21323,6 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21338,7 +21340,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21363,7 +21365,6 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21435,6 +21436,48 @@ func (q *sqlQuerier) GetWorkspaceBuildMetricsByResourceID(ctx context.Context, i
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildProvisionerStateByID = `-- name: GetWorkspaceBuildProvisionerStateByID :one
|
||||
SELECT
|
||||
workspace_builds.provisioner_state,
|
||||
templates.id AS template_id,
|
||||
templates.organization_id AS template_organization_id,
|
||||
templates.user_acl,
|
||||
templates.group_acl
|
||||
FROM
|
||||
workspace_builds
|
||||
INNER JOIN
|
||||
workspaces ON workspaces.id = workspace_builds.workspace_id
|
||||
INNER JOIN
|
||||
templates ON templates.id = workspaces.template_id
|
||||
WHERE
|
||||
workspace_builds.id = $1
|
||||
`
|
||||
|
||||
type GetWorkspaceBuildProvisionerStateByIDRow struct {
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
TemplateOrganizationID uuid.UUID `db:"template_organization_id" json:"template_organization_id"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
}
|
||||
|
||||
// Fetches the provisioner state of a workspace build, joined through to the
|
||||
// template so that dbauthz can enforce policy.ActionUpdate on the template.
|
||||
// Provisioner state contains sensitive Terraform state and should only be
|
||||
// accessible to template administrators.
|
||||
func (q *sqlQuerier) GetWorkspaceBuildProvisionerStateByID(ctx context.Context, workspaceBuildID uuid.UUID) (GetWorkspaceBuildProvisionerStateByIDRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWorkspaceBuildProvisionerStateByID, workspaceBuildID)
|
||||
var i GetWorkspaceBuildProvisionerStateByIDRow
|
||||
err := row.Scan(
|
||||
&i.ProvisionerState,
|
||||
&i.TemplateID,
|
||||
&i.TemplateOrganizationID,
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildStatsByTemplates = `-- name: GetWorkspaceBuildStatsByTemplates :many
|
||||
SELECT
|
||||
w.template_id,
|
||||
@@ -21504,7 +21547,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
@@ -21568,7 +21611,6 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
@@ -21595,7 +21637,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, has_external_agent, 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, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, has_external_agent, 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) {
|
||||
@@ -21616,7 +21658,6 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
|
||||
@@ -25,10 +25,12 @@ LIMIT
|
||||
SELECT * FROM api_keys WHERE last_used > $1;
|
||||
|
||||
-- name: GetAPIKeysByLoginType :many
|
||||
SELECT * FROM api_keys WHERE login_type = $1;
|
||||
SELECT * FROM api_keys WHERE login_type = $1
|
||||
AND (@include_expired::bool OR expires_at > now());
|
||||
|
||||
-- name: GetAPIKeysByUserID :many
|
||||
SELECT * FROM api_keys WHERE login_type = $1 AND user_id = $2;
|
||||
SELECT * FROM api_keys WHERE login_type = $1 AND user_id = $2
|
||||
AND (@include_expired::bool OR expires_at > now());
|
||||
|
||||
-- name: InsertAPIKey :one
|
||||
INSERT INTO
|
||||
|
||||
@@ -271,3 +271,23 @@ JOIN workspace_resources wr ON wr.job_id = wb.job_id
|
||||
JOIN workspace_agents wa ON wa.resource_id = wr.id
|
||||
WHERE wb.job_id = (SELECT job_id FROM workspace_resources WHERE workspace_resources.id = $1)
|
||||
GROUP BY wb.created_at, wb.transition, t.name, o.name, w.owner_id;
|
||||
|
||||
-- name: GetWorkspaceBuildProvisionerStateByID :one
|
||||
-- Fetches the provisioner state of a workspace build, joined through to the
|
||||
-- template so that dbauthz can enforce policy.ActionUpdate on the template.
|
||||
-- Provisioner state contains sensitive Terraform state and should only be
|
||||
-- accessible to template administrators.
|
||||
SELECT
|
||||
workspace_builds.provisioner_state,
|
||||
templates.id AS template_id,
|
||||
templates.organization_id AS template_organization_id,
|
||||
templates.user_acl,
|
||||
templates.group_acl
|
||||
FROM
|
||||
workspace_builds
|
||||
INNER JOIN
|
||||
workspaces ON workspaces.id = workspace_builds.workspace_id
|
||||
INNER JOIN
|
||||
templates ON templates.id = workspaces.template_id
|
||||
WHERE
|
||||
workspace_builds.id = @workspace_build_id;
|
||||
|
||||
@@ -348,8 +348,12 @@ func reapJob(ctx context.Context, log slog.Logger, db database.Store, pub pubsub
|
||||
|
||||
// Only copy the provisioner state if there's no state in
|
||||
// the current build.
|
||||
if len(build.ProvisionerState) == 0 {
|
||||
// Get the previous build if it exists.
|
||||
currentStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace build provisioner state: %w", err)
|
||||
}
|
||||
if len(currentStateRow.ProvisionerState) == 0 {
|
||||
// Get the previous build's state if it exists.
|
||||
prevBuild, err := db.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
|
||||
WorkspaceID: build.WorkspaceID,
|
||||
BuildNumber: build.BuildNumber - 1,
|
||||
@@ -358,10 +362,14 @@ func reapJob(ctx context.Context, log slog.Logger, db database.Store, pub pubsub
|
||||
return xerrors.Errorf("get previous workspace build: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
prevStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, prevBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get previous workspace build provisioner state: %w", err)
|
||||
}
|
||||
err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
||||
ID: build.ID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
ProvisionerState: prevBuild.ProvisionerState,
|
||||
ProvisionerState: prevStateRow.ProvisionerState,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace build by id: %w", err)
|
||||
|
||||
@@ -126,9 +126,9 @@ func TestDetectorHungWorkspaceBuild(t *testing.T) {
|
||||
previousBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{
|
||||
ProvisionerState: expectedWorkspaceBuildState,
|
||||
}).Succeeded(dbfake.WithJobCompletedAt(twentyMinAgo)).
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{}).
|
||||
ProvisionerState(expectedWorkspaceBuildState).
|
||||
Succeeded(dbfake.WithJobCompletedAt(twentyMinAgo)).
|
||||
Do()
|
||||
|
||||
// Current build (hung - running job with UpdatedAt > 5 min ago).
|
||||
@@ -163,7 +163,9 @@ func TestDetectorHungWorkspaceBuild(t *testing.T) {
|
||||
// Check that the provisioner state was copied.
|
||||
build, err := db.GetWorkspaceBuildByID(ctx, currentBuild.Build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, build.ProvisionerState)
|
||||
provisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, provisionerStateRow.ProvisionerState)
|
||||
|
||||
detector.Close()
|
||||
detector.Wait()
|
||||
@@ -194,9 +196,9 @@ func TestDetectorHungWorkspaceBuildNoOverrideState(t *testing.T) {
|
||||
previousBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{
|
||||
ProvisionerState: []byte(`{"dean":"NOT cool","colin":"also NOT cool"}`),
|
||||
}).Succeeded(dbfake.WithJobCompletedAt(twentyMinAgo)).
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{}).
|
||||
ProvisionerState([]byte(`{"dean":"NOT cool","colin":"also NOT cool"}`)).
|
||||
Succeeded(dbfake.WithJobCompletedAt(twentyMinAgo)).
|
||||
Do()
|
||||
|
||||
// Current build (hung - running job with UpdatedAt > 5 min ago).
|
||||
@@ -204,9 +206,8 @@ func TestDetectorHungWorkspaceBuildNoOverrideState(t *testing.T) {
|
||||
currentBuild := dbfake.WorkspaceBuild(t, db, previousBuild.Workspace).
|
||||
Pubsub(pubsub).
|
||||
Seed(database.WorkspaceBuild{
|
||||
BuildNumber: 2,
|
||||
ProvisionerState: expectedWorkspaceBuildState,
|
||||
}).
|
||||
BuildNumber: 2,
|
||||
}).ProvisionerState(expectedWorkspaceBuildState).
|
||||
Starting(dbfake.WithJobStartedAt(tenMinAgo), dbfake.WithJobUpdatedAt(sixMinAgo)).
|
||||
Do()
|
||||
|
||||
@@ -235,7 +236,9 @@ func TestDetectorHungWorkspaceBuildNoOverrideState(t *testing.T) {
|
||||
// Check that the provisioner state was NOT copied.
|
||||
build, err := db.GetWorkspaceBuildByID(ctx, currentBuild.Build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, build.ProvisionerState)
|
||||
provisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, provisionerStateRow.ProvisionerState)
|
||||
|
||||
detector.Close()
|
||||
detector.Wait()
|
||||
@@ -266,9 +269,9 @@ func TestDetectorHungWorkspaceBuildNoOverrideStateIfNoExistingBuild(t *testing.T
|
||||
currentBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{
|
||||
ProvisionerState: expectedWorkspaceBuildState,
|
||||
}).Starting(dbfake.WithJobStartedAt(tenMinAgo), dbfake.WithJobUpdatedAt(sixMinAgo)).
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{}).
|
||||
ProvisionerState(expectedWorkspaceBuildState).
|
||||
Starting(dbfake.WithJobStartedAt(tenMinAgo), dbfake.WithJobUpdatedAt(sixMinAgo)).
|
||||
Do()
|
||||
|
||||
t.Log("current job ID: ", currentBuild.Build.JobID)
|
||||
@@ -295,7 +298,9 @@ func TestDetectorHungWorkspaceBuildNoOverrideStateIfNoExistingBuild(t *testing.T
|
||||
// Check that the provisioner state was NOT updated.
|
||||
build, err := db.GetWorkspaceBuildByID(ctx, currentBuild.Build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, build.ProvisionerState)
|
||||
provisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, provisionerStateRow.ProvisionerState)
|
||||
|
||||
detector.Close()
|
||||
detector.Wait()
|
||||
@@ -325,9 +330,9 @@ func TestDetectorPendingWorkspaceBuildNoOverrideStateIfNoExistingBuild(t *testin
|
||||
currentBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{
|
||||
ProvisionerState: expectedWorkspaceBuildState,
|
||||
}).Pending(dbfake.WithJobCreatedAt(thirtyFiveMinAgo), dbfake.WithJobUpdatedAt(thirtyFiveMinAgo)).
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{}).
|
||||
ProvisionerState(expectedWorkspaceBuildState).
|
||||
Pending(dbfake.WithJobCreatedAt(thirtyFiveMinAgo), dbfake.WithJobUpdatedAt(thirtyFiveMinAgo)).
|
||||
Do()
|
||||
|
||||
t.Log("current job ID: ", currentBuild.Build.JobID)
|
||||
@@ -356,7 +361,9 @@ func TestDetectorPendingWorkspaceBuildNoOverrideStateIfNoExistingBuild(t *testin
|
||||
// Check that the provisioner state was NOT updated.
|
||||
build, err := db.GetWorkspaceBuildByID(ctx, currentBuild.Build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, build.ProvisionerState)
|
||||
provisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedWorkspaceBuildState, provisionerStateRow.ProvisionerState)
|
||||
|
||||
detector.Close()
|
||||
detector.Wait()
|
||||
@@ -398,9 +405,9 @@ func TestDetectorWorkspaceBuildForDormantWorkspace(t *testing.T) {
|
||||
Time: now.Add(-time.Hour),
|
||||
Valid: true,
|
||||
},
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{
|
||||
ProvisionerState: expectedWorkspaceBuildState,
|
||||
}).Starting(dbfake.WithJobStartedAt(tenMinAgo), dbfake.WithJobUpdatedAt(sixMinAgo)).
|
||||
}).Pubsub(pubsub).Seed(database.WorkspaceBuild{}).
|
||||
ProvisionerState(expectedWorkspaceBuildState).
|
||||
Starting(dbfake.WithJobStartedAt(tenMinAgo), dbfake.WithJobUpdatedAt(sixMinAgo)).
|
||||
Do()
|
||||
|
||||
t.Log("current job ID: ", currentBuild.Build.JobID)
|
||||
|
||||
@@ -725,11 +725,16 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
|
||||
}
|
||||
}
|
||||
|
||||
provisionerStateRow, err := s.Database.GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuild.ID)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("get workspace build provisioner state: %s", err))
|
||||
}
|
||||
|
||||
protoJob.Type = &proto.AcquiredJob_WorkspaceBuild_{
|
||||
WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{
|
||||
WorkspaceBuildId: workspaceBuild.ID.String(),
|
||||
WorkspaceName: workspace.Name,
|
||||
State: workspaceBuild.ProvisionerState,
|
||||
State: provisionerStateRow.ProvisionerState,
|
||||
RichParameterValues: convertRichParameterValues(workspaceBuildParameters),
|
||||
PreviousParameterValues: convertRichParameterValues(lastWorkspaceBuildParameters),
|
||||
VariableValues: asVariableValues(templateVariables),
|
||||
|
||||
@@ -1321,7 +1321,9 @@ func TestFailJob(t *testing.T) {
|
||||
<-publishedLogs
|
||||
build, err := db.GetWorkspaceBuildByID(ctx, buildID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "some state", string(build.ProvisionerState))
|
||||
provisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "some state", string(provisionerStateRow.ProvisionerState))
|
||||
require.Len(t, auditor.AuditLogs(), 1)
|
||||
|
||||
// Assert that the workspace_id field get populated
|
||||
|
||||
@@ -81,6 +81,7 @@ const (
|
||||
SubjectAibridged SubjectType = "aibridged"
|
||||
SubjectTypeDBPurge SubjectType = "dbpurge"
|
||||
SubjectTypeBoundaryUsageTracker SubjectType = "boundary_usage_tracker"
|
||||
SubjectTypeWorkspaceBuilder SubjectType = "workspace_builder"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@@ -35,14 +36,11 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/prebuilds"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
maputil "github.com/coder/coder/v2/coderd/util/maps"
|
||||
strutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -295,6 +293,7 @@ func (api *API) patchWorkspaceAgentLogs(rw http.ResponseWriter, r *http.Request)
|
||||
// @Param request body agentsdk.PatchAppStatus true "app status"
|
||||
// @Success 200 {object} codersdk.Response
|
||||
// @Router /workspaceagents/me/app-status [patch]
|
||||
// @Deprecated Use UpdateAppStatus on the Agent API instead.
|
||||
func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
@@ -304,45 +303,6 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
app, err := api.Database.GetWorkspaceAppByAgentIDAndSlug(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{
|
||||
AgentID: workspaceAgent.ID,
|
||||
Slug: req.AppSlug,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to get workspace app.",
|
||||
Detail: fmt.Sprintf("No app found with slug %q", req.AppSlug),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Message) > 160 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Message is too long.",
|
||||
Detail: "Message must be less than 160 characters.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "message", Detail: "Message must be less than 160 characters."},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch req.State {
|
||||
case codersdk.WorkspaceAppStatusStateComplete,
|
||||
codersdk.WorkspaceAppStatusStateFailure,
|
||||
codersdk.WorkspaceAppStatusStateWorking,
|
||||
codersdk.WorkspaceAppStatusStateIdle: // valid states
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid state provided.",
|
||||
Detail: fmt.Sprintf("invalid state: %q", req.State),
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "state", Detail: "State must be one of: complete, failure, working."},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
@@ -352,176 +312,50 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Treat the message as untrusted input.
|
||||
cleaned := strutil.UISanitize(req.Message)
|
||||
|
||||
// Get the latest status for the workspace app to detect no-op updates
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
latestAppStatus, err := api.Database.GetLatestWorkspaceAppStatusByAppID(dbauthz.AsSystemRestricted(ctx), app.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get latest workspace app status.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// If no rows found, latestAppStatus will be a zero-value struct (ID == uuid.Nil)
|
||||
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
_, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: workspaceAgent.ID,
|
||||
AppID: app.ID,
|
||||
State: database.WorkspaceAppStatusState(req.State),
|
||||
Message: cleaned,
|
||||
Uri: sql.NullString{
|
||||
String: req.URI,
|
||||
Valid: req.URI != "",
|
||||
// This functionality has been moved to the AppsAPI in the agentapi. We keep this HTTP handler around for back
|
||||
// compatibility with older agents. We'll translate the request into the protobuf so there is only one primary
|
||||
// implementation.
|
||||
appAPI := &agentapi.AppsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return workspaceAgent, nil
|
||||
},
|
||||
})
|
||||
Database: api.Database,
|
||||
Log: api.Logger,
|
||||
PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent, kind wspubsub.WorkspaceEventKind) error {
|
||||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||||
Kind: kind,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: &agent.ID,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
NotificationsEnqueuer: api.NotificationsEnqueuer,
|
||||
Clock: api.Clock,
|
||||
}
|
||||
protoReq, err := agentsdk.ProtoFromPatchAppStatus(req)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to insert workspace app status.",
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to parse request.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||||
Kind: wspubsub.WorkspaceEventKindAgentAppStatusUpdate,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: &workspaceAgent.ID,
|
||||
})
|
||||
|
||||
// Notify on state change to Working/Idle for AI tasks
|
||||
api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace, workspaceAgent)
|
||||
|
||||
// Bump deadline when agent reports working or transitions away from working.
|
||||
// This prevents auto-pause during active work and gives users time to interact
|
||||
// after work completes.
|
||||
shouldBump := false
|
||||
newState := database.WorkspaceAppStatusState(req.State)
|
||||
|
||||
// Bump if reporting working state.
|
||||
if newState == database.WorkspaceAppStatusStateWorking {
|
||||
shouldBump = true
|
||||
}
|
||||
|
||||
// Bump if transitioning away from working state.
|
||||
if latestAppStatus.ID != uuid.Nil {
|
||||
prevState := latestAppStatus.State
|
||||
if prevState == database.WorkspaceAppStatusStateWorking {
|
||||
shouldBump = true
|
||||
_, err = appAPI.UpdateAppStatus(r.Context(), protoReq)
|
||||
if err != nil {
|
||||
sdkErr := new(codersdk.Error)
|
||||
if xerrors.As(err, &sdkErr) {
|
||||
httpapi.Write(ctx, rw, sdkErr.StatusCode(), sdkErr.Response)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update app status.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if shouldBump {
|
||||
// We pass time.Time{} for nextAutostart since we don't have access to
|
||||
// TemplateScheduleStore here. The activity bump logic handles this by
|
||||
// defaulting to the template's activity_bump duration (typically 1 hour).
|
||||
workspacestats.ActivityBumpWorkspace(ctx, api.Logger, api.Database, workspace.ID, time.Time{})
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// enqueueAITaskStateNotification enqueues a notification when an AI task's app
|
||||
// transitions to Working or Idle.
|
||||
// No-op if:
|
||||
// - the workspace agent app isn't configured as an AI task,
|
||||
// - the new state equals the latest persisted state,
|
||||
// - the workspace agent is not ready (still starting up).
|
||||
func (api *API) enqueueAITaskStateNotification(
|
||||
ctx context.Context,
|
||||
appID uuid.UUID,
|
||||
latestAppStatus database.WorkspaceAppStatus,
|
||||
newAppStatus codersdk.WorkspaceAppStatusState,
|
||||
workspace database.Workspace,
|
||||
agent database.WorkspaceAgent,
|
||||
) {
|
||||
// Select notification template based on the new state
|
||||
var notificationTemplate uuid.UUID
|
||||
switch newAppStatus {
|
||||
case codersdk.WorkspaceAppStatusStateWorking:
|
||||
notificationTemplate = notifications.TemplateTaskWorking
|
||||
case codersdk.WorkspaceAppStatusStateIdle:
|
||||
notificationTemplate = notifications.TemplateTaskIdle
|
||||
case codersdk.WorkspaceAppStatusStateComplete:
|
||||
notificationTemplate = notifications.TemplateTaskCompleted
|
||||
case codersdk.WorkspaceAppStatusStateFailure:
|
||||
notificationTemplate = notifications.TemplateTaskFailed
|
||||
default:
|
||||
// Not a notifiable state, do nothing
|
||||
return
|
||||
}
|
||||
|
||||
if !workspace.TaskID.Valid {
|
||||
// Workspace has no task ID, do nothing.
|
||||
return
|
||||
}
|
||||
|
||||
// Only send notifications when the agent is ready. We want to skip
|
||||
// any state transitions that occur whilst the workspace is starting
|
||||
// up as it doesn't make sense to receive them.
|
||||
if agent.LifecycleState != database.WorkspaceAgentLifecycleStateReady {
|
||||
api.Logger.Debug(ctx, "skipping AI task notification because agent is not ready",
|
||||
slog.F("agent_id", agent.ID),
|
||||
slog.F("lifecycle_state", agent.LifecycleState),
|
||||
slog.F("new_app_status", newAppStatus),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := api.Database.GetTaskByID(ctx, workspace.TaskID.UUID)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to get task", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !task.WorkspaceAppID.Valid || task.WorkspaceAppID.UUID != appID {
|
||||
// Non-task app, do nothing.
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if the latest persisted state equals the new state (no new transition)
|
||||
// Note: uuid.Nil check is valid here. If no previous status exists,
|
||||
// GetLatestWorkspaceAppStatusByAppID returns sql.ErrNoRows and we get a zero-value struct.
|
||||
if latestAppStatus.ID != uuid.Nil && latestAppStatus.State == database.WorkspaceAppStatusState(newAppStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the initial "Working" notification when task first starts.
|
||||
// This is obvious to the user since they just created the task.
|
||||
// We still notify on first "Idle" status and all subsequent transitions.
|
||||
if latestAppStatus.ID == uuid.Nil && newAppStatus == codersdk.WorkspaceAppStatusStateWorking {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
|
||||
// nolint:gocritic // Need notifier actor to enqueue notifications
|
||||
dbauthz.AsNotifier(ctx),
|
||||
workspace.OwnerID,
|
||||
notificationTemplate,
|
||||
map[string]string{
|
||||
"task": task.Name,
|
||||
"workspace": workspace.Name,
|
||||
},
|
||||
map[string]any{
|
||||
// Use a 1-minute bucketed timestamp to bypass per-day dedupe,
|
||||
// allowing identical content to resend within the same day
|
||||
// (but not more than once every 10s).
|
||||
"dedupe_bypass_ts": api.Clock.Now().UTC().Truncate(time.Minute),
|
||||
},
|
||||
"api-workspace-agent-app-status",
|
||||
// Associate this notification with related entities
|
||||
workspace.ID, workspace.OwnerID, workspace.OrganizationID, appID,
|
||||
); err != nil {
|
||||
api.Logger.Warn(ctx, "failed to notify of task state", slog.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// workspaceAgentLogs returns the logs associated with a workspace agent
|
||||
//
|
||||
// @Summary Get logs by workspace agent
|
||||
|
||||
@@ -856,32 +856,24 @@ func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "No workspace exists for this job.",
|
||||
})
|
||||
|
||||
// The dbauthz layer enforces policy.ActionUpdate on the template.
|
||||
row, err := api.Database.GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuild.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get template",
|
||||
Message: "Internal error fetching provisioner state.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// You must have update permissions on the template to get the state.
|
||||
// This matches a push!
|
||||
if !api.Authorize(r, policy.ActionUpdate, template.RBACObject()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = rw.Write(workspaceBuild.ProvisionerState)
|
||||
_, _ = rw.Write(row.ProvisionerState)
|
||||
}
|
||||
|
||||
// @Summary Update workspace build state
|
||||
|
||||
@@ -460,7 +460,7 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
|
||||
// to read all provisioner daemons. We need to retrieve the eligible
|
||||
// provisioner daemons for this job to show in the UI if there is no
|
||||
// matching provisioner daemon.
|
||||
provisionerDaemons, err := b.store.GetEligibleProvisionerDaemonsByProvisionerJobIDs(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), []uuid.UUID{provisionerJob.ID})
|
||||
provisionerDaemons, err := b.store.GetEligibleProvisionerDaemonsByProvisionerJobIDs(dbauthz.AsWorkspaceBuilder(b.ctx), []uuid.UUID{provisionerJob.ID})
|
||||
if err != nil {
|
||||
// NOTE: we do **not** want to fail a workspace build if we fail to
|
||||
// retrieve provisioner daemons. This is just to show in the UI if there
|
||||
@@ -578,8 +578,8 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
|
||||
}
|
||||
}
|
||||
if b.state.orphan && !hasActiveEligibleProvisioner {
|
||||
// nolint: gocritic // At this moment, we are pretending to be provisionerd.
|
||||
if err := store.UpdateProvisionerJobWithCompleteWithStartedAtByID(dbauthz.AsProvisionerd(b.ctx), database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{
|
||||
// nolint: gocritic // User won't necessarily have the permission to do this so we act as a system user.
|
||||
if err := store.UpdateProvisionerJobWithCompleteWithStartedAtByID(dbauthz.AsWorkspaceBuilder(b.ctx), database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: now},
|
||||
Error: sql.NullString{Valid: false},
|
||||
ErrorCode: sql.NullString{Valid: false},
|
||||
@@ -823,7 +823,12 @@ func (b *Builder) getState() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get last build to get state: %w", err)
|
||||
}
|
||||
return bld.ProvisionerState, nil
|
||||
// nolint: gocritic // Workspace builder needs to read provisioner state for the new build.
|
||||
state, err := b.store.GetWorkspaceBuildProvisionerStateByID(dbauthz.AsWorkspaceBuilder(b.ctx), bld.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace build provisioner state: %w", err)
|
||||
}
|
||||
return state.ProvisionerState, nil
|
||||
}
|
||||
|
||||
func (b *Builder) getParameters() (names, values []string, err error) {
|
||||
|
||||
@@ -65,6 +65,7 @@ func TestBuilder_NoOptions(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -124,6 +125,7 @@ func TestBuilder_Initiator(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -174,6 +176,7 @@ func TestBuilder_Baggage(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -216,6 +219,7 @@ func TestBuilder_Reason(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -365,6 +369,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(richParameters),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, templateVersionVariables),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -464,6 +469,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(richParameters),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(initialBuildParameters),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -515,6 +521,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(richParameters),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(initialBuildParameters),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -661,6 +668,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
|
||||
withTemplate,
|
||||
withActiveVersion(version2params),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(activeVersionID, nil),
|
||||
withRichParameters(initialBuildParameters),
|
||||
withParameterSchemas(activeJobID, nil),
|
||||
@@ -727,6 +735,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
|
||||
withTemplate,
|
||||
withActiveVersion(version2params),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(activeVersionID, nil),
|
||||
withRichParameters(initialBuildParameters),
|
||||
withParameterSchemas(activeJobID, nil),
|
||||
@@ -791,6 +800,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
|
||||
withTemplate,
|
||||
withActiveVersion(version2params),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(activeVersionID, nil),
|
||||
withRichParameters(initialBuildParameters),
|
||||
withParameterSchemas(activeJobID, nil),
|
||||
@@ -1062,6 +1072,7 @@ func TestWorkspaceBuildUsageChecker(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -1175,6 +1186,7 @@ func TestWorkspaceBuildWithTask(t *testing.T) {
|
||||
withTemplate,
|
||||
withInactiveVersion(nil),
|
||||
withLastBuildFound,
|
||||
withLastBuildState,
|
||||
withTemplateVersionVariables(inactiveVersionID, nil),
|
||||
withRichParameters(nil),
|
||||
withParameterSchemas(inactiveJobID, nil),
|
||||
@@ -1378,7 +1390,6 @@ func withLastBuildFound(mTx *dbmock.MockStore) {
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
InitiatorID: userID,
|
||||
JobID: lastBuildJobID,
|
||||
ProvisionerState: []byte("last build state"),
|
||||
Reason: database.BuildReasonInitiator,
|
||||
}, nil)
|
||||
|
||||
@@ -1398,6 +1409,14 @@ func withLastBuildFound(mTx *dbmock.MockStore) {
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func withLastBuildState(mTx *dbmock.MockStore) {
|
||||
mTx.EXPECT().GetWorkspaceBuildProvisionerStateByID(gomock.Any(), lastBuildID).
|
||||
Times(1).
|
||||
Return(database.GetWorkspaceBuildProvisionerStateByIDRow{
|
||||
ProvisionerState: []byte("last build state"),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func withLastBuildNotFound(mTx *dbmock.MockStore) {
|
||||
mTx.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Times(1).
|
||||
|
||||
@@ -464,3 +464,16 @@ func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.Worksp
|
||||
SubagentId: subagentID,
|
||||
}
|
||||
}
|
||||
|
||||
func ProtoFromPatchAppStatus(pas PatchAppStatus) (*proto.UpdateAppStatusRequest, error) {
|
||||
state, ok := proto.UpdateAppStatusRequest_AppStatusState_value[strings.ToUpper(string(pas.State))]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("Invalid state: %s", pas.State)
|
||||
}
|
||||
return &proto.UpdateAppStatusRequest{
|
||||
Slug: pas.AppSlug,
|
||||
State: proto.UpdateAppStatusRequest_AppStatusState(state),
|
||||
Message: pas.Message,
|
||||
Uri: pas.URI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyR
|
||||
}
|
||||
|
||||
type TokensFilter struct {
|
||||
IncludeAll bool `json:"include_all"`
|
||||
IncludeAll bool `json:"include_all"`
|
||||
IncludeExpired bool `json:"include_expired"`
|
||||
}
|
||||
|
||||
type APIKeyWithOwner struct {
|
||||
@@ -112,6 +113,7 @@ func (f TokensFilter) asRequestOption() RequestOption {
|
||||
return func(r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
q.Set("include_all", fmt.Sprintf("%t", f.IncludeAll))
|
||||
q.Set("include_expired", fmt.Sprintf("%t", f.IncludeExpired))
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +535,14 @@ func NewTestError(statusCode int, method string, u string) *Error {
|
||||
}
|
||||
}
|
||||
|
||||
// NewError creates a new Error with the response and status code.
|
||||
func NewError(statusCode int, response Response) *Error {
|
||||
return &Error{
|
||||
statusCode: statusCode,
|
||||
Response: response,
|
||||
}
|
||||
}
|
||||
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
|
||||
@@ -36,7 +36,7 @@ We track the following resources:
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>disable_module_cache</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
|
||||
@@ -105,8 +105,13 @@ expires the token, (soft-delete):
|
||||
coder tokens remove <name|id>
|
||||
```
|
||||
|
||||
Expired tokens can no longer be used for authentication but remain visible in
|
||||
token listings.
|
||||
Expired tokens can no longer be used for authentication and are hidden from
|
||||
token listings by default. To include expired tokens, use the
|
||||
`--include-expired` flag:
|
||||
|
||||
```console
|
||||
coder tokens list --include-expired
|
||||
```
|
||||
|
||||
To hard-delete a token, use the `--delete` flag:
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -746,9 +746,10 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------|----------|----------------------|
|
||||
| `user` | path | string | true | User ID, name, or me |
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------------------|-------|---------|----------|------------------------------------|
|
||||
| `user` | path | string | true | User ID, name, or me |
|
||||
| `include_expired` | query | boolean | false | Include expired tokens in the list |
|
||||
|
||||
### Example responses
|
||||
|
||||
|
||||
@@ -55,13 +55,25 @@ To list shared workspaces:
|
||||
|
||||
### UI
|
||||
|
||||
#### Sharing your Workspace
|
||||
|
||||
1. Open a workspace that you own.
|
||||
|
||||
1. Locate and click the 'Share' button.
|
||||
|
||||

|
||||
|
||||
1. Add the users or groups that you want to share the workspace with. For each one, select a role.
|
||||
|
||||

|
||||
|
||||
- `use` allows for connection via SSH and apps, the ability to start and stop the workspace, view logs and stats, and update on start when required.
|
||||
- `admin` allows for all of the above, as well as the ability to rename the workspace, update at any time, and invite others with the `use` role.
|
||||
- Neither role allows for the user to delete the workspace.
|
||||
- After removing a user/group, a workspace restart is required for the removal to take effect.
|
||||
|
||||
#### Using a shared workspace
|
||||
|
||||
Once a workspace is shared, you can find the shared workspace by filtering for "Shared" in the Workspaces page.
|
||||
|
||||

|
||||
|
||||
@@ -190,7 +190,6 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"build_number": ActionIgnore,
|
||||
"transition": ActionIgnore,
|
||||
"initiator_id": ActionIgnore,
|
||||
"provisioner_state": ActionIgnore,
|
||||
"job_id": ActionIgnore,
|
||||
"deadline": ActionIgnore,
|
||||
"reason": ActionIgnore,
|
||||
|
||||
@@ -253,13 +253,16 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
|
||||
TemplateID: template.ID,
|
||||
}).Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
ProvisionerState: []byte(must(cryptorand.String(64))),
|
||||
}).Succeeded(dbfake.WithJobCompletedAt(buildTime)).Do()
|
||||
}).ProvisionerState([]byte(must(cryptorand.String(64)))).Succeeded(dbfake.WithJobCompletedAt(buildTime)).Do()
|
||||
|
||||
// Assert test invariant: workspace build state must not be empty
|
||||
require.NotEmpty(t, buildResp.Build.ProvisionerState, "provisioner state must not be empty")
|
||||
var buildProvisionerState []byte
|
||||
buildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, buildResp.Build.ID)
|
||||
require.NoError(t, err)
|
||||
buildProvisionerState = buildProvisionerStateRow.ProvisionerState
|
||||
require.NotEmpty(t, buildProvisionerState, "provisioner state must not be empty")
|
||||
|
||||
err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||||
err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||||
ID: buildResp.Build.ID,
|
||||
UpdatedAt: buildTime,
|
||||
Deadline: c.deadline,
|
||||
@@ -310,7 +313,9 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
|
||||
require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second, "max_deadline")
|
||||
|
||||
// Check that the new build has the same state as before.
|
||||
require.Equal(t, wsBuild.ProvisionerState, newBuild.ProvisionerState, "provisioner state mismatch")
|
||||
newBuildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, newBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, buildProvisionerState, newBuildProvisionerStateRow.ProvisionerState, "provisioner state mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -388,7 +393,8 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
shouldBeUpdated bool
|
||||
|
||||
// Set below:
|
||||
wsBuild database.WorkspaceBuild
|
||||
wsBuild database.WorkspaceBuild
|
||||
wsBuildProvisionerState []byte
|
||||
}{
|
||||
{
|
||||
name: "DifferentTemplate",
|
||||
@@ -483,19 +489,25 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
},
|
||||
OrganizationID: templateJob.OrganizationID,
|
||||
})
|
||||
wsBuildProvisionerState := []byte(must(cryptorand.String(64)))
|
||||
wsBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
WorkspaceID: wsID,
|
||||
BuildNumber: b.buildNumber,
|
||||
JobID: job.ID,
|
||||
InitiatorID: user.ID,
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
ProvisionerState: []byte(must(cryptorand.String(64))),
|
||||
})
|
||||
err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
||||
ID: wsBuild.ID,
|
||||
UpdatedAt: wsBuild.UpdatedAt,
|
||||
ProvisionerState: wsBuildProvisionerState,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert test invariant: workspace build state must not be empty
|
||||
require.NotEmpty(t, wsBuild.ProvisionerState, "provisioner state must not be empty")
|
||||
require.NotEmpty(t, wsBuildProvisionerState, "provisioner state must not be empty")
|
||||
|
||||
err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||||
err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||||
ID: wsBuild.ID,
|
||||
UpdatedAt: buildTime,
|
||||
Deadline: originalMaxDeadline,
|
||||
@@ -507,8 +519,9 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert test invariant: workspace build state must not be empty
|
||||
require.NotEmpty(t, wsBuild.ProvisionerState, "provisioner state must not be empty")
|
||||
require.NotEmpty(t, wsBuildProvisionerState, "provisioner state must not be empty")
|
||||
|
||||
builds[i].wsBuildProvisionerState = wsBuildProvisionerState
|
||||
builds[i].wsBuild = wsBuild
|
||||
|
||||
if !b.buildStarted {
|
||||
@@ -595,7 +608,9 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
assert.WithinDuration(t, originalMaxDeadline, newBuild.MaxDeadline, time.Second, msg)
|
||||
}
|
||||
|
||||
assert.Equal(t, builds[i].wsBuild.ProvisionerState, newBuild.ProvisionerState, "provisioner state mismatch")
|
||||
newBuildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, newBuild.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, builds[i].wsBuildProvisionerState, newBuildProvisionerStateRow.ProvisionerState, "provisioner state mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1107,6 +1107,7 @@ func TestProvision(t *testing.T) {
|
||||
require.Contains(t, initComplete.Error, testCase.InitErrorContains)
|
||||
return
|
||||
}
|
||||
require.Empty(t, initComplete.Error, "unexpected init error")
|
||||
|
||||
planRequest := &proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{
|
||||
Metadata: testCase.Metadata,
|
||||
|
||||
@@ -5558,6 +5558,7 @@ export interface TokenConfig {
|
||||
// From codersdk/apikey.go
|
||||
export interface TokensFilter {
|
||||
readonly include_all: boolean;
|
||||
readonly include_expired: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
|
||||
@@ -50,6 +50,7 @@ export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Initiator</TableHead>
|
||||
<TableHead>Tokens</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Tool Calls</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -12,7 +12,8 @@ import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { type FC, Fragment, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatDate, humanDuration } from "utils/time";
|
||||
import { AIBridgeModelIcon } from "../AIBridgeModelIcon";
|
||||
import { AIBridgeClientIcon } from "../icons/AIBridgeClientIcon";
|
||||
import { AIBridgeModelIcon } from "../icons/AIBridgeModelIcon";
|
||||
|
||||
type RequestLogsRowProps = {
|
||||
interception: AIBridgeInterception;
|
||||
@@ -191,10 +192,32 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-40 max-w-40">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeClientIcon
|
||||
client={interception.client}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0">
|
||||
{interception.client ?? "Unknown"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{interception.client}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-40 max-w-40">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeModelIcon
|
||||
@@ -206,11 +229,11 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
|
||||
{interception.model}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{interception.model}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{interception.model}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-32 text-center">{toolCalls}</TableCell>
|
||||
</TableRow>
|
||||
@@ -268,6 +291,21 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Client:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<Badge className="gap-2">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeClientIcon
|
||||
client={interception.client}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0 w-full text-2xs">
|
||||
{interception.client ?? "Unknown"}
|
||||
</span>
|
||||
</Badge>
|
||||
</dd>
|
||||
|
||||
<dt>Model:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<Badge className="gap-2">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SelectFilterOption,
|
||||
} from "components/Filter/SelectFilter";
|
||||
import type { FC } from "react";
|
||||
import { AIBridgeProviderIcon } from "../AIBridgeProviderIcon";
|
||||
import { AIBridgeProviderIcon } from "../icons/AIBridgeProviderIcon";
|
||||
|
||||
const AIBRIDGE_PROVIDERS: SelectFilterOption[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AIBridgeInterception } from "api/typesGenerated";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { CircleQuestionMarkIcon } from "lucide-react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const AIBridgeClientIcon = ({
|
||||
client,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
client: AIBridgeInterception["client"];
|
||||
} & React.ComponentProps<"svg">) => {
|
||||
const iconClassName = "flex-shrink-0";
|
||||
// This should be kept in sync with the client names in
|
||||
// the AI Bridge bridge.go file.
|
||||
// https://github.com/coder/aibridge/blob/main/bridge.go#L31-L32
|
||||
switch (client) {
|
||||
case "Claude Code":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/claude.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "Codex":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/openai-codex.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "Kilo Code":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/kilo-code.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "Roo Code":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/roo-code.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "Zed":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/zed.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "Cursor":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/cursor.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "GitHub Copilot (VS Code)":
|
||||
case "GitHub Copilot (CLI)":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/github-copilot.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleQuestionMarkIcon
|
||||
className={cn(iconClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -33,6 +33,7 @@ function inferModelFamily(model: string): string {
|
||||
|
||||
export const AIBridgeModelIcon = ({
|
||||
model,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
model: string;
|
||||
@@ -44,20 +45,20 @@ export const AIBridgeModelIcon = ({
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/claude.svg"
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "openai":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/openai.svg"
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleQuestionMarkIcon
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { cn } from "utils/cn";
|
||||
|
||||
export const AIBridgeProviderIcon = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
provider: AIBridgeInterception["provider"];
|
||||
@@ -15,27 +16,27 @@ export const AIBridgeProviderIcon = ({
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/openai.svg"
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "anthropic":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/claude.svg"
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "copilot":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/github.svg"
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleQuestionMarkIcon
|
||||
className={cn(iconClassName, props.className)}
|
||||
className={cn(iconClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -27,6 +27,7 @@ const TokensPage: FC = () => {
|
||||
// we currently do not show all tokens in the UI, even if
|
||||
// the user has read all permissions
|
||||
include_all: false,
|
||||
include_expired: false,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
} from "react-query";
|
||||
|
||||
// Load all tokens
|
||||
export const useTokensData = ({ include_all }: TokensFilter) => {
|
||||
const queryKey = ["tokens", include_all];
|
||||
export const useTokensData = ({
|
||||
include_all,
|
||||
include_expired,
|
||||
}: TokensFilter) => {
|
||||
const queryKey = ["tokens", include_all, include_expired];
|
||||
const result = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => API.getTokens({ include_all }),
|
||||
queryFn: () => API.getTokens({ include_all, include_expired }),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -143,32 +143,38 @@ export function getExternalImageStylesFromUrl(
|
||||
export const defaultParametersForBuiltinIcons = new Map<string, string>([
|
||||
["/icon/apple-black.svg", "monochrome"],
|
||||
["/icon/auggie.svg", "monochrome"],
|
||||
["/icon/anthropic.svg", "monochrome"],
|
||||
["/icon/auto-dev-server.svg", "monochrome"],
|
||||
["/icon/aws-monochrome.svg", "monochrome"],
|
||||
["/icon/aws.png", "whiteWithColor&brightness=1.5"],
|
||||
["/icon/aws.svg", "whiteWithColor&brightness=1.5"],
|
||||
["/icon/aws-monochrome.svg", "monochrome"],
|
||||
["/icon/coder.svg", "monochrome"],
|
||||
["/icon/container.svg", "monochrome"],
|
||||
["/icon/copyparty.svg", "blackWithColor"],
|
||||
["/icon/database.svg", "monochrome"],
|
||||
["/icon/docker-white.svg", "monochrome"],
|
||||
["/icon/folder.svg", "monochrome"],
|
||||
["/icon/gemini-monochrome.svg", "monochrome"],
|
||||
["/icon/github-copilot.svg", "whiteWithColor"],
|
||||
["/icon/github.svg", "monochrome"],
|
||||
["/icon/image.svg", "monochrome"],
|
||||
["/icon/jupyter.svg", "blackWithColor"],
|
||||
["/icon/kasmvnc.svg", "whiteWithColor"],
|
||||
["/icon/kilo-code.svg", "blackWithColor"],
|
||||
["/icon/kiro.svg", "whiteWithColor"],
|
||||
["/icon/memory.svg", "monochrome"],
|
||||
["/icon/mux.svg", "monochrome"],
|
||||
["/icon/nexus-repository.svg", "blackWithColor"],
|
||||
["/icon/okta.svg", "monochrome"],
|
||||
["/icon/openai-codex.svg", "monochrome"],
|
||||
["/icon/openai.svg", "monochrome"],
|
||||
["/icon/openwebui.svg", "monochrome"],
|
||||
["/icon/perplexica.svg", "monochrome"],
|
||||
["/icon/roo-code.svg", "whiteWithColor"],
|
||||
["/icon/rust.svg", "monochrome"],
|
||||
["/icon/tasks.svg", "monochrome"],
|
||||
["/icon/terminal.svg", "monochrome"],
|
||||
["/icon/widgets.svg", "monochrome"],
|
||||
["/icon/windsurf.svg", "monochrome"],
|
||||
["/icon/zed.svg", "monochrome"],
|
||||
["/icon/tasks.svg", "monochrome"],
|
||||
["/icon/openwebui.svg", "monochrome"],
|
||||
["/icon/perplexica.svg", "monochrome"],
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"amazon-q.svg",
|
||||
"android-studio.svg",
|
||||
"ansible.svg",
|
||||
"anthropic.svg",
|
||||
"antigravity.svg",
|
||||
"apache-guacamole.svg",
|
||||
"apple-black.svg",
|
||||
@@ -56,9 +57,11 @@
|
||||
"gateway.svg",
|
||||
"gcp.png",
|
||||
"gcp.svg",
|
||||
"gemini-monochrome.svg",
|
||||
"gemini.svg",
|
||||
"git.svg",
|
||||
"gitea.svg",
|
||||
"github-copilot.svg",
|
||||
"github.svg",
|
||||
"gitlab.svg",
|
||||
"go.svg",
|
||||
@@ -78,6 +81,7 @@
|
||||
"k8s.svg",
|
||||
"kasmvnc.svg",
|
||||
"keycloak.svg",
|
||||
"kilo-code.svg",
|
||||
"kiro.svg",
|
||||
"kotlin.svg",
|
||||
"lakefs.svg",
|
||||
@@ -96,6 +100,7 @@
|
||||
"nomad.svg",
|
||||
"novnc.svg",
|
||||
"okta.svg",
|
||||
"openai-codex.svg",
|
||||
"openai.svg",
|
||||
"opencode.svg",
|
||||
"openwebui.svg",
|
||||
@@ -112,6 +117,7 @@
|
||||
"rdp.svg",
|
||||
"rider.svg",
|
||||
"rockylinux.svg",
|
||||
"roo-code.svg",
|
||||
"rstudio.svg",
|
||||
"ruby.png",
|
||||
"ruby.svg",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_10)">
|
||||
<path d="M184.642 38H145.492L216.85 218H256L184.642 38ZM71.3579 38L0 218H39.9826L54.6985 180.338H129.388L143.826 218H183.809L112.451 38H71.3579ZM67.4707 146.831L91.9046 83.6923L116.338 146.831H67.4707Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_10">
|
||||
<rect width="256" height="180" fill="white" transform="translate(0 38)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_25)">
|
||||
<g clip-path="url(#clip1_1_25)">
|
||||
<path d="M128 0C130.683 0 133.022 1.83438 133.677 4.43801C135.678 12.4013 138.315 20.1912 141.563 27.7326C150.052 47.4571 161.701 64.72 176.491 79.5094C191.288 94.3027 208.547 105.952 228.267 114.441C235.811 117.688 243.602 120.325 251.566 122.327C254.17 122.982 256 125.317 256 128C256 130.683 254.17 133.022 251.562 133.677C243.599 135.678 235.809 138.315 228.267 141.563C208.543 150.052 191.284 161.701 176.491 176.491C161.701 191.288 150.052 208.547 141.563 228.267C138.315 235.81 135.676 243.602 133.673 251.566C133.356 252.831 132.626 253.954 131.598 254.757C130.57 255.561 129.304 255.998 128 256C125.317 256 122.982 254.17 122.327 251.562C120.325 243.599 117.686 235.809 114.437 228.267C105.952 208.543 94.3067 191.284 79.5094 176.491C64.716 161.701 47.4571 150.052 27.7326 141.563C20.1909 138.315 12.4011 135.676 4.43801 133.673C3.17283 133.357 2.04931 132.627 1.24542 131.601C0.441537 130.574 0.00325282 129.308 0 128.004C0 125.321 1.83438 122.986 4.43801 122.331C12.4014 120.329 20.1913 117.69 27.7326 114.441C47.4571 105.956 64.72 94.3067 79.5094 79.5133C94.3027 64.7239 105.952 47.461 114.441 27.7366C117.688 20.1947 120.325 12.4049 122.327 4.44195C122.643 3.17539 123.372 2.05049 124.4 1.24577C125.428 0.441044 126.695 0.00259578 128 0Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_25">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1_25">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_80_10)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M255.118 180.6C245.94 196.419 192.649 233.743 128.002 233.743C63.3548 233.743 10.0628 196.419 0.88631 180.6C0.215186 179.444 -0.00140381 178.123 -0.00140381 176.788V148.629C-0.00140381 147.461 0.180177 146.301 0.6155 145.217C4.58422 135.33 14.978 120.967 28.3917 117.113C30.1701 112.583 32.8037 105.962 35.2612 101.074C34.8506 97.3342 34.7053 93.4765 34.7053 89.5791C34.7053 75.5 37.7138 63.149 46.7771 53.9523C51.0104 49.6573 56.2628 46.3617 62.4923 43.8857C77.4117 31.8598 98.6561 21.7429 127.768 21.7429C156.88 21.7429 178.585 31.8598 193.505 43.8857C199.734 46.3617 204.987 49.6573 209.22 53.9523C218.284 63.149 221.292 75.5 221.292 89.5791C221.292 93.4765 221.147 97.3342 220.736 101.074C223.193 105.962 225.827 112.583 227.605 117.113C241.02 120.967 251.413 135.33 255.382 145.217C255.817 146.301 255.999 147.461 255.999 148.629V176.788C255.999 178.123 255.788 179.444 255.118 180.6ZM136.671 86.1665C136.217 82.6591 136.001 79.5184 135.996 76.7124V76.4908C136.009 68.3507 137.803 63.0542 140.668 59.8012C144.305 55.6697 151.824 52.5048 167.671 54.2068C183.725 55.931 192.699 59.8845 197.786 65.0464C202.712 70.0449 205.3 77.5223 205.3 89.5791C205.3 102.39 203.439 109.958 199.348 114.563C195.458 118.941 187.796 122.503 171.018 122.503C158.119 122.503 150.743 118.34 146.029 112.583C140.966 106.402 138.117 97.3458 136.671 86.1665ZM119.33 86.1665C119.783 82.6591 119.999 79.5184 120.004 76.7124V76.4908C119.991 68.3507 118.197 63.0542 115.332 59.8012C111.695 55.6697 104.176 52.5048 88.3294 54.2068C72.2749 55.931 63.3012 59.8845 58.2136 65.0464C53.288 70.0449 50.6998 77.5223 50.6998 89.5791C50.6998 102.39 52.5605 109.958 56.6524 114.563C60.5424 118.941 68.2035 122.503 84.9825 122.503C97.8813 122.503 105.257 118.34 109.971 112.583C115.034 106.401 117.883 97.3458 119.33 86.1665ZM129.836 117.209C130.449 117.209 129.224 117.212 129.836 117.209C129.225 117.209 126.776 117.212 126.164 117.209C125.035 119.084 123.78 120.885 122.383 122.591C114.177 132.611 101.926 138.371 84.9815 138.371C66.5905 138.371 53.1124 134.573 44.6556 125.057C44.1749 124.516 43.7448 123.949 43.7448 123.949L42.7033 125.057V194.709C58.001 202.958 90.8366 217.761 128 217.761C165.163 217.761 197.999 202.958 213.297 194.709V125.057L212.255 123.949C212.255 123.949 211.903 124.428 211.344 125.057C202.888 134.573 189.409 138.371 171.018 138.371C154.074 138.371 141.823 132.611 133.617 122.591C132.221 120.885 130.965 119.084 129.836 117.209Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M102.999 148.743C107.969 148.743 111.999 152.888 111.999 158.001V180.484C111.999 185.598 107.969 189.743 102.999 189.743C98.0282 189.743 93.9986 185.598 93.9986 180.484V158.001C93.9986 152.888 98.0282 148.743 102.999 148.743Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M152.999 148.743C157.969 148.743 161.999 152.888 161.999 158.001V180.484C161.999 185.598 157.969 189.743 152.999 189.743C148.028 189.743 143.999 185.598 143.999 180.484V158.001C143.999 152.888 148.028 148.743 152.999 148.743Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_80_10">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_14_12)">
|
||||
<path d="M0 0V256H256V0H0ZM237.037 237.037H18.963V18.963H237.037V237.037ZM156.444 184.089H180.148V203.052H150.349L137.481 190.184V160.385H156.444V184.089ZM199.111 184.089H180.148V160.385H156.444V141.422H186.243L199.111 154.29V184.089ZM118.519 156.445H99.5556V137.482H118.519V156.445ZM56.8889 137.482H75.8518V180.149H118.519V199.112H69.7566L56.8889 186.244V137.482ZM199.111 99.5556V118.519H137.481V99.5556H158.674V75.8518H137.481V56.8889H164.769L177.637 69.7566V99.5556H199.111ZM75.8518 78.2222H99.5556L118.519 97.1852V118.519H99.5556V97.1852H75.8518V118.519H56.8889V56.8889H75.8518V78.2222ZM118.519 78.2222H99.5556V56.8889H118.519V78.2222Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_14_12">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_118_8)">
|
||||
<path d="M140.59 167.519H185.355C190.601 167.519 195.147 162.973 195.147 157.377C195.147 152.131 190.601 147.584 185.355 147.584H140.59C134.995 147.584 130.448 152.131 130.448 157.377C130.448 162.973 134.995 167.519 140.59 167.519ZM81.1361 171.016C84.6338 171.016 87.7811 168.918 89.5306 166.12L108.416 133.596C110.864 129.049 110.864 125.552 108.416 121.005L89.5306 88.4807C87.7811 85.6825 84.6338 83.9342 81.1361 83.9342C75.5409 83.9342 70.9945 88.4807 70.9945 93.7266C70.9945 96.1744 72.0432 98.2731 73.0932 100.021L89.1802 127.301L72.3936 155.977C71.3437 157.727 70.9945 159.126 70.9945 160.874C70.9945 166.469 75.5409 171.016 81.1361 171.016ZM127.999 235.716C68.5467 235.716 20.2843 187.453 20.2843 127.999C20.2843 68.5467 68.5467 20.2843 127.999 20.2843C187.453 20.2843 235.716 68.5467 235.716 127.999C235.716 187.453 187.453 235.716 127.999 235.716ZM127.999 256C198.645 256 256 198.645 256 127.999C256 57.3552 198.645 0 127.999 0C57.3552 0 0 57.3552 0 127.999C0 198.645 57.3552 256 127.999 256Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_118_8">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M213.7 51.4245L209.003 68.3546C208.755 69.2509 207.817 69.7688 206.923 69.503L128.149 46.0693C127.625 45.9135 127.058 46.024 126.632 46.365L48.6177 108.742C48.3899 108.924 48.1181 109.043 47.8296 109.088L1.4115 116.22C0.552004 116.352 -0.0607743 117.121 0.00480471 117.986L0.2065 120.645C0.270918 121.495 0.969693 122.158 1.82377 122.18L55.7403 123.569L56.3552 123.586L96.1945 102.369C96.7517 102.071 97.4307 102.115 97.9454 102.481L126.17 122.541C126.612 122.856 126.873 123.366 126.868 123.908L126.63 150.525C126.627 150.87 126.732 151.206 126.929 151.489L166.632 208.289C166.943 208.735 167.453 209 167.998 209H180.567C181.821 209 182.625 207.67 182.039 206.565L153.741 153.116C153.464 152.593 153.487 151.962 153.801 151.462L168.555 127.896C168.716 127.639 168.944 127.432 169.214 127.294L221.963 100.606C222.499 100.335 223.14 100.373 223.64 100.705L238.713 110.723C238.987 110.905 239.308 111.001 239.637 111.001H253.332C254.659 111.001 255.452 109.531 254.722 108.427L216.694 50.9528C215.907 49.7629 214.081 50.0505 213.7 51.4245Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -63,6 +63,7 @@ import (
|
||||
//
|
||||
// API v2.8:
|
||||
// - Added support for pre-created sub agents on the Agent API.
|
||||
// - Added support for UpdateAppStatus on the Agent API.
|
||||
const (
|
||||
CurrentMajor = 2
|
||||
CurrentMinor = 8
|
||||
|
||||