Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93ab5d480f | |||
| 68845eb3e4 | |||
| df4715376f | |||
| d81d7eeb30 | |||
| ed5785fa3c | |||
| 8d18875902 | |||
| e2a46393fc | |||
| aa5b22cb46 | |||
| 8765352fb7 |
+1
-1
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+50
@@ -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": {
|
||||
|
||||
Generated
+46
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
coderd/database/migrations/testdata/fixtures/000371_add_api_key_and_oauth2_provider_app_token.up.sql
Vendored
+57
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+38
@@ -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
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
Generated
+8
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+637
-637
File diff suppressed because it is too large
Load Diff
Generated
+9
@@ -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
|
||||
|
||||
+5
-23
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user