Compare commits

...

9 Commits

Author SHA1 Message Date
Kacper Sawicki 93ab5d480f feat(cli): backport #21374 to 2.28 (#21560)
backport #21374 to 2.28

feat(cli): add --no-build flag to state push for state-only updates
#21374
2026-01-20 15:47:03 -06:00
Kacper Sawicki 68845eb3e4 fix: backport update boundary version to 2.28 (#21290) (#21574)
(cherry picked from commit 7a5c5581e9)

fix: update boundary version https://github.com/coder/coder/pull/21290

required by https://github.com/coder/coder/pull/21560

Co-authored-by: Yevhenii Shcherbina <evgeniy.shcherbina.es@gmail.com>
2026-01-20 11:19:46 +01:00
Jakub Domeracki df4715376f chore: update react to apply patch for CVE-2025-55182 (#21084) (#21175)
Reference:

https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components

> Please note that coder deployments aren't vulnerable since [React
Server Components](https://react.dev/reference/rsc/server-components)
aren't in use
2025-12-09 08:44:18 -06:00
Danielle Maywood d81d7eeb30 fix: allow agents to be created on dormant workspaces (#20909) (#20912)
We now allow agents to be created on dormant workspaces.

I've ran the test with and without the change. I've confirmed that -
without the fix - it triggers the "rbac: unauthorized" error.

---

Cherry picked from #20909
2025-12-01 15:29:13 -06:00
Danielle Maywood ed5785fa3c fix: do not notify marked for deletion for deleted workspaces (#20937) (#20945)
Stop notifying workspaces as being marked for deleted when they're
already deleted

---

Cherry-picked from e7dbbcde87
2025-12-01 15:26:59 -06:00
Steven Masley 8d18875902 perf: optimize migration 371 to run faster on large deployments (#20906) (#21042)
Backport of #20906
(cherry picked from commit a9261577bc)

Co-authored-by: George K <george.katsitadze@gmail.com>
2025-12-01 15:26:17 -06:00
Danny Kopping e2a46393fc fix: remove a sensitive field from an agent log line (#20968) (#20972)
Backport of #20968

Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com>
2025-11-27 17:43:39 +02:00
Jaayden Halko aa5b22cb46 chore: backport fix #20769 to 2.28 (#20872)
backport #20769  to 2.28

Fix dynamic parameters for create workspace form when using autofill
params from the url
2025-11-25 10:24:52 -06:00
Jaayden Halko 8765352fb7 chore: backport dynamic parameters fix #20740 (#20777)
backport #20740  to 2.28
2025-11-14 17:10:21 -06:00
26 changed files with 1201 additions and 673 deletions
+1 -1
View File
@@ -1087,7 +1087,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
if err != nil {
return xerrors.Errorf("fetch metadata: %w", err)
}
a.logger.Info(ctx, "fetched manifest", slog.F("manifest", mp))
a.logger.Info(ctx, "fetched manifest")
manifest, err := agentsdk.ManifestFromProto(mp)
if err != nil {
a.logger.Critical(ctx, "failed to convert manifest", slog.F("manifest", mp), slog.Error(err))
+17
View File
@@ -87,6 +87,7 @@ func buildNumberOption(n *int64) serpent.Option {
func (r *RootCmd) statePush() *serpent.Command {
var buildNumber int64
var noBuild bool
cmd := &serpent.Command{
Use: "push <workspace> <file>",
Short: "Push a Terraform state file to a workspace.",
@@ -126,6 +127,16 @@ func (r *RootCmd) statePush() *serpent.Command {
return err
}
if noBuild {
// Update state directly without triggering a build.
err = client.UpdateWorkspaceBuildState(inv.Context(), build.ID, state)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, "State updated successfully.")
return nil
}
build, err = client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: build.TemplateVersionID,
Transition: build.Transition,
@@ -139,6 +150,12 @@ func (r *RootCmd) statePush() *serpent.Command {
}
cmd.Options = serpent.OptionSet{
buildNumberOption(&buildNumber),
{
Flag: "no-build",
FlagShorthand: "n",
Description: "Update the state without triggering a workspace build. Useful for state-only migrations.",
Value: serpent.BoolOf(&noBuild),
},
}
return cmd
}
+47
View File
@@ -2,6 +2,7 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"testing"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/stretchr/testify/require"
@@ -158,4 +160,49 @@ func TestStatePush(t *testing.T) {
err := inv.Run()
require.NoError(t, err)
})
t.Run("NoBuild", func(t *testing.T) {
t.Parallel()
client, store := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, taUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
initialState := []byte("initial state")
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: taUser.ID,
}).
Seed(database.WorkspaceBuild{ProvisionerState: initialState}).
Do()
wantState := []byte("updated state")
stateFile, err := os.CreateTemp(t.TempDir(), "")
require.NoError(t, err)
_, err = stateFile.Write(wantState)
require.NoError(t, err)
err = stateFile.Close()
require.NoError(t, err)
inv, root := clitest.New(t, "state", "push", "--no-build", r.Workspace.Name, stateFile.Name())
clitest.SetupConfig(t, templateAdmin, root)
var stdout bytes.Buffer
inv.Stdout = &stdout
err = inv.Run()
require.NoError(t, err)
require.Contains(t, stdout.String(), "State updated successfully")
// Verify the state was updated by pulling it.
inv, root = clitest.New(t, "state", "pull", r.Workspace.Name)
var gotState bytes.Buffer
inv.Stdout = &gotState
clitest.SetupConfig(t, templateAdmin, root)
err = inv.Run()
require.NoError(t, err)
require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes()))
// Verify no new build was created.
builds, err := store.GetWorkspaceBuildsByWorkspaceID(dbauthz.AsSystemRestricted(context.Background()), database.GetWorkspaceBuildsByWorkspaceIDParams{
WorkspaceID: r.Workspace.ID,
})
require.NoError(t, err)
require.Len(t, builds, 1, "expected only the initial build, no new build should be created")
})
}
+4
View File
@@ -9,5 +9,9 @@ OPTIONS:
-b, --build int
Specify a workspace build to target by name. Defaults to latest.
-n, --no-build bool
Update the state without triggering a workspace build. Useful for
state-only migrations.
———
Run `coder --help` for a list of global options.
+50
View File
@@ -10008,6 +10008,45 @@ const docTemplate = `{
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"tags": [
"Builds"
],
"summary": "Update workspace build state",
"operationId": "update-workspace-build-state",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace build ID",
"name": "workspacebuild",
"in": "path",
"required": true
},
{
"description": "Request body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateWorkspaceBuildStateRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspacebuilds/{workspacebuild}/timings": {
@@ -19159,6 +19198,17 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateWorkspaceBuildStateRequest": {
"type": "object",
"properties": {
"state": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"codersdk.UpdateWorkspaceDormancy": {
"type": "object",
"properties": {
+46
View File
@@ -8870,6 +8870,41 @@
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"tags": ["Builds"],
"summary": "Update workspace build state",
"operationId": "update-workspace-build-state",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace build ID",
"name": "workspacebuild",
"in": "path",
"required": true
},
{
"description": "Request body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateWorkspaceBuildStateRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspacebuilds/{workspacebuild}/timings": {
@@ -17581,6 +17616,17 @@
}
}
},
"codersdk.UpdateWorkspaceBuildStateRequest": {
"type": "object",
"properties": {
"state": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"codersdk.UpdateWorkspaceDormancy": {
"type": "object",
"properties": {
+1
View File
@@ -1496,6 +1496,7 @@ func New(options *Options) *API {
r.Get("/parameters", api.workspaceBuildParameters)
r.Get("/resources", api.workspaceBuildResourcesDeprecated)
r.Get("/state", api.workspaceBuildState)
r.Put("/state", api.workspaceBuildUpdateState)
r.Get("/timings", api.workspaceBuildTimings)
})
r.Route("/authcheck", func(r chi.Router) {
+1 -1
View File
@@ -217,7 +217,7 @@ var (
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
// Unsure why provisionerd needs update and read personal
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
// Provisionerd needs to read, update, and delete tasks associated with workspaces.
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
@@ -141,13 +141,19 @@ ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:read';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:update';
-- End enum extensions
-- Purge old API keys to speed up the migration for large deployments.
-- Note: that problem should be solved in coderd once PR 20863 is released:
-- https://github.com/coder/coder/blob/main/coderd/database/dbpurge/dbpurge.go#L85
DELETE FROM api_keys WHERE expires_at < NOW() - INTERVAL '7 days';
-- Add new columns without defaults; backfill; then enforce NOT NULL
ALTER TABLE api_keys ADD COLUMN scopes api_key_scope[];
ALTER TABLE api_keys ADD COLUMN allow_list text[];
-- Backfill existing rows for compatibility
UPDATE api_keys SET scopes = ARRAY[scope::api_key_scope];
UPDATE api_keys SET allow_list = ARRAY['*:*'];
UPDATE api_keys SET
scopes = ARRAY[scope::api_key_scope],
allow_list = ARRAY['*:*'];
-- Enforce NOT NULL
ALTER TABLE api_keys ALTER COLUMN scopes SET NOT NULL;
@@ -0,0 +1,57 @@
-- Ensure api_keys and oauth2_provider_app_tokens have live data after
-- migration 000371 deletes expired rows.
INSERT INTO api_keys (
id,
hashed_secret,
user_id,
last_used,
expires_at,
created_at,
updated_at,
login_type,
lifetime_seconds,
ip_address,
token_name,
scopes,
allow_list
)
VALUES (
'fixture-api-key',
'\xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'30095c71-380b-457a-8995-97b8ee6e5307',
NOW() - INTERVAL '1 hour',
NOW() + INTERVAL '30 days',
NOW() - INTERVAL '1 day',
NOW() - INTERVAL '1 day',
'password',
86400,
'0.0.0.0',
'fixture-api-key',
ARRAY['workspace:read']::api_key_scope[],
ARRAY['*:*']
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO oauth2_provider_app_tokens (
id,
created_at,
expires_at,
hash_prefix,
refresh_hash,
app_secret_id,
api_key_id,
audience,
user_id
)
VALUES (
'9f92f3c9-811f-4f6f-9a1c-3f2eed1f9f15',
NOW() - INTERVAL '30 minutes',
NOW() + INTERVAL '30 days',
CAST('fixture-hash-prefix' AS bytea),
CAST('fixture-refresh-hash' AS bytea),
'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'fixture-api-key',
'https://coder.example.com',
'30095c71-380b-457a-8995-97b8ee6e5307'
)
ON CONFLICT (id) DO NOTHING;
+1
View File
@@ -23531,6 +23531,7 @@ SET
WHERE
template_id = $3
AND dormant_at IS NOT NULL
AND deleted = false
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
-- should not have their dormant or deleting at set, as these are handled by the
-- prebuilds reconciliation loop.
+1
View File
@@ -854,6 +854,7 @@ SET
WHERE
template_id = @template_id
AND dormant_at IS NOT NULL
AND deleted = false
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
-- should not have their dormant or deleting at set, as these are handled by the
-- prebuilds reconciliation loop.
+57
View File
@@ -849,6 +849,63 @@ func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
_, _ = rw.Write(workspaceBuild.ProvisionerState)
}
// @Summary Update workspace build state
// @ID update-workspace-build-state
// @Security CoderSessionToken
// @Accept json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceBuildStateRequest true "Request body"
// @Success 204
// @Router /workspacebuilds/{workspacebuild}/state [put]
func (api *API) workspaceBuildUpdateState(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.",
})
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",
Detail: err.Error(),
})
return
}
// You must have update permissions on the template to update the state.
if !api.Authorize(r, policy.ActionUpdate, template.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
var req codersdk.UpdateWorkspaceBuildStateRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
// Use system context since we've already verified authorization via template permissions.
// nolint:gocritic // System access required for provisioner state update.
err = api.Database.UpdateWorkspaceBuildProvisionerStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildProvisionerStateByIDParams{
ID: workspaceBuild.ID,
ProvisionerState: req.State,
UpdatedAt: dbtime.Now(),
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update workspace build state.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get workspace build timings by ID
// @ID get-workspace-build-timings-by-id
// @Security CoderSessionToken
+22
View File
@@ -189,6 +189,28 @@ func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]by
return io.ReadAll(res.Body)
}
// UpdateWorkspaceBuildStateRequest is the request body for updating the
// provisioner state of a workspace build.
type UpdateWorkspaceBuildStateRequest struct {
State []byte `json:"state"`
}
// UpdateWorkspaceBuildState updates the provisioner state of the build without
// triggering a new build. This is useful for state-only migrations.
func (c *Client) UpdateWorkspaceBuildState(ctx context.Context, build uuid.UUID, state []byte) error {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), UpdateWorkspaceBuildStateRequest{
State: state,
})
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx context.Context, username string, workspaceName string, buildNumber string) (WorkspaceBuild, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s/builds/%s", username, workspaceName, buildNumber), nil)
if err != nil {
+38
View File
@@ -1216,6 +1216,44 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update workspace build state
### Code samples
```shell
# Example request using curl
curl -X PUT http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/state \
-H 'Content-Type: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`PUT /workspacebuilds/{workspacebuild}/state`
> Body parameter
```json
{
"state": [
0
]
}
```
### Parameters
| Name | In | Type | Required | Description |
|------------------|------|--------------------------------------------------------------------------------------------------|----------|--------------------|
| `workspacebuild` | path | string(uuid) | true | Workspace build ID |
| `body` | body | [codersdk.UpdateWorkspaceBuildStateRequest](schemas.md#codersdkupdateworkspacebuildstaterequest) | true | Request body |
### Responses
| Status | Meaning | Description | Schema |
|--------|-----------------------------------------------------------------|-------------|--------|
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace build timings by ID
### Code samples
+16
View File
@@ -9365,6 +9365,22 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|------------|--------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `schedule` | string | false | | Schedule is expected to be of the form `CRON_TZ=<IANA Timezone> <min> <hour> * * <dow>` Example: `CRON_TZ=US/Central 30 9 * * 1-5` represents 0930 in the timezone US/Central on weekdays (Mon-Fri). `CRON_TZ` defaults to UTC if not present. |
## codersdk.UpdateWorkspaceBuildStateRequest
```json
{
"state": [
0
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------|------------------|----------|--------------|-------------|
| `state` | array of integer | false | | |
## codersdk.UpdateWorkspaceDormancy
```json
+8
View File
@@ -18,3 +18,11 @@ coder state push [flags] <workspace> <file>
| Type | <code>int</code> |
Specify a workspace build to target by name. Defaults to latest.
### -n, --no-build
| | |
|------|-------------------|
| Type | <code>bool</code> |
Update the state without triggering a workspace build. Useful for state-only migrations.
@@ -737,6 +737,105 @@ func TestNotifications(t *testing.T) {
require.Contains(t, sent[i].Targets, dormantWs.OwnerID)
}
})
// Regression test for https://github.com/coder/coder/issues/20913
// Deleted workspaces should not receive dormancy notifications.
t.Run("DeletedWorkspacesNotNotified", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
ctx = testutil.Context(t, testutil.WaitLong)
user = dbgen.User(t, db, database.User{})
file = dbgen.File(t, db, database.File{
CreatedBy: user.ID,
})
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
FileID: file.ID,
InitiatorID: user.ID,
Tags: database.StringMap{
"foo": "bar",
},
})
timeTilDormant = time.Minute * 2
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
JobID: templateJob.ID,
OrganizationID: templateJob.OrganizationID,
})
template = dbgen.Template(t, db, database.Template{
ActiveVersionID: templateVersion.ID,
CreatedBy: user.ID,
OrganizationID: templateJob.OrganizationID,
TimeTilDormant: int64(timeTilDormant),
TimeTilDormantAutoDelete: int64(timeTilDormant),
})
)
// Create a dormant workspace that is NOT deleted.
activeDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
OrganizationID: templateJob.OrganizationID,
LastUsedAt: time.Now().Add(-time.Hour),
})
_, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
ID: activeDormantWorkspace.ID,
DormantAt: sql.NullTime{
Time: activeDormantWorkspace.LastUsedAt.Add(timeTilDormant),
Valid: true,
},
})
require.NoError(t, err)
// Create a dormant workspace that IS deleted.
deletedDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
OrganizationID: templateJob.OrganizationID,
LastUsedAt: time.Now().Add(-time.Hour),
Deleted: true, // Mark as deleted
})
_, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
ID: deletedDormantWorkspace.ID,
DormantAt: sql.NullTime{
Time: deletedDormantWorkspace.LastUsedAt.Add(timeTilDormant),
Valid: true,
},
})
require.NoError(t, err)
// Setup dependencies
notifyEnq := notificationstest.NewFakeEnqueuer()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
require.NoError(t, err)
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
// triggers notifications.
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
TimeTilDormant: timeTilDormant / 2,
TimeTilDormantAutoDelete: timeTilDormant / 2,
})
require.NoError(t, err)
// We should only receive a notification for the non-deleted dormant workspace.
sent := notifyEnq.Sent()
require.Len(t, sent, 1, "expected exactly 1 notification for the non-deleted workspace")
require.Equal(t, sent[0].UserID, activeDormantWorkspace.OwnerID)
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion)
require.Contains(t, sent[0].Targets, activeDormantWorkspace.ID)
// Ensure the deleted workspace was NOT notified
for _, notification := range sent {
require.NotContains(t, notification.Targets, deletedDormantWorkspace.ID,
"deleted workspace should not receive notifications")
}
})
}
func TestTemplateTTL(t *testing.T) {
+67
View File
@@ -833,6 +833,73 @@ func TestWorkspaceAutobuild(t *testing.T) {
require.True(t, ws.LastUsedAt.After(dormantLastUsedAt))
})
// This test has been added to ensure we don't introduce a regression
// to this issue https://github.com/coder/coder/issues/20711.
t.Run("DormantAutostop", func(t *testing.T) {
t.Parallel()
var (
ticker = make(chan time.Time)
statCh = make(chan autobuild.Stats)
inactiveTTL = time.Minute
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
)
client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
AutobuildTicker: ticker,
AutobuildStats: statCh,
IncludeProvisionerDaemon: true,
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
},
})
// Create a template version that includes agents on both start AND stop builds.
// This simulates a template without `count = data.coder_workspace.me.start_count`.
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds())
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
ws := coderdtest.CreateWorkspace(t, client, template.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
// Simulate the workspace becoming inactive and transitioning to dormant.
tickTime := ws.LastUsedAt.Add(inactiveTTL * 2)
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil)
require.NoError(t, err)
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
ticker <- tickTime
stats := <-statCh
// Expect workspace to transition to stopped state.
require.Len(t, stats.Transitions, 1)
require.Equal(t, stats.Transitions[ws.ID], database.WorkspaceTransitionStop)
// The autostop build should succeed even though the template includes
// agents without `count = data.coder_workspace.me.start_count`.
// This verifies that provisionerd has permission to create agents on
// dormant workspaces during stop builds.
ws = coderdtest.MustWorkspace(t, client, ws.ID)
require.NotNil(t, ws.DormantAt, "workspace should be marked as dormant")
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition)
latestBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusStopped, latestBuild.Status)
})
// This test serves as a regression prevention for generating
// audit logs in the same transaction the transition workspaces to
// the dormant state. The auditor that is passed to autobuild does
+2 -2
View File
@@ -478,7 +478,7 @@ require (
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v0.1.7
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945
github.com/coder/boundary v0.0.1-alpha
github.com/coder/preview v1.0.4
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/fsnotify/fsnotify v1.9.0
@@ -513,7 +513,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
+4 -4
View File
@@ -852,8 +852,8 @@ github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwP
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5/go.mod h1:jtAfVaU/2cu1+wdSRPWE2c1N2qeAA3K4RH9pYgqwets=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
@@ -921,8 +921,8 @@ github.com/coder/aibridge v0.1.7 h1:GTAM8nHawXMeb/pxAIwvzr76dyVGu9hw9qV6Gvpc7nw=
github.com/coder/aibridge v0.1.7/go.mod h1:7GhrLbzf6uM3sCA7OPaDzvq9QNrCjNuzMy+WgipYwfQ=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945 h1:hDUf02kTX8EGR3+5B+v5KdYvORs4YNfDPci0zCs+pC0=
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945/go.mod h1:d1AMFw81rUgrGHuZzWdPNhkY0G8w7pvLNLYF0e3ceC4=
github.com/coder/boundary v0.0.1-alpha h1:6shUQ2zkrWrfbgVcqWvpV2ibljOQvPvYqTctWBkKoUA=
github.com/coder/boundary v0.0.1-alpha/go.mod h1:d1AMFw81rUgrGHuZzWdPNhkY0G8w7pvLNLYF0e3ceC4=
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI=
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4=
github.com/coder/clistat v1.1.1 h1:T45dlwr7fSmjLPGLk7QRKgynnDeMOPoraHSGtLIHY3s=
+2 -2
View File
@@ -93,11 +93,11 @@
"lucide-react": "0.545.0",
"monaco-editor": "0.53.0",
"pretty-bytes": "6.1.1",
"react": "19.1.1",
"react": "19.2.1",
"react-color": "2.19.3",
"react-confetti": "6.4.0",
"react-date-range": "1.4.0",
"react-dom": "19.1.1",
"react-dom": "19.2.1",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
"react-resizable-panels": "3.0.6",
+637 -637
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -5524,6 +5524,15 @@ export interface UpdateWorkspaceAutostartRequest {
readonly schedule?: string;
}
// From codersdk/workspacebuilds.go
/**
* UpdateWorkspaceBuildStateRequest is the request body for updating the
* provisioner state of a workspace build.
*/
export interface UpdateWorkspaceBuildStateRequest {
readonly state: string;
}
// From codersdk/workspaces.go
/**
* UpdateWorkspaceDormancy is a request to activate or make a workspace dormant.
@@ -119,7 +119,7 @@ export const CreateWorkspacePageViewExperimental: FC<
// Only touched fields are sent to the websocket
// Autofilled parameters are marked as touched since they have been modified
const initialTouched = Object.fromEntries(
parameters.filter((p) => autofillByName[p.name]).map((p) => [p, true]),
parameters.filter((p) => autofillByName[p.name]).map((p) => [p.name, true]),
);
// The form parameters values hold the working state of the parameters that will be submitted when creating a workspace
@@ -48,18 +48,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
onCancel,
templateVersionId,
}) => {
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
const initialTouched = parameters.reduce(
(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
);
const form = useFormik({
onSubmit,
initialValues: {
@@ -68,7 +56,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
autofillParameters,
),
},
initialTouched,
validationSchema: useValidationSchemaForDynamicParameters(parameters),
enableReinitialize: false,
validateOnChange: true,
@@ -89,28 +76,23 @@ export const WorkspaceParametersPageViewExperimental: FC<
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
};
// Send the changed parameter and all touched parameters to the websocket
const sendDynamicParamsRequest = (
parameter: PreviewParameter,
value: string,
) => {
const formInputs: Record<string, string> = {};
formInputs[parameter.name] = value;
const parameters = form.values.rich_parameter_values ?? [];
for (const [fieldName, isTouched] of Object.entries(form.touched)) {
if (isTouched && fieldName !== parameter.name) {
const param = parameters.find((p) => p.name === fieldName);
if (param?.value) {
formInputs[fieldName] = param.value;
}
for (const param of parameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
};