Skip to content

Commit 262d769

Browse files
authored
feat: add force refresh of license entitlements (#9155)
* feat: add force refresh of license entitlements * send "going away" mesasge on licenses pubsub on close * Add manual refresh to licenses page
1 parent 37a3b42 commit 262d769

File tree

16 files changed

+264
-13
lines changed

16 files changed

+264
-13
lines changed

coderd/apidoc/docs.go

+29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+25
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codersdk/deployment.go

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ type Entitlements struct {
103103
HasLicense bool `json:"has_license"`
104104
Trial bool `json:"trial"`
105105
RequireTelemetry bool `json:"require_telemetry"`
106+
RefreshedAt time.Time `json:"refreshed_at" format:"date-time"`
106107
}
107108

108109
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {

docs/api/enterprise.md

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/organizations.md

+38
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

enterprise/coderd/coderd.go

+16-1
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
130130
})
131131
r.Route("/licenses", func(r chi.Router) {
132132
r.Use(apiKeyMiddleware)
133+
r.Post("/refresh-entitlements", api.postRefreshEntitlements)
133134
r.Post("/", api.postLicense)
134135
r.Get("/", api.licenses)
135136
r.Delete("/{id}", api.deleteLicense)
@@ -403,10 +404,13 @@ type API struct {
403404
}
404405

405406
func (api *API) Close() error {
406-
api.cancel()
407+
// Replica manager should be closed first. This is because the replica
408+
// manager updates the replica's table in the database when it closes.
409+
// This tells other Coderds that it is now offline.
407410
if api.replicaManager != nil {
408411
_ = api.replicaManager.Close()
409412
}
413+
api.cancel()
410414
if api.derpMesh != nil {
411415
_ = api.derpMesh.Close()
412416
}
@@ -802,6 +806,17 @@ func (api *API) runEntitlementsLoop(ctx context.Context) {
802806
updates := make(chan struct{}, 1)
803807
subscribed := false
804808

809+
defer func() {
810+
// If this function ends, it means the context was cancelled and this
811+
// coderd is shutting down. In this case, post a pubsub message to
812+
// tell other coderd's to resync their entitlements. This is required to
813+
// make sure things like replica counts are updated in the UI.
814+
// Ignore the error, as this is just a best effort. If it fails,
815+
// the system will eventually recover as replicas timeout
816+
// if their heartbeats stop. The best effort just tries to update the
817+
// UI faster if it succeeds.
818+
_ = api.Pubsub.Publish(PubsubEventLicenses, []byte("going away"))
819+
}()
805820
for {
806821
select {
807822
case <-ctx.Done():

enterprise/coderd/license/license.go

+1
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ func Entitlements(
225225
entitlements.Features[featureName] = feature
226226
}
227227
}
228+
entitlements.RefreshedAt = now
228229

229230
return entitlements, nil
230231
}

enterprise/coderd/licenses.go

+70
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
_ "embed"
99
"encoding/base64"
1010
"encoding/json"
11+
"fmt"
1112
"net/http"
1213
"strconv"
1314
"strings"
@@ -150,6 +151,75 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
150151
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
151152
}
152153

154+
// postRefreshEntitlements forces an `updateEntitlements` call and publishes
155+
// a message to the PubsubEventLicenses topic to force other replicas
156+
// to update their entitlements.
157+
// Updates happen automatically on a timer, however that time is every 10 minutes,
158+
// and we want to be able to force an update immediately in some cases.
159+
//
160+
// @Summary Update license entitlements
161+
// @ID update-license-entitlements
162+
// @Security CoderSessionToken
163+
// @Produce json
164+
// @Tags Organizations
165+
// @Success 201 {object} codersdk.Response
166+
// @Router /licenses/refresh-entitlements [post]
167+
func (api *API) postRefreshEntitlements(rw http.ResponseWriter, r *http.Request) {
168+
ctx := r.Context()
169+
170+
// If the user cannot create a new license, then they cannot refresh entitlements.
171+
// Refreshing entitlements is a way to force a refresh of the license, so it is
172+
// equivalent to creating a new license.
173+
if !api.AGPL.Authorize(r, rbac.ActionCreate, rbac.ResourceLicense) {
174+
httpapi.Forbidden(rw)
175+
return
176+
}
177+
178+
// Prevent abuse by limiting how often we allow a forced refresh.
179+
now := time.Now()
180+
if diff := now.Sub(api.entitlements.RefreshedAt); diff < time.Minute {
181+
wait := time.Minute - diff
182+
rw.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())))
183+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
184+
Message: fmt.Sprintf("Entitlements already recently refreshed, please wait %d seconds to force a new refresh", int(wait.Seconds())),
185+
Detail: fmt.Sprintf("Last refresh at %s", now.UTC().String()),
186+
})
187+
return
188+
}
189+
190+
err := api.replicaManager.UpdateNow(ctx)
191+
if err != nil {
192+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
193+
Message: "Failed to sync replicas",
194+
Detail: err.Error(),
195+
})
196+
return
197+
}
198+
199+
err = api.updateEntitlements(ctx)
200+
if err != nil {
201+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
202+
Message: "Failed to update entitlements",
203+
Detail: err.Error(),
204+
})
205+
return
206+
}
207+
208+
err = api.Pubsub.Publish(PubsubEventLicenses, []byte("refresh"))
209+
if err != nil {
210+
api.Logger.Error(context.Background(), "failed to publish forced entitlement update", slog.Error(err))
211+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
212+
Message: "Failed to publish forced entitlement update. Other replicas might not be updated.",
213+
Detail: err.Error(),
214+
})
215+
return
216+
}
217+
218+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
219+
Message: "Entitlements updated",
220+
})
221+
}
222+
153223
// @Summary Get licenses
154224
// @ID get-licenses
155225
// @Security CoderSessionToken

