mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Add a ruleguard rule forbidding direct `json.NewDecoder(res.Body).Decode(...)` on `*http.Response` in codersdk packages, so new typed endpoints use `codersdk.ReadBodyAsJSON` and keep returning structured errors for non-JSON bodies. The rule matches both the chained call form and decoders assigned to a variable first. Intentional raw-body paths carry documented `//nolint:gocritic` exceptions: the 16 agent-direct HTTP decodes in `workspacesdk/agentconn.go` route through a single `decodeAgentJSON` helper (agent-direct over tailnet, so `ReadBodyAsJSON`'s reverse proxy/SSO error guidance does not apply), and the Azure IMDS attested-document decode in `agentsdk/azure.go` keeps an inline exception. The two `UseNumber` decoders in `licenses.go` are migrated to a new `codersdk.ReadBodyAsJSONUseNumber`, so `coder licenses add/list` also return structured errors for non-JSON bodies instead of `invalid character '<' looking for beginning of value`. Note for local verification: golangci-lint caches results, so run `golangci-lint cache clean` after modifying `scripts/rules.go` or the rule may silently not fire. Final PR of the stack on #27804, #27857, and #27858. Refs #27044. Stack plan Inventory (full-tree audit): 280 migratable call sites across 47 files; 17 excluded (16 agent-direct HTTP sites in `workspacesdk/agentconn.go`, 1 Azure IMDS decode in `agentsdk/azure.go`). 1. **#27857** `refactor(codersdk): use ReadBodyAsJSON in typed endpoints`: mechanical migration of all sites except `chats.go` (224 sites, 46 files). 2. **#27858** `refactor(codersdk): use shared error helpers in chat endpoints`: migrate the 56 `chats.go` sites and consolidate the duplicated `readRawBodyAsError`/`newResponseError` helpers onto the shared `client.go` error path, with regression tests for the 409 usage-limit flow. 3. **#27859** `chore: forbid direct response body JSON decode in codersdk`: ruleguard rule with documented exceptions for the intentional raw-body paths, plus `ReadBodyAsJSONUseNumber` for the `licenses.go` decoders. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
136 lines
3.9 KiB
Go
136 lines
3.9 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
const (
|
|
LicenseExpiryClaim = "license_expires"
|
|
LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
|
|
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
|
|
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
|
|
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
|
|
)
|
|
|
|
type AddLicenseRequest struct {
|
|
License string `json:"license" validate:"required"`
|
|
}
|
|
|
|
type License struct {
|
|
ID int32 `json:"id"`
|
|
UUID uuid.UUID `json:"uuid" format:"uuid"`
|
|
UploadedAt time.Time `json:"uploaded_at" format:"date-time"`
|
|
// Claims are the JWT claims asserted by the license. Here we use
|
|
// a generic string map to ensure that all data from the server is
|
|
// parsed verbatim, not just the fields this version of Coder
|
|
// understands.
|
|
Claims map[string]interface{} `json:"claims" table:"claims"`
|
|
}
|
|
|
|
// ExpiresAt returns the expiration time of the license.
|
|
// If the claim is missing or has an unexpected type, an error is returned.
|
|
func (l *License) ExpiresAt() (time.Time, error) {
|
|
expClaim, ok := l.Claims[LicenseExpiryClaim]
|
|
if !ok {
|
|
return time.Time{}, xerrors.New("license_expires claim is missing")
|
|
}
|
|
|
|
// This claim should be a unix timestamp.
|
|
// Everything is already an interface{}, so we need to do some type
|
|
// assertions to figure out what we're dealing with.
|
|
if unix, ok := expClaim.(json.Number); ok {
|
|
i64, err := unix.Int64()
|
|
if err != nil {
|
|
return time.Time{}, xerrors.Errorf("license_expires claim is not a valid unix timestamp: %w", err)
|
|
}
|
|
return time.Unix(i64, 0), nil
|
|
}
|
|
|
|
return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim)
|
|
}
|
|
|
|
func (l *License) Trial() bool {
|
|
if trail, ok := l.Claims["trail"].(bool); ok {
|
|
return trail
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (l *License) AllFeaturesClaim() bool {
|
|
if all, ok := l.Claims["all_features"].(bool); ok {
|
|
return all
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FeaturesClaims provides the feature claims in license.
|
|
// This only returns the explicit claims. If checking for actual usage,
|
|
// also check `AllFeaturesClaim`.
|
|
func (l *License) FeaturesClaims() (map[FeatureName]int64, error) {
|
|
strMap, ok := l.Claims["features"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, xerrors.New("features key is unexpected type")
|
|
}
|
|
fMap := make(map[FeatureName]int64)
|
|
for k, v := range strMap {
|
|
jn, ok := v.(json.Number)
|
|
if !ok {
|
|
return nil, xerrors.Errorf("feature %q has unexpected type", k)
|
|
}
|
|
|
|
n, err := jn.Int64()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fMap[FeatureName(k)] = n
|
|
}
|
|
|
|
return fMap, nil
|
|
}
|
|
|
|
func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/licenses", r)
|
|
if err != nil {
|
|
return License{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return License{}, ReadBodyAsError(res)
|
|
}
|
|
var l License
|
|
return l, ReadBodyAsJSONUseNumber(res, &l)
|
|
}
|
|
|
|
func (c *Client) Licenses(ctx context.Context) ([]License, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/licenses", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, ReadBodyAsError(res)
|
|
}
|
|
var licenses []License
|
|
return licenses, ReadBodyAsJSONUseNumber(res, &licenses)
|
|
}
|
|
|
|
func (c *Client) DeleteLicense(ctx context.Context, id int32) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/licenses/%d", id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|