fix: upsert coder_app resources in case they are persistent (#18509)

This commit is contained in:
Danny Kopping
2025-06-23 20:50:44 +02:00
committed by GitHub
parent 82af2e019d
commit 4699393522
12 changed files with 325 additions and 189 deletions

View File

@@ -12,12 +12,13 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/quartz"
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/codersdk"
"github.com/coder/coder/v2/provisioner"
"github.com/coder/quartz"
)
type SubAgentAPI struct {
@@ -164,8 +165,8 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
}
}
_, err := a.Database.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
ID: uuid.New(),
_, err := a.Database.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: uuid.New(), // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is.
CreatedAt: createdAt,
AgentID: subAgent.ID,
Slug: app.Slug,

View File

@@ -3938,23 +3938,6 @@ func (q *querier) InsertWorkspaceAgentStats(ctx context.Context, arg database.In
return q.db.InsertWorkspaceAgentStats(ctx, arg)
}
func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) {
// NOTE(DanielleMaywood):
// It is possible for there to exist an agent without a workspace.
// This means that we want to allow execution to continue if
// there isn't a workspace found to allow this behavior to continue.
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.AgentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return database.WorkspaceApp{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil {
return database.WorkspaceApp{}, err
}
return q.db.InsertWorkspaceApp(ctx, arg)
}
func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return err
@@ -5181,6 +5164,23 @@ func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg databas
return q.db.UpsertWorkspaceAgentPortShare(ctx, arg)
}
func (q *querier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) {
// NOTE(DanielleMaywood):
// It is possible for there to exist an agent without a workspace.
// This means that we want to allow execution to continue if
// there isn't a workspace found to allow this behavior to continue.
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.AgentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return database.WorkspaceApp{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil {
return database.WorkspaceApp{}, err
}
return q.db.UpsertWorkspaceApp(ctx, arg)
}
func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return false, err

View File

@@ -4114,7 +4114,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
APIKeyScope: database.AgentKeyScopeEnumAll,
}).Asserts(ws, policy.ActionCreateAgent)
}))
s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) {
s.Run("UpsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) {
_ = dbgen.User(s.T(), db, database.User{})
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
@@ -4130,7 +4130,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
_ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID})
res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID})
agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID})
check.Args(database.InsertWorkspaceAppParams{
check.Args(database.UpsertWorkspaceAppParams{
ID: uuid.New(),
AgentID: agent.ID,
Health: database.WorkspaceAppHealthDisabled,

View File

@@ -778,7 +778,7 @@ func ProvisionerKey(t testing.TB, db database.Store, orig database.ProvisionerKe
}
func WorkspaceApp(t testing.TB, db database.Store, orig database.WorkspaceApp) database.WorkspaceApp {
resource, err := db.InsertWorkspaceApp(genCtx, database.InsertWorkspaceAppParams{
resource, err := db.UpsertWorkspaceApp(genCtx, database.UpsertWorkspaceAppParams{
ID: takeFirst(orig.ID, uuid.New()),
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
AgentID: takeFirst(orig.AgentID, uuid.New()),

View File

@@ -10018,48 +10018,6 @@ func (q *FakeQuerier) InsertWorkspaceAgentStats(_ context.Context, arg database.
return nil
}
func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) {
if err := validateDatabaseType(arg); err != nil {
return database.WorkspaceApp{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
if arg.SharingLevel == "" {
arg.SharingLevel = database.AppSharingLevelOwner
}
if arg.OpenIn == "" {
arg.OpenIn = database.WorkspaceAppOpenInSlimWindow
}
// nolint:gosimple
workspaceApp := database.WorkspaceApp{
ID: arg.ID,
AgentID: arg.AgentID,
CreatedAt: arg.CreatedAt,
Slug: arg.Slug,
DisplayName: arg.DisplayName,
Icon: arg.Icon,
Command: arg.Command,
Url: arg.Url,
External: arg.External,
Subdomain: arg.Subdomain,
SharingLevel: arg.SharingLevel,
HealthcheckUrl: arg.HealthcheckUrl,
HealthcheckInterval: arg.HealthcheckInterval,
HealthcheckThreshold: arg.HealthcheckThreshold,
Health: arg.Health,
Hidden: arg.Hidden,
DisplayOrder: arg.DisplayOrder,
OpenIn: arg.OpenIn,
DisplayGroup: arg.DisplayGroup,
}
q.workspaceApps = append(q.workspaceApps, workspaceApp)
return workspaceApp, nil
}
func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error {
err := validateDatabaseType(arg)
if err != nil {
@@ -13192,6 +13150,58 @@ func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg datab
return psl, nil
}
func (q *FakeQuerier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.WorkspaceApp{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
if arg.SharingLevel == "" {
arg.SharingLevel = database.AppSharingLevelOwner
}
if arg.OpenIn == "" {
arg.OpenIn = database.WorkspaceAppOpenInSlimWindow
}
buildApp := func(id uuid.UUID, createdAt time.Time) database.WorkspaceApp {
return database.WorkspaceApp{
ID: id,
CreatedAt: createdAt,
AgentID: arg.AgentID,
Slug: arg.Slug,
DisplayName: arg.DisplayName,
Icon: arg.Icon,
Command: arg.Command,
Url: arg.Url,
External: arg.External,
Subdomain: arg.Subdomain,
SharingLevel: arg.SharingLevel,
HealthcheckUrl: arg.HealthcheckUrl,
HealthcheckInterval: arg.HealthcheckInterval,
HealthcheckThreshold: arg.HealthcheckThreshold,
Health: arg.Health,
Hidden: arg.Hidden,
DisplayOrder: arg.DisplayOrder,
OpenIn: arg.OpenIn,
DisplayGroup: arg.DisplayGroup,
}
}
for i, app := range q.workspaceApps {
if app.ID == arg.ID {
q.workspaceApps[i] = buildApp(app.ID, app.CreatedAt)
return q.workspaceApps[i], nil
}
}
workspaceApp := buildApp(arg.ID, arg.CreatedAt)
q.workspaceApps = append(q.workspaceApps, workspaceApp)
return workspaceApp, nil
}
func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(_ context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
err := validateDatabaseType(arg)
if err != nil {

View File

@@ -2440,13 +2440,6 @@ func (m queryMetricsStore) InsertWorkspaceAgentStats(ctx context.Context, arg da
return r0
}
func (m queryMetricsStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) {
start := time.Now()
app, err := m.s.InsertWorkspaceApp(ctx, arg)
m.queryLatencies.WithLabelValues("InsertWorkspaceApp").Observe(time.Since(start).Seconds())
return app, err
}
func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error {
start := time.Now()
r0 := m.s.InsertWorkspaceAppStats(ctx, arg)
@@ -3287,6 +3280,13 @@ func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceApp(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertWorkspaceApp").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceAppAuditSession(ctx, arg)

View File

@@ -5150,21 +5150,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStats(ctx, arg any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStats), ctx, arg)
}
// InsertWorkspaceApp mocks base method.
func (m *MockStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertWorkspaceApp", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertWorkspaceApp indicates an expected call of InsertWorkspaceApp.
func (mr *MockStoreMockRecorder) InsertWorkspaceApp(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), ctx, arg)
}
// InsertWorkspaceAppStats mocks base method.
func (m *MockStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error {
m.ctrl.T.Helper()
@@ -6924,6 +6909,21 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentPortShare(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentPortShare), ctx, arg)
}
// UpsertWorkspaceApp mocks base method.
func (m *MockStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertWorkspaceApp", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpsertWorkspaceApp indicates an expected call of UpsertWorkspaceApp.
func (mr *MockStoreMockRecorder) UpsertWorkspaceApp(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceApp), ctx, arg)
}
// UpsertWorkspaceAppAuditSession mocks base method.
func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
m.ctrl.T.Helper()

View File

@@ -530,7 +530,6 @@ type sqlcQuerier interface {
InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) (WorkspaceAgentScriptTiming, error)
InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error)
InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error
InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error)
InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error
InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error)
InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error
@@ -671,6 +670,7 @@ type sqlcQuerier interface {
UpsertTemplateUsageStats(ctx context.Context) error
UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error)
//
// The returned boolean, new_or_stale, can be used to deduce if a new session
// was started. This means that a new row was inserted (no previous session) or

View File

@@ -16673,102 +16673,6 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
return items, nil
}
const insertWorkspaceApp = `-- name: InsertWorkspaceApp :one
INSERT INTO
workspace_apps (
id,
created_at,
agent_id,
slug,
display_name,
icon,
command,
url,
external,
subdomain,
sharing_level,
healthcheck_url,
healthcheck_interval,
healthcheck_threshold,
health,
display_order,
hidden,
open_in,
display_group
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group
`
type InsertWorkspaceAppParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Slug string `db:"slug" json:"slug"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
External bool `db:"external" json:"external"`
Subdomain bool `db:"subdomain" json:"subdomain"`
SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"`
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
Health WorkspaceAppHealth `db:"health" json:"health"`
DisplayOrder int32 `db:"display_order" json:"display_order"`
Hidden bool `db:"hidden" json:"hidden"`
OpenIn WorkspaceAppOpenIn `db:"open_in" json:"open_in"`
DisplayGroup sql.NullString `db:"display_group" json:"display_group"`
}
func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) {
row := q.db.QueryRowContext(ctx, insertWorkspaceApp,
arg.ID,
arg.CreatedAt,
arg.AgentID,
arg.Slug,
arg.DisplayName,
arg.Icon,
arg.Command,
arg.Url,
arg.External,
arg.Subdomain,
arg.SharingLevel,
arg.HealthcheckUrl,
arg.HealthcheckInterval,
arg.HealthcheckThreshold,
arg.Health,
arg.DisplayOrder,
arg.Hidden,
arg.OpenIn,
arg.DisplayGroup,
)
var i WorkspaceApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
&i.External,
&i.DisplayOrder,
&i.Hidden,
&i.OpenIn,
&i.DisplayGroup,
)
return i, err
}
const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one
INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, uri)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -16830,6 +16734,121 @@ func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg Updat
return err
}
const upsertWorkspaceApp = `-- name: UpsertWorkspaceApp :one
INSERT INTO
workspace_apps (
id,
created_at,
agent_id,
slug,
display_name,
icon,
command,
url,
external,
subdomain,
sharing_level,
healthcheck_url,
healthcheck_interval,
healthcheck_threshold,
health,
display_order,
hidden,
open_in,
display_group
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (id) DO UPDATE SET
display_name = EXCLUDED.display_name,
icon = EXCLUDED.icon,
command = EXCLUDED.command,
url = EXCLUDED.url,
external = EXCLUDED.external,
subdomain = EXCLUDED.subdomain,
sharing_level = EXCLUDED.sharing_level,
healthcheck_url = EXCLUDED.healthcheck_url,
healthcheck_interval = EXCLUDED.healthcheck_interval,
healthcheck_threshold = EXCLUDED.healthcheck_threshold,
health = EXCLUDED.health,
display_order = EXCLUDED.display_order,
hidden = EXCLUDED.hidden,
open_in = EXCLUDED.open_in,
display_group = EXCLUDED.display_group,
agent_id = EXCLUDED.agent_id,
slug = EXCLUDED.slug
RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group
`
type UpsertWorkspaceAppParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Slug string `db:"slug" json:"slug"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
External bool `db:"external" json:"external"`
Subdomain bool `db:"subdomain" json:"subdomain"`
SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"`
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
Health WorkspaceAppHealth `db:"health" json:"health"`
DisplayOrder int32 `db:"display_order" json:"display_order"`
Hidden bool `db:"hidden" json:"hidden"`
OpenIn WorkspaceAppOpenIn `db:"open_in" json:"open_in"`
DisplayGroup sql.NullString `db:"display_group" json:"display_group"`
}
func (q *sqlQuerier) UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error) {
row := q.db.QueryRowContext(ctx, upsertWorkspaceApp,
arg.ID,
arg.CreatedAt,
arg.AgentID,
arg.Slug,
arg.DisplayName,
arg.Icon,
arg.Command,
arg.Url,
arg.External,
arg.Subdomain,
arg.SharingLevel,
arg.HealthcheckUrl,
arg.HealthcheckInterval,
arg.HealthcheckThreshold,
arg.Health,
arg.DisplayOrder,
arg.Hidden,
arg.OpenIn,
arg.DisplayGroup,
)
var i WorkspaceApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
&i.External,
&i.DisplayOrder,
&i.Hidden,
&i.OpenIn,
&i.DisplayGroup,
)
return i, err
}
const insertWorkspaceAppStats = `-- name: InsertWorkspaceAppStats :exec
INSERT INTO
workspace_app_stats (

View File

@@ -10,7 +10,7 @@ SELECT * FROM workspace_apps WHERE agent_id = $1 AND slug = $2;
-- name: GetWorkspaceAppsCreatedAfter :many
SELECT * FROM workspace_apps WHERE created_at > $1 ORDER BY slug ASC;
-- name: InsertWorkspaceApp :one
-- name: UpsertWorkspaceApp :one
INSERT INTO
workspace_apps (
id,
@@ -34,7 +34,26 @@ INSERT INTO
display_group
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (id) DO UPDATE SET
display_name = EXCLUDED.display_name,
icon = EXCLUDED.icon,
command = EXCLUDED.command,
url = EXCLUDED.url,
external = EXCLUDED.external,
subdomain = EXCLUDED.subdomain,
sharing_level = EXCLUDED.sharing_level,
healthcheck_url = EXCLUDED.healthcheck_url,
healthcheck_interval = EXCLUDED.healthcheck_interval,
healthcheck_threshold = EXCLUDED.healthcheck_threshold,
health = EXCLUDED.health,
display_order = EXCLUDED.display_order,
hidden = EXCLUDED.hidden,
open_in = EXCLUDED.open_in,
display_group = EXCLUDED.display_group,
agent_id = EXCLUDED.agent_id,
slug = EXCLUDED.slug
RETURNING *;
-- name: UpdateWorkspaceAppHealthByID :exec
UPDATE

View File

@@ -28,6 +28,7 @@ import (
protobuf "google.golang.org/protobuf/proto"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk/drpcsdk"
@@ -2606,7 +2607,8 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
return xerrors.Errorf("parse app uuid: %w", err)
}
dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
// If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert.
dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: id,
CreatedAt: dbtime.Now(),
AgentID: dbAgent.ID,
@@ -2635,7 +2637,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
OpenIn: openIn,
})
if err != nil {
return xerrors.Errorf("insert app: %w", err)
return xerrors.Errorf("upsert app: %w", err)
}
snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp))
}

View File

@@ -19,6 +19,8 @@ import (
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/terraform-provider-coder/v2/provider"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -42,7 +44,6 @@ import (
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
"github.com/coder/terraform-provider-coder/v2/provider"
)
func TestWorkspace(t *testing.T) {
@@ -4614,3 +4615,87 @@ func TestWorkspaceFilterHasAITask(t *testing.T) {
require.NoError(t, err)
require.Len(t, res.Workspaces, 5)
}
func TestWorkspaceAppUpsertRestart(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
// Define an app to be created with the workspace
apps := []*proto.App{
{
Id: uuid.NewString(),
Slug: "test-app",
DisplayName: "Test App",
Command: "test-command",
Url: "http://localhost:8080",
Icon: "/test.svg",
},
}
// Create template version with workspace app
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "test-resource",
Type: "example",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "dev",
Auth: &proto.Agent_Token{},
Apps: apps,
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Create template and workspace
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Verify initial workspace has the app
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1)
agent := workspace.LatestBuild.Resources[0].Agents[0]
require.Len(t, agent.Apps, 1)
require.Equal(t, "test-app", agent.Apps[0].Slug)
require.Equal(t, "Test App", agent.Apps[0].DisplayName)
// Stop the workspace
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID)
// Restart the workspace (this will trigger upsert for the app)
startBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, startBuild.ID)
// Verify the workspace restarted successfully
workspace, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status)
// Verify the app is still present after restart (upsert worked)
require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1)
agent = workspace.LatestBuild.Resources[0].Agents[0]
require.Len(t, agent.Apps, 1)
require.Equal(t, "test-app", agent.Apps[0].Slug)
require.Equal(t, "Test App", agent.Apps[0].DisplayName)
// Verify the provisioner job completed successfully (no error)
require.Equal(t, codersdk.ProvisionerJobSucceeded, workspace.LatestBuild.Job.Status)
require.Empty(t, workspace.LatestBuild.Job.Error)
}