From 6a200a49d376be92f0081df9bdd874e55aee6a39 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 6 May 2026 09:27:24 -0700 Subject: [PATCH] feat: refresh dynamic parameters on secret changes (#24786) Publishes user secret create, update, and delete events and subscribes dynamic parameter websockets to authorized owner secret changes. Secret changes trigger fresh renders with monotonic response IDs, with backend tests covering subscription authorization and websocket refresh behavior. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/parameters.go | 195 +++++++++++++---- coderd/parameters_internal_test.go | 148 +++++++++++++ coderd/parameters_test.go | 197 +++++++++++++++++- coderd/usersecrets.go | 36 ++++ coderd/usersecretspubsub/usersecretspubsub.go | 42 ++++ codersdk/parameters.go | 5 +- docs/reference/api/schemas.md | 12 +- site/src/api/typesGenerated.ts | 5 +- 10 files changed, 586 insertions(+), 58 deletions(-) create mode 100644 coderd/parameters_internal_test.go create mode 100644 coderd/usersecretspubsub/usersecretspubsub.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index be71ec44e1..e6dd2eefe8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17876,7 +17876,7 @@ const docTemplate = `{ "type": "object", "properties": { "id": { - "description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.", + "description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.", "type": "integer" }, "inputs": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9f900a3435..2c9eac6b40 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16236,7 +16236,7 @@ "type": "object", "properties": { "id": { - "description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.", + "description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.", "type": "integer" }, "inputs": { diff --git a/coderd/parameters.go b/coderd/parameters.go index f39d05ab2a..2531655651 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -8,16 +8,23 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/usersecretspubsub" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/websocket" ) +const initialDynamicParametersResponseID = -1 + // @Summary Evaluate dynamic parameters for template version // @ID evaluate-dynamic-parameters-for-template-version // @Security CoderSessionToken @@ -63,7 +70,7 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter } api.templateVersionDynamicParameters(true, codersdk.DynamicParametersRequest{ - ID: -1, + ID: initialDynamicParametersResponseID, Inputs: map[string]string{}, OwnerID: userID, })(rw, r) @@ -117,16 +124,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini ctx := r.Context() // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: 0, - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - + response := renderDynamicParametersResponse(ctx, render, 0, initial.OwnerID, initial.Inputs) httpapi.Write(ctx, rw, http.StatusOK, response) } @@ -151,31 +149,43 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request api.Logger, ) + secretEvents := make(chan uuid.UUID, 1) + secretSubscriber := ¶meterSecretEventSubscriber{ + api: api, + events: secretEvents, + } + secretSubscriber.UpdateOwnerSubscription(ctx, initial.OwnerID) + defer secretSubscriber.Close() + + sender := dynamicParametersResponseSender{ + stream: stream, + render: render, + } + // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: -1, // Always start with -1. - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - err = stream.Send(response) - if err != nil { - stream.Drop() + if !sender.Send(ctx, initialDynamicParametersResponseID, initial.OwnerID, initial.Inputs) { return } - // As the user types into the form, reprocess the state using their input, - // and respond with updates. + // As the user types into the form or updates secrets in another client, + // reprocess the state using their input and respond with updates. updates := stream.Chan() ownerID := initial.OwnerID + inputs := initial.Inputs + lastResponseID := initialDynamicParametersResponseID for { select { case <-ctx.Done(): stream.Close(websocket.StatusGoingAway) return + case eventOwnerID := <-secretEvents: + if eventOwnerID != ownerID { + continue + } + lastResponseID = nextDynamicParametersResponseID(lastResponseID, lastResponseID+1) + if !sender.Send(ctx, lastResponseID, ownerID, inputs) { + return + } case update, ok := <-updates: if !ok { // The connection has been closed, so there is no one to write to @@ -189,21 +199,130 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request } ownerID = update.OwnerID - - result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: update.ID, - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - err = stream.Send(response) - if err != nil { - stream.Drop() + inputs = update.Inputs + secretSubscriber.UpdateOwnerSubscription(ctx, ownerID) + responseID := nextDynamicParametersResponseID(lastResponseID, update.ID) + lastResponseID = responseID + if !sender.Send(ctx, responseID, ownerID, inputs) { return } } } } + +func renderDynamicParametersResponse( + ctx context.Context, + render dynamicparameters.Renderer, + id int, + ownerID uuid.UUID, + inputs map[string]string, +) codersdk.DynamicParametersResponse { + result, diagnostics := render.Render(ctx, ownerID, inputs, dynamicparameters.IncludeSecretRequirements()) + response := codersdk.DynamicParametersResponse{ + ID: id, + Diagnostics: db2sdk.HCLDiagnostics(diagnostics), + } + if result.Output != nil { + response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) + } + response.SecretRequirements = result.SecretRequirements + return response +} + +type dynamicParametersResponseSender struct { + stream *wsjson.Stream[codersdk.DynamicParametersRequest, codersdk.DynamicParametersResponse] + render dynamicparameters.Renderer +} + +func (s dynamicParametersResponseSender) Send( + ctx context.Context, + id int, + ownerID uuid.UUID, + inputs map[string]string, +) bool { + response := renderDynamicParametersResponse(ctx, s.render, id, ownerID, inputs) + if err := s.stream.Send(response); err != nil { + s.stream.Drop() + return false + } + return true +} + +type parameterSecretEventSubscriber struct { + api *API + events chan uuid.UUID + + cancel func() + ownerID uuid.UUID +} + +// UpdateOwnerSubscription switches the pubsub subscription to the owner's +// user secret channel. Dynamic parameters can render for a workspace owner +// other than the connected user, so owner changes must update the channel +// that drives secret requirement refreshes. +func (s *parameterSecretEventSubscriber) UpdateOwnerSubscription(ctx context.Context, ownerID uuid.UUID) { + if ownerID == s.ownerID { + return + } + if s.cancel != nil { + s.Close() + } + // Websocket authorization uses the actor snapshot from connection + // creation, matching the rest of the websocket handlers. + if !s.api.canSubscribeUserSecretEvents(ctx, ownerID) { + s.ownerID = ownerID + return + } + s.ownerID = ownerID + subscribedOwnerID := ownerID + cancel, err := s.api.Pubsub.Subscribe(usersecretspubsub.Channel(ownerID), func(context.Context, []byte) { + s.notify(subscribedOwnerID) + }) + if err != nil { + // Leave the owner unset so transient pubsub failures can be + // retried on the next update for this owner. + s.ownerID = uuid.Nil + s.api.Logger.Warn(ctx, "failed to subscribe to user secret events", + slog.F("user_id", ownerID), + slog.Error(err), + ) + return + } + s.cancel = cancel +} + +func (s *parameterSecretEventSubscriber) Close() { + if s.cancel == nil { + return + } + s.cancel() + s.cancel = nil +} + +func (s *parameterSecretEventSubscriber) notify(ownerID uuid.UUID) { + select { + case s.events <- ownerID: + default: + } +} + +func nextDynamicParametersResponseID(lastResponseID int, requestID int) int { + if requestID <= lastResponseID { + return lastResponseID + 1 + } + return requestID +} + +func (api *API) canSubscribeUserSecretEvents(ctx context.Context, ownerID uuid.UUID) bool { + roles, ok := dbauthz.ActorFromContext(ctx) + if !ok { + api.Logger.Error(ctx, "no authorization actor for user secret event subscription") + return false + } + return api.HTTPAuth.Authorizer.Authorize( + ctx, + roles, + policy.ActionRead, + rbac.ResourceUserSecret.WithOwner(ownerID.String()), + ) == nil +} diff --git a/coderd/parameters_internal_test.go b/coderd/parameters_internal_test.go new file mode 100644 index 0000000000..4b08064ffd --- /dev/null +++ b/coderd/parameters_internal_test.go @@ -0,0 +1,148 @@ +package coderd + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/testutil" +) + +func TestNextDynamicParametersResponseID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lastResponseID int + requestID int + want int + }{ + { + name: "request ID advances response ID", + lastResponseID: 1, + requestID: 4, + want: 4, + }, + { + name: "request ID collision advances response ID", + lastResponseID: 4, + requestID: 4, + want: 5, + }, + { + name: "stale request ID advances response ID", + lastResponseID: 4, + requestID: 2, + want: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := nextDynamicParametersResponseID(tt.lastResponseID, tt.requestID) + require.Equal(t, tt.want, got) + }) + } +} + +func TestCanSubscribeUserSecretEventsRequiresSecretRead(t *testing.T) { + t.Parallel() + + ownerID := uuid.New() + actor := rbac.Subject{ID: uuid.NewString()} + + t.Run("allowed", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{} + api := &API{ + Options: &Options{ + Logger: testutil.Logger(t), + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: testutil.Logger(t), + }, + } + ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context. + + require.True(t, api.canSubscribeUserSecretEvents(ctx, ownerID)) + require.Len(t, auth.calls, 1) + require.Equal(t, actor, auth.calls[0].Actor) + require.Equal(t, policy.ActionRead, auth.calls[0].Action) + require.Equal(t, rbac.ResourceUserSecret.Type, auth.calls[0].Object.Type) + require.Equal(t, ownerID.String(), auth.calls[0].Object.Owner) + }) + + t.Run("denied", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{err: xerrors.New("denied")} + api := &API{ + Options: &Options{ + Logger: testutil.Logger(t), + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: testutil.Logger(t), + }, + } + ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context. + + require.False(t, api.canSubscribeUserSecretEvents(ctx, ownerID)) + require.Len(t, auth.calls, 1) + }) + + t.Run("no actor", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{} + logger := slogtest.Make(t, &slogtest.Options{ + IgnoredErrorIs: []error{}, + IgnoreErrorFn: func(entry slog.SinkEntry) bool { + return entry.Message == "no authorization actor for user secret event subscription" + }, + }) + api := &API{ + Options: &Options{ + Logger: logger, + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: logger, + }, + } + + require.False(t, api.canSubscribeUserSecretEvents(context.Background(), ownerID)) + require.Empty(t, auth.calls) + }) +} + +type recordingAuthorizer struct { + err error + calls []rbac.AuthCall +} + +func (a *recordingAuthorizer) Authorize(_ context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error { + a.calls = append(a.calls, rbac.AuthCall{ + Actor: subject, + Action: action, + Object: object, + }) + return a.err +} + +func (*recordingAuthorizer) Prepare(context.Context, rbac.Subject, policy.Action, string) (rbac.PreparedAuthorized, error) { + //nolint:nilnil // Prepare is unused by these tests. + return nil, nil +} diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 3473cc01e8..442682b5e0 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -414,6 +414,183 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { }}, preview.SecretRequirements) }) + t.Run("SecretRequirementPushesOnSecretChange", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_test", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + err = setup.dynamicParamsClient.DeleteUserSecret(ctx, codersdk.Me, "github-token") + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_test", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 2, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + otherEnvName := "OTHER_GITHUB_TOKEN" + _, err = setup.dynamicParamsClient.UpdateUserSecret(ctx, codersdk.Me, "github-token", codersdk.UpdateUserSecretRequest{ + EnvName: &otherEnvName, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 3, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + }) + + t.Run("SecretRequirementPushesAfterOwnerSwitch", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + // No production role grants cross-user user_secret:read today, + // so use an allow-all authorizer for lifecycle coverage. + authorizer: &coderdtest.FakeAuthorizer{}, + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + err = setup.stream.Send(codersdk.DynamicParametersRequest{ + ID: 0, + Inputs: map[string]string{}, + OwnerID: target.ID, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_target", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_initial", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + require.Never(t, func() bool { + select { + case <-previews: + return true + default: + return false + } + }, testutil.WaitShort/5, testutil.IntervalFast) + }) + + t.Run("SecretRequirementDoesNotSubscribeWhenOwnerUnauthorized", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + err = setup.stream.Send(codersdk.DynamicParametersRequest{ + ID: 0, + Inputs: map[string]string{}, + OwnerID: target.ID, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Empty(t, preview.SecretRequirements) + require.Len(t, preview.Diagnostics, 1) + require.Equal(t, dynamicparameters.DiagCodeSecretValidationForbidden, preview.Diagnostics[0].Extra.Code) + + _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_target", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + require.Never(t, func() bool { + select { + case <-previews: + return true + default: + return false + } + }, testutil.WaitShort/5, testutil.IntervalFast) + }) + // Regression test for PLAT-100: a workspace whose template has an // unsatisfied coder_secret requirement must still be stoppable and // deletable. Start remains blocked. @@ -475,6 +652,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { type setupDynamicParamsTestParams struct { db database.Store ps pubsub.Pubsub + authorizer rbac.Authorizer provisionerDaemonVersion string mainTF []byte modulesArchive []byte @@ -486,16 +664,18 @@ type setupDynamicParamsTestParams struct { } type dynamicParamsTest struct { - client *codersdk.Client - api *coderd.API - stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] - template codersdk.Template + client *codersdk.Client + dynamicParamsClient *codersdk.Client + api *coderd.API + stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] + template codersdk.Template } func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest { ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: args.db, Pubsub: args.ps, + Authorizer: args.authorizer, IncludeProvisionerDaemon: true, ProvisionerDaemonVersion: args.provisionerDaemonVersion, }) @@ -530,10 +710,11 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn }) return dynamicParamsTest{ - client: ownerClient, - api: api, - stream: stream, - template: tpl, + client: ownerClient, + dynamicParamsClient: templateAdmin, + api: api, + stream: stream, + template: tpl, } } diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 78ca22f776..57aa6d9f16 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "net/http" @@ -9,11 +10,13 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/usersecretspubsub" "github.com/coder/coder/v2/codersdk" ) @@ -106,6 +109,14 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { } aReq.New = secret + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindCreated, + UserID: secret.UserID, + Name: secret.Name, + EnvName: secret.EnvName, + FilePath: secret.FilePath, + }) + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret)) } @@ -303,6 +314,14 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { return } + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindUpdated, + UserID: secret.UserID, + Name: secret.Name, + EnvName: secret.EnvName, + FilePath: secret.FilePath, + }) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret)) } @@ -346,5 +365,22 @@ func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) { } aReq.Old = deleted + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindDeleted, + UserID: user.ID, + Name: name, + }) + rw.WriteHeader(http.StatusNoContent) } + +func (api *API) publishUserSecretEvent(ctx context.Context, event usersecretspubsub.Event) { + if err := usersecretspubsub.Publish(api.Pubsub, event); err != nil { + api.Logger.Warn(ctx, "failed to publish user secret event", + slog.F("user_id", event.UserID), + slog.F("secret_name", event.Name), + slog.F("event_kind", event.Kind), + slog.Error(err), + ) + } +} diff --git a/coderd/usersecretspubsub/usersecretspubsub.go b/coderd/usersecretspubsub/usersecretspubsub.go new file mode 100644 index 0000000000..39fb5c0759 --- /dev/null +++ b/coderd/usersecretspubsub/usersecretspubsub.go @@ -0,0 +1,42 @@ +package usersecretspubsub + +import ( + "encoding/json" + "fmt" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database/pubsub" +) + +type EventKind string + +const ( + EventKindCreated EventKind = "created" + EventKindUpdated EventKind = "updated" + EventKindDeleted EventKind = "deleted" +) + +type Event struct { + Kind EventKind `json:"kind"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + Name string `json:"name"` + EnvName string `json:"env_name,omitempty"` + FilePath string `json:"file_path,omitempty"` +} + +func Channel(userID uuid.UUID) string { + return fmt.Sprintf("user_secrets:%s", userID) +} + +func Publish(ps pubsub.Publisher, event Event) error { + msg, err := json.Marshal(event) + if err != nil { + return xerrors.Errorf("marshal user secret event: %w", err) + } + if err := ps.Publish(Channel(event.UserID), msg); err != nil { + return xerrors.Errorf("publish user secret event: %w", err) + } + return nil +} diff --git a/codersdk/parameters.go b/codersdk/parameters.go index ba1ac864e9..7f6c9cdad1 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -162,8 +162,9 @@ type PreviewParameterValidation struct { } type DynamicParametersRequest struct { - // ID identifies the request. The response contains the same - // ID so that the client can match it to the request. + // ID identifies the request for response ordering. Websocket response + // IDs are monotonically increasing and may exceed the request ID when + // server-side events trigger additional renders. ID int `json:"id"` Inputs map[string]string `json:"inputs"` // OwnerID if uuid.Nil, it defaults to `codersdk.Me` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 99ca97beff..534003f5cd 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6407,12 +6407,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|---------|----------|--------------|--------------------------------------------------------------------------------------------------------------| -| `id` | integer | false | | ID identifies the request. The response contains the same ID so that the client can match it to the request. | -| `inputs` | object | false | | | -| » `[any property]` | string | false | | | -| `owner_id` | string | false | | Owner ID if uuid.Nil, it defaults to `codersdk.Me` | +| Name | Type | Required | Restrictions | Description | +|--------------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | integer | false | | ID identifies the request for response ordering. Websocket response IDs are monotonically increasing and may exceed the request ID when server-side events trigger additional renders. | +| `inputs` | object | false | | | +| » `[any property]` | string | false | | | +| `owner_id` | string | false | | Owner ID if uuid.Nil, it defaults to `codersdk.Me` | ## codersdk.DynamicParametersResponse diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bcbfa35ddb..12c18112b7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3782,8 +3782,9 @@ export const DisplayApps: DisplayApp[] = [ // From codersdk/parameters.go export interface DynamicParametersRequest { /** - * ID identifies the request. The response contains the same - * ID so that the client can match it to the request. + * ID identifies the request for response ordering. Websocket response + * IDs are monotonically increasing and may exceed the request ID when + * server-side events trigger additional renders. */ readonly id: number; readonly inputs: Record;