site/src/api/api.ts

+5
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,10 @@ export const putWorkspaceExtension = async (
808808
})
809809
}
810810

811+
export const refreshEntitlements = async (): Promise<void> => {
812+
await axios.post("/api/v2/licenses/refresh-entitlements")
813+
}
814+
811815
export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
812816
try {
813817
const response = await axios.get("/api/v2/entitlements")
@@ -821,6 +825,7 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
821825
require_telemetry: false,
822826
trial: false,
823827
warnings: [],
828+
refreshed_at: "",
824829
}
825830
}
826831
throw ex

site/src/api/typesGenerated.ts

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx

+12-2
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@ import useToggle from "react-use/lib/useToggle"
99
import { pageTitle } from "utils/page"
1010
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"
1111
import LicensesSettingsPageView from "./LicensesSettingsPageView"
12+
import { getErrorMessage } from "api/errors"
1213

1314
const LicensesSettingsPage: FC = () => {
1415
const queryClient = useQueryClient()
15-
const [entitlementsState] = useMachine(entitlementsMachine)
16-
const { entitlements } = entitlementsState.context
16+
const [entitlementsState, sendEvent] = useMachine(entitlementsMachine)
17+
const { entitlements, getEntitlementsError } = entitlementsState.context
1718
const [searchParams, setSearchParams] = useSearchParams()
1819
const success = searchParams.get("success")
1920
const [confettiOn, toggleConfettiOn] = useToggle(false)
21+
if (getEntitlementsError) {
22+
displayError(
23+
getErrorMessage(getEntitlementsError, "Failed to fetch entitlements"),
24+
)
25+
}
2026

2127
const { mutate: removeLicenseApi, isLoading: isRemovingLicense } =
2228
useMutation(removeLicense, {
@@ -58,6 +64,10 @@ const LicensesSettingsPage: FC = () => {
5864
licenses={licenses}
5965
isRemovingLicense={isRemovingLicense}
6066
removeLicense={(licenseId: number) => removeLicenseApi(licenseId)}
67+
refreshEntitlements={() => {
68+
const x = sendEvent("REFRESH")
69+
return !x.context.getEntitlementsError
70+
}}
6171
/>
6272
</>
6373
)

0 commit comments

Comments
 (0)