From 6ca70d36187a8ac038f3a248098041dd02605f65 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 12 Jan 2026 15:16:59 +0100 Subject: [PATCH] feat(cli): add --no-build flag to state push for state-only updates (#21374) ## Summary Adds a `--no-build` flag to `coder state push` that updates the Terraform state directly without triggering a workspace build. ## Use Case This enables state-only migrations, such as migrating Kubernetes resources from deprecated types (e.g., `kubernetes_config_map`) to versioned types (e.g., `kubernetes_config_map_v1`): ```bash coder state pull my-workspace > state.json terraform init terraform state rm -state=state.json kubernetes_config_map.example terraform import -state=state.json kubernetes_config_map_v1.example default/example coder state push --no-build my-workspace state.json ``` ## Changes - Add `PUT /api/v2/workspacebuilds/{id}/state` endpoint to update state without triggering a build - Add `UpdateWorkspaceBuildState` SDK method - Add `--no-build`/`-n` flag to `coder state push` - Add confirmation prompt (can be skipped with `--yes`/`-y`) since this is a potentially dangerous operation - Add test for `--no-build` functionality Fixes #21336 --- cli/state.go | 17 ++++++ cli/state_test.go | 47 +++++++++++++++++ cli/testdata/coder_state_push_--help.golden | 4 ++ coderd/apidoc/docs.go | 50 ++++++++++++++++++ coderd/apidoc/swagger.json | 46 +++++++++++++++++ coderd/coderd.go | 1 + coderd/workspacebuilds.go | 57 +++++++++++++++++++++ codersdk/workspacebuilds.go | 22 ++++++++ docs/reference/api/builds.md | 38 ++++++++++++++ docs/reference/api/schemas.md | 16 ++++++ docs/reference/cli/state_push.md | 8 +++ site/src/api/typesGenerated.ts | 9 ++++ 12 files changed, 315 insertions(+) diff --git a/cli/state.go b/cli/state.go index 2b8e7f8cc6..4dac6a3d17 100644 --- a/cli/state.go +++ b/cli/state.go @@ -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 ", 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 } diff --git a/cli/state_test.go b/cli/state_test.go index af2d032113..05fa7da077 100644 --- a/cli/state_test.go +++ b/cli/state_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "context" "fmt" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "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/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/provisioner/echo" @@ -157,4 +159,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") + }) } diff --git a/cli/testdata/coder_state_push_--help.golden b/cli/testdata/coder_state_push_--help.golden index 06764846c3..df73146cfe 100644 --- a/cli/testdata/coder_state_push_--help.golden +++ b/cli/testdata/coder_state_push_--help.golden @@ -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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 47e7c91e59..f6735f0212 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10225,6 +10225,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": { @@ -19550,6 +19589,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateWorkspaceBuildStateRequest": { + "type": "object", + "properties": { + "state": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e4188a833f..11a167f62e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9056,6 +9056,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": { @@ -17935,6 +17970,17 @@ } } }, + "codersdk.UpdateWorkspaceBuildStateRequest": { + "type": "object", + "properties": { + "state": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 607b7f0ea1..a493a9da1a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1503,6 +1503,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) { diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 409d6567d0..090d480cba 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -882,6 +882,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 diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index a91148ab2a..78efbb4eaa 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -188,6 +188,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 { diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index ff0d49e0c9..1ad978f11d 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -1183,6 +1183,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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0fe145b4a7..dfbd084d64 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9147,6 +9147,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= * * ` 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 diff --git a/docs/reference/cli/state_push.md b/docs/reference/cli/state_push.md index 039b03fc01..7796d0ba8d 100644 --- a/docs/reference/cli/state_push.md +++ b/docs/reference/cli/state_push.md @@ -18,3 +18,11 @@ coder state push [flags] | Type | int | Specify a workspace build to target by name. Defaults to latest. + +### -n, --no-build + +| | | +|------|-------------------| +| Type | bool | + +Update the state without triggering a workspace build. Useful for state-only migrations. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 563b72dc61..e38ed995a9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5606,6 +5606,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.