mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
Due to a logical error in CommitQuota, all workspace Stop->Start operations were being accepted, regardless of the Quota limit. This issue only appeared after #9201, so this was a minor regression in main for about 3 days. This PR adds a test to make sure this kind of bug doesn't recur. To make the new test possible, we give the echo provisioner the ability to simulate responses to specific transitions.
163 lines
4.0 KiB
Go
163 lines
4.0 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"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/codersdk"
|
|
"github.com/coder/coder/v2/provisionerd/proto"
|
|
)
|
|
|
|
type committer struct {
|
|
Log slog.Logger
|
|
Database database.Store
|
|
}
|
|
|
|
func (c *committer) CommitQuota(
|
|
ctx context.Context, request *proto.CommitQuotaRequest,
|
|
) (*proto.CommitQuotaResponse, error) {
|
|
jobID, err := uuid.Parse(request.JobId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nextBuild, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
workspace, err := c.Database.GetWorkspaceByID(ctx, nextBuild.WorkspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
consumed int64
|
|
budget int64
|
|
permit bool
|
|
)
|
|
err = c.Database.InTx(func(s database.Store) error {
|
|
var err error
|
|
consumed, err = s.GetQuotaConsumedForUser(ctx, workspace.OwnerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
budget, err = s.GetQuotaAllowanceForUser(ctx, workspace.OwnerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the new build will reduce overall quota consumption, then we
|
|
// allow it even if the user is over quota.
|
|
netIncrease := true
|
|
prevBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
|
|
WorkspaceID: workspace.ID,
|
|
BuildNumber: nextBuild.BuildNumber - 1,
|
|
})
|
|
if err == nil {
|
|
netIncrease = request.DailyCost >= prevBuild.DailyCost
|
|
c.Log.Debug(
|
|
ctx, "previous build cost",
|
|
slog.F("prev_cost", prevBuild.DailyCost),
|
|
slog.F("next_cost", request.DailyCost),
|
|
slog.F("net_increase", netIncrease),
|
|
)
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
newConsumed := int64(request.DailyCost) + consumed
|
|
if newConsumed > budget && netIncrease {
|
|
c.Log.Debug(
|
|
ctx, "over quota, rejecting",
|
|
slog.F("prev_consumed", consumed),
|
|
slog.F("next_consumed", newConsumed),
|
|
slog.F("budget", budget),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
err = s.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{
|
|
ID: nextBuild.ID,
|
|
DailyCost: request.DailyCost,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
permit = true
|
|
consumed = newConsumed
|
|
return nil
|
|
}, &sql.TxOptions{
|
|
Isolation: sql.LevelSerializable,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &proto.CommitQuotaResponse{
|
|
Ok: permit,
|
|
CreditsConsumed: int32(consumed),
|
|
Budget: int32(budget),
|
|
}, nil
|
|
}
|
|
|
|
// @Summary Get workspace quota by user
|
|
// @ID get-workspace-quota-by-user
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.WorkspaceQuota
|
|
// @Router /workspace-quota/{user} [get]
|
|
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
|
|
user := httpmw.UserParam(r)
|
|
|
|
if !api.AGPL.Authorize(r, rbac.ActionRead, user) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
api.entitlementsMu.RLock()
|
|
licensed := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled
|
|
api.entitlementsMu.RUnlock()
|
|
|
|
// There are no groups and thus no allowance if RBAC isn't licensed.
|
|
var quotaAllowance int64 = -1
|
|
if licensed {
|
|
var err error
|
|
quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), user.ID)
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get allowance",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), user.ID)
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get consumed",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{
|
|
CreditsConsumed: int(quotaConsumed),
|
|
Budget: int(quotaAllowance),
|
|
})
|
|
}
|