Implement Quotas v3 (#5012)

* provisioner/terraform: add cost to resource_metadata

* provisionerd/runner: use Options struct

* Complete provisionerd implementation

* Add quota_allowance to groups

* Combine Quota and RBAC licenses

* Add Opts to InTx
This commit is contained in:
Ammar Bandukwala 2022-11-14 11:57:33 -06:00 committed by GitHub
parent 3fb7892c07
commit 97dbd4dc5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 1577 additions and 847 deletions

View File

@ -362,12 +362,6 @@ func newConfig() *codersdk.DeploymentConfig {
Enterprise: true,
Secret: true,
},
UserWorkspaceQuota: &codersdk.DeploymentConfigField[int]{
Name: "User Workspace Quota",
Usage: "Enables and sets a limit on how many workspaces each user can create.",
Flag: "user-workspace-quota",
Enterprise: true,
},
Provisioner: &codersdk.ProvisionerConfig{
Daemons: &codersdk.DeploymentConfigField[int]{
Name: "Provisioner Daemons",

View File

@ -79,16 +79,14 @@ func TestConfig(t *testing.T) {
}, {
Name: "Enterprise",
Env: map[string]string{
"CODER_AUDIT_LOGGING": "false",
"CODER_BROWSER_ONLY": "true",
"CODER_SCIM_API_KEY": "some-key",
"CODER_USER_WORKSPACE_QUOTA": "10",
"CODER_AUDIT_LOGGING": "false",
"CODER_BROWSER_ONLY": "true",
"CODER_SCIM_API_KEY": "some-key",
},
Valid: func(config *codersdk.DeploymentConfig) {
require.Equal(t, config.AuditLogging.Value, false)
require.Equal(t, config.BrowserOnly.Value, true)
require.Equal(t, config.SCIMAPIKey.Value, "some-key")
require.Equal(t, config.UserWorkspaceQuota.Value, 10)
},
}, {
Name: "TLS",

View File

@ -63,7 +63,7 @@ func activityBumpWorkspace(log slog.Logger, db database.Store, workspace databas
return xerrors.Errorf("update workspace build: %w", err)
}
return nil
})
}, nil)
if err != nil {
log.Error(
ctx, "bump failed",

View File

@ -177,7 +177,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
}
return nil
})
}, nil)
if err != nil {
log.Error(e.ctx, "workspace scheduling failed", slog.Error(err))
}

View File

@ -40,9 +40,9 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/workspacequota"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
"github.com/coder/coder/site"
"github.com/coder/coder/tailnet"
)
@ -66,7 +66,6 @@ type Options struct {
CacheDir string
Auditor audit.Auditor
WorkspaceQuotaEnforcer workspacequota.Enforcer
AgentConnectionUpdateFrequency time.Duration
AgentInactiveDisconnectTimeout time.Duration
// APIRateLimit is the minutely throughput rate limit per user or ip.
@ -145,9 +144,6 @@ func New(options *Options) *API {
if options.Auditor == nil {
options.Auditor = audit.NewNop()
}
if options.WorkspaceQuotaEnforcer == nil {
options.WorkspaceQuotaEnforcer = workspacequota.NewNop()
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
@ -174,12 +170,10 @@ func New(options *Options) *API {
Authorizer: options.Authorizer,
Logger: options.Logger,
},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
WorkspaceQuotaEnforcer: atomic.Pointer[workspacequota.Enforcer]{},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
}
api.Auditor.Store(&options.Auditor)
api.WorkspaceQuotaEnforcer.Store(&options.WorkspaceQuotaEnforcer)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
oauthConfigs := &httpmw.OAuth2Configs{
@ -590,8 +584,8 @@ type API struct {
ID uuid.UUID
Auditor atomic.Pointer[audit.Auditor]
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
WorkspaceQuotaEnforcer atomic.Pointer[workspacequota.Enforcer]
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
HTTPAuth *HTTPAuthorizer
// APIHandler serves "/api/v2"

View File

@ -528,7 +528,8 @@ func AwaitWorkspaceBuildJob(t *testing.T, client *codersdk.Client, build uuid.UU
t.Logf("waiting for workspace build job %s", build)
var workspaceBuild codersdk.WorkspaceBuild
require.Eventually(t, func() bool {
workspaceBuild, err := client.WorkspaceBuild(context.Background(), build)
var err error
workspaceBuild, err = client.WorkspaceBuild(context.Background(), build)
return assert.NoError(t, err) && workspaceBuild.Job.CompletedAt != nil
}, testutil.WaitShort, testutil.IntervalFast)
return workspaceBuild

View File

@ -121,7 +121,7 @@ func (*fakeQuerier) Ping(_ context.Context) (time.Duration, error) {
}
// InTx doesn't rollback data properly for in-memory yet.
func (q *fakeQuerier) InTx(fn func(database.Store) error) error {
func (q *fakeQuerier) InTx(fn func(database.Store) error, _ *sql.TxOptions) error {
q.mutex.Lock()
defer q.mutex.Unlock()
return fn(&fakeQuerier{mutex: inTxMutex{}, data: q.data})
@ -2246,6 +2246,7 @@ func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.In
Name: arg.Name,
Hide: arg.Hide,
Icon: arg.Icon,
DailyCost: arg.DailyCost,
}
q.provisionerJobResources = append(q.provisionerJobResources, resource)
return resource, nil
@ -2757,6 +2758,20 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U
}
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceBuildCostByID(_ context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) (database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, workspaceBuild := range q.workspaceBuilds {
if workspaceBuild.ID != arg.ID {
continue
}
workspaceBuild.DailyCost = arg.DailyCost
q.workspaceBuilds[index] = workspaceBuild
return workspaceBuild, nil
}
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database.UpdateWorkspaceDeletedByIDParams) error {
q.mutex.Lock()
@ -2858,6 +2873,7 @@ func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou
if group.ID == arg.ID {
group.Name = arg.Name
group.AvatarURL = arg.AvatarURL
group.QuotaAllowance = arg.QuotaAllowance
q.groups[i] = group
return group, nil
}
@ -3230,6 +3246,7 @@ func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar
Name: arg.Name,
OrganizationID: arg.OrganizationID,
AvatarURL: arg.AvatarURL,
QuotaAllowance: arg.QuotaAllowance,
}
q.groups = append(q.groups, group)
@ -3430,3 +3447,46 @@ func (q *fakeQuerier) UpdateGitAuthLink(_ context.Context, arg database.UpdateGi
}
return nil
}
func (q *fakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UUID) (int64, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
var sum int64
for _, member := range q.groupMembers {
if member.UserID != userID {
continue
}
for _, group := range q.groups {
if group.ID == member.GroupID {
sum += int64(group.QuotaAllowance)
}
}
}
return sum, nil
}
func (q *fakeQuerier) GetQuotaConsumedForUser(_ context.Context, userID uuid.UUID) (int64, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
var sum int64
for _, workspace := range q.workspaces {
if workspace.OwnerID != userID {
continue
}
if workspace.Deleted {
continue
}
var lastBuild database.WorkspaceBuild
for _, build := range q.workspaceBuilds {
if build.WorkspaceID != workspace.ID {
continue
}
if build.CreatedAt.After(lastBuild.CreatedAt) {
lastBuild = build
}
}
sum += int64(lastBuild.DailyCost)
}
return sum, nil
}

View File

@ -38,7 +38,7 @@ func TestInTx(t *testing.T) {
})
assert.NoError(t, err)
return nil
})
}, nil)
assert.NoError(t, err)
}()
var nums []int

View File

@ -26,7 +26,7 @@ type Store interface {
customQuerier
Ping(ctx context.Context) (time.Duration, error)
InTx(func(Store) error) error
InTx(func(Store) error, *sql.TxOptions) error
}
// DBTX represents a database connection or transaction.
@ -68,7 +68,7 @@ func (q *sqlQuerier) Ping(ctx context.Context) (time.Duration, error) {
}
// InTx performs database operations inside a transaction.
func (q *sqlQuerier) InTx(function func(Store) error) error {
func (q *sqlQuerier) InTx(function func(Store) error, txOpts *sql.TxOptions) error {
if _, ok := q.db.(*sqlx.Tx); ok {
// If the current inner "db" is already a transaction, we just reuse it.
// We do not need to handle commit/rollback as the outer tx will handle
@ -80,7 +80,7 @@ func (q *sqlQuerier) InTx(function func(Store) error) error {
return nil
}
transaction, err := q.sdb.BeginTxx(context.Background(), nil)
transaction, err := q.sdb.BeginTxx(context.Background(), txOpts)
if err != nil {
return xerrors.Errorf("begin transaction: %w", err)
}

View File

@ -43,8 +43,8 @@ func TestNestedInTx(t *testing.T) {
LoginType: database.LoginTypeGithub,
})
return err
})
})
}, nil)
}, nil)
require.NoError(t, err, "outer tx: %w", err)
user, err := db.GetUserByID(context.Background(), uid)

View File

@ -19,9 +19,16 @@ func NewDB(t *testing.T) (database.Store, database.Pubsub) {
db := databasefake.New()
pubsub := database.NewPubsubInMemory()
if os.Getenv("DB") != "" {
connectionURL, closePg, err := postgres.Open()
require.NoError(t, err)
t.Cleanup(closePg)
connectionURL := os.Getenv("CODER_PG_CONNECTION_URL")
if connectionURL == "" {
var (
err error
closePg func()
)
connectionURL, closePg, err = postgres.Open()
require.NoError(t, err)
t.Cleanup(closePg)
}
sqlDB, err := sql.Open("postgres", connectionURL)
require.NoError(t, err)
t.Cleanup(func() {

View File

@ -192,7 +192,8 @@ CREATE TABLE groups (
id uuid NOT NULL,
name text NOT NULL,
organization_id uuid NOT NULL,
avatar_url text DEFAULT ''::text NOT NULL
avatar_url text DEFAULT ''::text NOT NULL,
quota_allowance integer DEFAULT 0 NOT NULL
);
CREATE TABLE licenses (
@ -444,7 +445,8 @@ CREATE TABLE workspace_builds (
provisioner_state bytea,
job_id uuid NOT NULL,
deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
reason build_reason DEFAULT 'initiator'::build_reason NOT NULL
reason build_reason DEFAULT 'initiator'::build_reason NOT NULL,
daily_cost integer DEFAULT 0 NOT NULL
);
CREATE TABLE workspace_resource_metadata (
@ -463,7 +465,8 @@ CREATE TABLE workspace_resources (
name character varying(64) NOT NULL,
hide boolean DEFAULT false NOT NULL,
icon character varying(256) DEFAULT ''::character varying NOT NULL,
instance_type character varying(256)
instance_type character varying(256),
daily_cost integer DEFAULT 0 NOT NULL
);
CREATE TABLE workspaces (

View File

@ -0,0 +1,3 @@
ALTER TABLE workspace_builds DROP COLUMN daily_cost;
ALTER TABLE workspace_resources DROP COLUMN daily_cost;
ALTER TABLE groups DROP COLUMN quota_allowance;

View File

@ -0,0 +1,3 @@
ALTER TABLE workspace_builds ADD COLUMN daily_cost int NOT NULL DEFAULT 0;
ALTER TABLE workspace_resources ADD COLUMN daily_cost int NOT NULL DEFAULT 0;
ALTER TABLE groups ADD COLUMN quota_allowance int NOT NULL DEFAULT 0;

View File

@ -454,6 +454,7 @@ type Group struct {
Name string `db:"name" json:"name"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"`
}
type GroupMember struct {
@ -700,6 +701,7 @@ type WorkspaceBuild struct {
JobID uuid.UUID `db:"job_id" json:"job_id"`
Deadline time.Time `db:"deadline" json:"deadline"`
Reason BuildReason `db:"reason" json:"reason"`
DailyCost int32 `db:"daily_cost" json:"daily_cost"`
}
type WorkspaceResource struct {
@ -712,6 +714,7 @@ type WorkspaceResource struct {
Hide bool `db:"hide" json:"hide"`
Icon string `db:"icon" json:"icon"`
InstanceType sql.NullString `db:"instance_type" json:"instance_type"`
DailyCost int32 `db:"daily_cost" json:"daily_cost"`
}
type WorkspaceResourceMetadatum struct {

View File

@ -72,6 +72,8 @@ type sqlcQuerier interface {
GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error)
GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error)
GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error)
GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error)
GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error)
GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error)
GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error)
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
@ -187,6 +189,7 @@ type sqlcQuerier interface {
UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error)
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error)
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error

View File

@ -1079,7 +1079,7 @@ func (q *sqlQuerier) GetAllOrganizationMembers(ctx context.Context, organization
const getGroupByID = `-- name: GetGroupByID :one
SELECT
id, name, organization_id, avatar_url
id, name, organization_id, avatar_url, quota_allowance
FROM
groups
WHERE
@ -1096,13 +1096,14 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
)
return i, err
}
const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one
SELECT
id, name, organization_id, avatar_url
id, name, organization_id, avatar_url, quota_allowance
FROM
groups
WHERE
@ -1126,6 +1127,7 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
)
return i, err
}
@ -1185,7 +1187,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]
const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many
SELECT
id, name, organization_id, avatar_url
id, name, organization_id, avatar_url, quota_allowance
FROM
groups
WHERE
@ -1208,6 +1210,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
); err != nil {
return nil, err
}
@ -1224,7 +1227,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization
const getUserGroups = `-- name: GetUserGroups :many
SELECT
groups.id, groups.name, groups.organization_id, groups.avatar_url
groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance
FROM
groups
JOIN
@ -1249,6 +1252,7 @@ func (q *sqlQuerier) GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Gro
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
); err != nil {
return nil, err
}
@ -1270,7 +1274,7 @@ INSERT INTO groups (
organization_id
)
VALUES
( $1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url
( $1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance
`
// We use the organization_id as the id
@ -1284,6 +1288,7 @@ func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uui
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
)
return i, err
}
@ -1293,10 +1298,11 @@ INSERT INTO groups (
id,
name,
organization_id,
avatar_url
avatar_url,
quota_allowance
)
VALUES
( $1, $2, $3, $4) RETURNING id, name, organization_id, avatar_url
( $1, $2, $3, $4, $5) RETURNING id, name, organization_id, avatar_url, quota_allowance
`
type InsertGroupParams struct {
@ -1304,6 +1310,7 @@ type InsertGroupParams struct {
Name string `db:"name" json:"name"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"`
}
func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) {
@ -1312,6 +1319,7 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr
arg.Name,
arg.OrganizationID,
arg.AvatarURL,
arg.QuotaAllowance,
)
var i Group
err := row.Scan(
@ -1319,6 +1327,7 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
)
return i, err
}
@ -1346,26 +1355,34 @@ UPDATE
groups
SET
name = $1,
avatar_url = $2
avatar_url = $2,
quota_allowance = $3
WHERE
id = $3
RETURNING id, name, organization_id, avatar_url
id = $4
RETURNING id, name, organization_id, avatar_url, quota_allowance
`
type UpdateGroupByIDParams struct {
Name string `db:"name" json:"name"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) {
row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.AvatarURL, arg.ID)
row := q.db.QueryRowContext(ctx, updateGroupByID,
arg.Name,
arg.AvatarURL,
arg.QuotaAllowance,
arg.ID,
)
var i Group
err := row.Scan(
&i.ID,
&i.Name,
&i.OrganizationID,
&i.AvatarURL,
&i.QuotaAllowance,
)
return i, err
}
@ -2770,6 +2787,53 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
return err
}
const getQuotaAllowanceForUser = `-- name: GetQuotaAllowanceForUser :one
SELECT
coalesce(SUM(quota_allowance), 0)::BIGINT
FROM
group_members gm
JOIN groups g ON
g.id = gm.group_id
WHERE
user_id = $1
`
func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaAllowanceForUser, userID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const getQuotaConsumedForUser = `-- name: GetQuotaConsumedForUser :one
WITH latest_builds AS (
SELECT
DISTINCT ON
(workspace_id) id,
workspace_id,
daily_cost
FROM
workspace_builds wb
ORDER BY
workspace_id,
created_at DESC
)
SELECT
coalesce(SUM(daily_cost), 0)::BIGINT
FROM
workspaces
JOIN latest_builds ON
latest_builds.workspace_id = workspaces.id
WHERE NOT deleted AND workspaces.owner_id = $1
`
func (q *sqlQuerier) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaConsumedForUser, ownerID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const deleteReplicasUpdatedBefore = `-- name: DeleteReplicasUpdatedBefore :exec
DELETE FROM replicas WHERE updated_at < $1
`
@ -5135,7 +5199,7 @@ func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg Updat
const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one
SELECT
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
FROM
workspace_builds
WHERE
@ -5162,12 +5226,13 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost
FROM (
SELECT
workspace_id, MAX(build_number) as max_build_number
@ -5203,6 +5268,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5218,7 +5284,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB
}
const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost
FROM (
SELECT
workspace_id, MAX(build_number) as max_build_number
@ -5256,6 +5322,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5272,7 +5339,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one
SELECT
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
FROM
workspace_builds
WHERE
@ -5297,13 +5364,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one
SELECT
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
FROM
workspace_builds
WHERE
@ -5328,13 +5396,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one
SELECT
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
FROM
workspace_builds
WHERE
@ -5363,13 +5432,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many
SELECT
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
FROM
workspace_builds
WHERE
@ -5437,6 +5507,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5452,7 +5523,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge
}
const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many
SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason FROM workspace_builds WHERE created_at > $1
SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost FROM workspace_builds WHERE created_at > $1
`
func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) {
@ -5477,6 +5548,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5508,7 +5580,7 @@ INSERT INTO
reason
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
`
type InsertWorkspaceBuildParams struct {
@ -5555,6 +5627,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
@ -5567,7 +5640,7 @@ SET
provisioner_state = $3,
deadline = $4
WHERE
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
`
type UpdateWorkspaceBuildByIDParams struct {
@ -5598,13 +5671,49 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const updateWorkspaceBuildCostByID = `-- name: UpdateWorkspaceBuildCostByID :one
UPDATE
workspace_builds
SET
daily_cost = $2
WHERE
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
`
type UpdateWorkspaceBuildCostByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
DailyCost int32 `db:"daily_cost" json:"daily_cost"`
}
func (q *sqlQuerier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error) {
row := q.db.QueryRowContext(ctx, updateWorkspaceBuildCostByID, arg.ID, arg.DailyCost)
var i WorkspaceBuild
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.WorkspaceID,
&i.TemplateVersionID,
&i.BuildNumber,
&i.Transition,
&i.InitiatorID,
&i.ProvisionerState,
&i.JobID,
&i.Deadline,
&i.Reason,
&i.DailyCost,
)
return i, err
}
const getWorkspaceResourceByID = `-- name: GetWorkspaceResourceByID :one
SELECT
id, created_at, job_id, transition, type, name, hide, icon, instance_type
id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost
FROM
workspace_resources
WHERE
@ -5624,6 +5733,7 @@ func (q *sqlQuerier) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID)
&i.Hide,
&i.Icon,
&i.InstanceType,
&i.DailyCost,
)
return i, err
}
@ -5738,7 +5848,7 @@ func (q *sqlQuerier) GetWorkspaceResourceMetadataCreatedAfter(ctx context.Contex
const getWorkspaceResourcesByJobID = `-- name: GetWorkspaceResourcesByJobID :many
SELECT
id, created_at, job_id, transition, type, name, hide, icon, instance_type
id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost
FROM
workspace_resources
WHERE
@ -5764,6 +5874,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui
&i.Hide,
&i.Icon,
&i.InstanceType,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5780,7 +5891,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui
const getWorkspaceResourcesByJobIDs = `-- name: GetWorkspaceResourcesByJobIDs :many
SELECT
id, created_at, job_id, transition, type, name, hide, icon, instance_type
id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost
FROM
workspace_resources
WHERE
@ -5806,6 +5917,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uu
&i.Hide,
&i.Icon,
&i.InstanceType,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5821,7 +5933,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uu
}
const getWorkspaceResourcesCreatedAfter = `-- name: GetWorkspaceResourcesCreatedAfter :many
SELECT id, created_at, job_id, transition, type, name, hide, icon, instance_type FROM workspace_resources WHERE created_at > $1
SELECT id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost FROM workspace_resources WHERE created_at > $1
`
func (q *sqlQuerier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) {
@ -5843,6 +5955,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, crea
&i.Hide,
&i.Icon,
&i.InstanceType,
&i.DailyCost,
); err != nil {
return nil, err
}
@ -5859,9 +5972,9 @@ func (q *sqlQuerier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, crea
const insertWorkspaceResource = `-- name: InsertWorkspaceResource :one
INSERT INTO
workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, instance_type)
workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, job_id, transition, type, name, hide, icon, instance_type
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost
`
type InsertWorkspaceResourceParams struct {
@ -5874,6 +5987,7 @@ type InsertWorkspaceResourceParams struct {
Hide bool `db:"hide" json:"hide"`
Icon string `db:"icon" json:"icon"`
InstanceType sql.NullString `db:"instance_type" json:"instance_type"`
DailyCost int32 `db:"daily_cost" json:"daily_cost"`
}
func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) {
@ -5887,6 +6001,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
arg.Hide,
arg.Icon,
arg.InstanceType,
arg.DailyCost,
)
var i WorkspaceResource
err := row.Scan(
@ -5899,6 +6014,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
&i.Hide,
&i.Icon,
&i.InstanceType,
&i.DailyCost,
)
return i, err
}

View File

@ -75,10 +75,11 @@ INSERT INTO groups (
id,
name,
organization_id,
avatar_url
avatar_url,
quota_allowance
)
VALUES
( $1, $2, $3, $4) RETURNING *;
( $1, $2, $3, $4, $5) RETURNING *;
-- We use the organization_id as the id
-- for simplicity since all users is
@ -97,9 +98,10 @@ UPDATE
groups
SET
name = $1,
avatar_url = $2
avatar_url = $2,
quota_allowance = $3
WHERE
id = $3
id = $4
RETURNING *;
-- name: InsertGroupMember :exec

View File

@ -0,0 +1,30 @@
-- name: GetQuotaAllowanceForUser :one
SELECT
coalesce(SUM(quota_allowance), 0)::BIGINT
FROM
group_members gm
JOIN groups g ON
g.id = gm.group_id
WHERE
user_id = $1;
-- name: GetQuotaConsumedForUser :one
WITH latest_builds AS (
SELECT
DISTINCT ON
(workspace_id) id,
workspace_id,
daily_cost
FROM
workspace_builds wb
ORDER BY
workspace_id,
created_at DESC
)
SELECT
coalesce(SUM(daily_cost), 0)::BIGINT
FROM
workspaces
JOIN latest_builds ON
latest_builds.workspace_id = workspaces.id
WHERE NOT deleted AND workspaces.owner_id = $1;

View File

@ -133,3 +133,12 @@ SET
deadline = $4
WHERE
id = $1 RETURNING *;
-- name: UpdateWorkspaceBuildCostByID :one
UPDATE
workspace_builds
SET
daily_cost = $2
WHERE
id = $1 RETURNING *;

View File

@ -27,9 +27,9 @@ SELECT * FROM workspace_resources WHERE created_at > $1;
-- name: InsertWorkspaceResource :one
INSERT INTO
workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, instance_type)
workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, instance_type, daily_cost)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *;
-- name: GetWorkspaceResourceMetadataByResourceID :many
SELECT

View File

@ -88,7 +88,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err)
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error inserting organization member.",

View File

@ -86,6 +86,7 @@ func (api *API) ListenProvisionerDaemon(ctx context.Context, acquireJobDebounce
Telemetry: api.Telemetry,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
AcquireJobDebounce: acquireJobDebounce,
QuotaCommitter: &api.QuotaCommitter,
})
if err != nil {
return nil, err

View File

@ -9,6 +9,7 @@ import (
"net/url"
"reflect"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@ -34,13 +35,14 @@ var (
)
type Server struct {
AccessURL *url.URL
ID uuid.UUID
Logger slog.Logger
Provisioners []database.ProvisionerType
Database database.Store
Pubsub database.Pubsub
Telemetry telemetry.Reporter
AccessURL *url.URL
ID uuid.UUID
Logger slog.Logger
Provisioners []database.ProvisionerType
Database database.Store
Pubsub database.Pubsub
Telemetry telemetry.Reporter
QuotaCommitter *atomic.Pointer[proto.QuotaCommitter]
AcquireJobDebounce time.Duration
}
@ -252,6 +254,35 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac
return protoJob, err
}
func (server *Server) CommitQuota(ctx context.Context, request *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error) {
jobID, err := uuid.Parse(request.JobId)
if err != nil {
return nil, xerrors.Errorf("parse job id: %w", err)
}
job, err := server.Database.GetProvisionerJobByID(ctx, jobID)
if err != nil {
return nil, xerrors.Errorf("get job: %w", err)
}
if !job.WorkerID.Valid {
return nil, xerrors.New("job isn't running yet")
}
if job.WorkerID.UUID.String() != server.ID.String() {
return nil, xerrors.New("you don't own this job")
}
q := server.QuotaCommitter.Load()
if q == nil {
// We're probably in community edition or a test.
return &proto.CommitQuotaResponse{
Budget: -1,
Ok: true,
}, nil
}
return (*q).CommitQuota(ctx, request)
}
func (server *Server) UpdateJob(ctx context.Context, request *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
parsedID, err := uuid.Parse(request.JobId)
if err != nil {
@ -620,7 +651,7 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
}
return nil
})
}, nil)
if err != nil {
return nil, xerrors.Errorf("complete job: %w", err)
}
@ -690,6 +721,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
Name: protoResource.Name,
Hide: protoResource.Hide,
Icon: protoResource.Icon,
DailyCost: protoResource.DailyCost,
InstanceType: sql.NullString{
String: protoResource.InstanceType,
Valid: protoResource.InstanceType != "",

View File

@ -745,8 +745,9 @@ func TestInsertWorkspaceResource(t *testing.T) {
db := databasefake.New()
job := uuid.New()
err := insert(db, job, &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Name: "something",
Type: "aws_instance",
DailyCost: 10,
Agents: []*sdkproto.Agent{{
Name: "dev",
Env: map[string]string{
@ -767,6 +768,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
resources, err := db.GetWorkspaceResourcesByJobID(ctx, job)
require.NoError(t, err)
require.Len(t, resources, 1)
require.EqualValues(t, 10, resources[0].DailyCost)
agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID})
require.NoError(t, err)
require.Len(t, agents, 1)

View File

@ -290,7 +290,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
template = api.convertTemplate(dbTemplate, 0, createdByNameMap[dbTemplate.ID.String()])
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error inserting template.",
@ -511,7 +511,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
}
return nil
})
}, nil)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@ -690,7 +690,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO
}
return nil
})
}, nil)
return template, err
}

View File

@ -538,7 +538,7 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque
}
return nil
})
}, nil)
if err != nil {
return
}
@ -651,7 +651,7 @@ func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Reque
return xerrors.Errorf("update active version: %w", err)
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating active template version.",
@ -852,7 +852,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
return xerrors.Errorf("insert template version: %w", err)
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: err.Error(),

View File

@ -520,7 +520,7 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook
}
return nil
})
}, nil)
if err != nil {
return nil, xerrors.Errorf("in tx: %w", err)
}

View File

@ -700,7 +700,7 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) {
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user's password.",
@ -1147,7 +1147,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
return xerrors.Errorf("create organization member: %w", err)
}
return nil
})
}, nil)
}
func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User {

View File

@ -136,7 +136,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
}
return nil
})
}, nil)
if err != nil {
return
}
@ -536,7 +536,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error inserting workspace build.",
@ -931,6 +931,7 @@ func (api *API) convertWorkspaceBuild(
Reason: codersdk.BuildReason(build.Reason),
Resources: apiResources,
Status: convertWorkspaceStatus(apiJob.Status, transition),
DailyCost: build.DailyCost,
}, nil
}
@ -974,6 +975,7 @@ func convertWorkspaceResource(resource database.WorkspaceResource, agents []code
Icon: resource.Icon,
Agents: agents,
Metadata: convertedMetadata,
DailyCost: resource.DailyCost,
}
}

View File

@ -1,19 +0,0 @@
package workspacequota
type Enforcer interface {
UserWorkspaceLimit() int
CanCreateWorkspace(count int) bool
}
type nop struct{}
func NewNop() Enforcer {
return &nop{}
}
func (*nop) UserWorkspaceLimit() int {
return 0
}
func (*nop) CanCreateWorkspace(_ int) bool {
return true
}

View File

@ -342,25 +342,6 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
workspaceCount, err := api.Database.GetWorkspaceCountByUserID(ctx, user.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace count.",
Detail: err.Error(),
})
return
}
// make sure the user has not hit their quota limit
e := *api.WorkspaceQuotaEnforcer.Load()
canCreate := e.CanCreateWorkspace(int(workspaceCount))
if !canCreate {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("User workspace limit of %d is already reached.", e.UserWorkspaceLimit()),
})
return
}
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@ -479,7 +460,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return xerrors.Errorf("insert workspace build: %w", err)
}
return nil
})
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating workspace.",
@ -710,7 +691,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
}
return nil
})
}, nil)
if err != nil {
resp := codersdk.Response{
Message: "Error updating workspace time until shutdown.",
@ -807,7 +788,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
resp.Message = "Deadline updated to " + newDeadline.Format(time.RFC3339) + "."
return nil
})
}, nil)
if err != nil {
api.Logger.Info(ctx, "extending workspace", slog.Error(err))
}

View File

@ -37,7 +37,6 @@ type DeploymentConfig struct {
AuditLogging *DeploymentConfigField[bool] `json:"audit_logging" typescript:",notnull"`
BrowserOnly *DeploymentConfigField[bool] `json:"browser_only" typescript:",notnull"`
SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"`
UserWorkspaceQuota *DeploymentConfigField[int] `json:"user_workspace_quota" typescript:",notnull"`
Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"`
APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"`
Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"`

View File

@ -19,7 +19,6 @@ const (
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
FeatureWorkspaceQuota = "workspace_quota"
FeatureTemplateRBAC = "template_rbac"
FeatureHighAvailability = "high_availability"
FeatureMultipleGitAuth = "multiple_git_auth"
@ -30,7 +29,6 @@ var FeatureNames = []string{
FeatureAuditLog,
FeatureBrowserOnly,
FeatureSCIM,
FeatureWorkspaceQuota,
FeatureTemplateRBAC,
FeatureHighAvailability,
FeatureMultipleGitAuth,

View File

@ -11,8 +11,9 @@ import (
)
type CreateGroupRequest struct {
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
QuotaAllowance int `json:"quota_allowance"`
}
type Group struct {
@ -21,6 +22,7 @@ type Group struct {
OrganizationID uuid.UUID `json:"organization_id"`
Members []User `json:"members"`
AvatarURL string `json:"avatar_url"`
QuotaAllowance int `json:"quota_allowance"`
}
func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) {
@ -93,10 +95,11 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) {
}
type PatchGroupRequest struct {
AddUsers []string `json:"add_users"`
RemoveUsers []string `json:"remove_users"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
AddUsers []string `json:"add_users"`
RemoveUsers []string `json:"remove_users"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
QuotaAllowance *int `json:"quota_allowance"`
}
func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupRequest) (Group, error) {

View File

@ -8,8 +8,8 @@ import (
)
type WorkspaceQuota struct {
UserWorkspaceCount int `json:"user_workspace_count"`
UserWorkspaceLimit int `json:"user_workspace_limit"`
CreditsConsumed int `json:"credits_consumed"`
Budget int `json:"budget"`
}
func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) {

View File

@ -68,6 +68,7 @@ type WorkspaceBuild struct {
Resources []WorkspaceResource `json:"resources"`
Deadline NullTime `json:"deadline,omitempty"`
Status WorkspaceStatus `json:"status"`
DailyCost int32 `json:"daily_cost"`
}
type WorkspaceResource struct {
@ -81,6 +82,7 @@ type WorkspaceResource struct {
Icon string `json:"icon"`
Agents []WorkspaceAgent `json:"agents,omitempty"`
Metadata []WorkspaceResourceMetadata `json:"metadata,omitempty"`
DailyCost int32 `json:"daily_cost"`
}
type WorkspaceResourceMetadata struct {

View File

@ -1,23 +1,95 @@
# Quotas
Coder Enterprise admins may define deployment-level quotas to protect against
Denial-of-Service, control costs, and ensure equitable access to cloud resources.
Coder Enterprise admins may define quotas to control costs
and ensure equitable access to cloud resources. The quota system controls
instantaneous cost. For example, the system can ensure that every user in your
deployment has a spend rate lower than $10/day at any given moment.
The quota is enabled by either the `CODER_USER_WORKSPACE_QUOTA`
environment variable or the `--user-workspace-quota` flag. For example,
you may limit each user in a deployment to 5 workspaces like so:
The workspace provisioner enforces quota during workspace start and stop operations.
When users reach their quota, they may unblock themselves by stopping or deleting
their workspace(s).
```bash
coder server --user-workspace-quota=5
Quotas are licensed with [Groups](./groups.md).
## Definitions
- **Credits** is the fundamental unit of the quota system. They map to the
smallest denomination of your preferred currency. For example, if you work with USD,
think of each credit as a cent.
- **Budget** is the per-user, enforced, upper limit to credit spend.
- **Allowance** is a grant of credits to the budget.
## Establishing Costs
Templates describe their cost through the `daily_cost` attribute in
[`resource_metadata`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata).
Since costs are associated with resources, an offline workspace may consume
less quota than an online workspace.
A common use case is separating costs for a persistent volume and ephemeral compute:
```hcl
resource "coder_metadata" "volume" {
resource_id = "${docker_volume.home_volume.id}"
cost = 10
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root"
}
resource "coder_metadata" "container" {
resource_id = "${docker_container.workspace.id}"
cost = 20
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/code-server:latest"
...
volumes {
container_path = "/home/coder/"
volume_name = docker_volume.home_volume.name
read_only = false
}
}
```
Then, when users create workspaces they would see:
In that template, the workspace consumes 10 quota credits when it's offline, and
30 when it's online.
<img src="../images/admin/quotas.png"/>
## Establishing Budgets
## Enabling this feature
Each group has a configurable Quota Allowance. A user's budget is calculated as
the sum of their allowances.
This feature is only available with an enterprise license. [Learn more](../enterprise.md)
![group-settings](../images/admin/quota-groups.png)
For example:
| Group Name | Quota Allowance |
| ---------- | --------------- |
| Frontend | 100 |
| Backend | 200 |
| Data | 300 |
<br/>
| Username | Groups | Effective Budget |
| -------- | ----------------- | ---------------- |
| jill | Frontend, Backend | 300 |
| jack | Backend, Data | 500 |
| sam | Data | 300 |
| alex | Frontend | 100 |
## Quota Enforcement
Coder enforces Quota on workspace start and stop operations. The workspace
build process dynamically calculates costs, so quota violation fails builds
as opposed to failing the build-triggering operation. For example, the Workspace
Create Form will never get held up by quota enforcement.
![build-log](../images/admin/quota-buildlog.png)
## Up next

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 791 KiB

View File

@ -109,6 +109,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"name": ActionTrack,
"organization_id": ActionIgnore, // Never changes.
"avatar_url": ActionTrack,
"quota_allowance": ActionTrack,
},
// We don't show any diff for the WorkspaceBuild resource,
// save for the template_version_id
@ -125,6 +126,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"job_id": ActionIgnore,
"deadline": ActionIgnore,
"reason": ActionIgnore,
"daily_cost": ActionIgnore,
},
})

View File

@ -61,7 +61,6 @@ func server() *cobra.Command {
AuditLogging: options.DeploymentConfig.AuditLogging.Value,
BrowserOnly: options.DeploymentConfig.BrowserOnly.Value,
SCIMAPIKey: []byte(options.DeploymentConfig.SCIMAPIKey.Value),
UserWorkspaceQuota: options.DeploymentConfig.UserWorkspaceQuota.Value,
RBAC: true,
DERPServerRelayAddress: options.DeploymentConfig.DERP.Server.RelayURL.Value,
DERPServerRegionID: options.DeploymentConfig.DERP.Server.RegionID.Value,

View File

@ -20,12 +20,12 @@ import (
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/workspacequota"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/enterprise/derpmesh"
"github.com/coder/coder/enterprise/replicasync"
"github.com/coder/coder/enterprise/tailnet"
"github.com/coder/coder/provisionerd/proto"
agpltailnet "github.com/coder/coder/tailnet"
)
@ -113,7 +113,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
})
r.Route("/workspace-quota", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Use(
apiKeyMiddleware,
)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database, false))
r.Get("/", api.workspaceQuota)
@ -183,9 +185,8 @@ type Options struct {
RBAC bool
AuditLogging bool
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
UserWorkspaceQuota int
BrowserOnly bool
SCIMAPIKey []byte
// Used for high availability.
DERPServerRelayAddress string
@ -224,7 +225,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureAuditLog: api.AuditLogging,
codersdk.FeatureBrowserOnly: api.BrowserOnly,
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0,
codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "",
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
codersdk.FeatureTemplateRBAC: api.RBAC,
@ -262,12 +262,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
}
if changed, enabled := featureChanged(codersdk.FeatureWorkspaceQuota); changed {
enforcer := workspacequota.NewNop()
if changed, enabled := featureChanged(codersdk.FeatureTemplateRBAC); changed {
if enabled {
enforcer = NewEnforcer(api.Options.UserWorkspaceQuota)
committer := committer{Database: api.Database}
ptr := proto.QuotaCommitter(&committer)
api.AGPL.QuotaCommitter.Store(&ptr)
} else {
api.AGPL.QuotaCommitter.Store(nil)
}
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
}
if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed {

View File

@ -70,7 +70,6 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
SCIMAPIKey: options.SCIMAPIKey,
DERPServerRelayAddress: oop.AccessURL.String(),
DERPServerRegionID: oop.DERPMap.RegionIDs()[0],
UserWorkspaceQuota: options.UserWorkspaceQuota,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Keys: Keys,
@ -110,7 +109,6 @@ type LicenseOptions struct {
AuditLog bool
BrowserOnly bool
SCIM bool
WorkspaceQuota bool
TemplateRBAC bool
HighAvailability bool
MultipleGitAuth bool
@ -145,10 +143,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.SCIM {
scim = 1
}
var workspaceQuota int64
if options.WorkspaceQuota {
workspaceQuota = 1
}
highAvailability := int64(0)
if options.HighAvailability {
highAvailability = 1
@ -182,7 +176,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
WorkspaceQuota: workspaceQuota,
HighAvailability: highAvailability,
TemplateRBAC: rbacEnabled,
MultipleGitAuth: multipleGitAuth,

View File

@ -53,6 +53,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request)
Name: req.Name,
OrganizationID: org.ID,
AvatarURL: req.AvatarURL,
QuotaAllowance: int32(req.QuotaAllowance),
})
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
@ -155,19 +156,25 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("get group by ID: %w", err)
}
// TODO: Do we care about validating this?
if req.AvatarURL != nil {
group.AvatarURL = *req.AvatarURL
}
if req.Name != "" {
group.Name = req.Name
updateGroupParams := database.UpdateGroupByIDParams{
ID: group.ID,
AvatarURL: group.AvatarURL,
Name: group.Name,
QuotaAllowance: group.QuotaAllowance,
}
group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{
ID: group.ID,
Name: group.Name,
AvatarURL: group.AvatarURL,
})
// TODO: Do we care about validating this?
if req.AvatarURL != nil {
updateGroupParams.AvatarURL = *req.AvatarURL
}
if req.Name != "" {
updateGroupParams.Name = req.Name
}
if req.QuotaAllowance != nil {
updateGroupParams.QuotaAllowance = int32(*req.QuotaAllowance)
}
group, err = tx.UpdateGroupByID(ctx, updateGroupParams)
if err != nil {
return xerrors.Errorf("update group by ID: %w", err)
}
@ -188,7 +195,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
}
}
return nil
})
}, nil)
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Cannot add the same user to a group twice!",
@ -327,6 +334,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group {
Name: g.Name,
OrganizationID: g.OrganizationID,
AvatarURL: g.AvatarURL,
QuotaAllowance: int(g.QuotaAllowance),
Members: convertUsers(users, orgs),
}
}

View File

@ -129,18 +129,22 @@ func TestPatchGroup(t *testing.T) {
})
ctx, _ := testutil.Context(t)
group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "hi",
AvatarURL: "https://example.com",
Name: "hi",
AvatarURL: "https://example.com",
QuotaAllowance: 10,
})
require.NoError(t, err)
require.Equal(t, 10, group.QuotaAllowance)
group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
Name: "bye",
AvatarURL: pointer.String("https://google.com"),
Name: "bye",
AvatarURL: pointer.String("https://google.com"),
QuotaAllowance: pointer.Int(20),
})
require.NoError(t, err)
require.Equal(t, "bye", group.Name)
require.Equal(t, "https://google.com", group.AvatarURL)
require.Equal(t, 20, group.QuotaAllowance)
})
// The FE sends a request from the edit page where the old name == new name.

View File

@ -99,12 +99,6 @@ func Entitlements(
Enabled: enablements[codersdk.FeatureSCIM],
}
}
if claims.Features.WorkspaceQuota > 0 {
entitlements.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{
Entitlement: entitlement,
Enabled: enablements[codersdk.FeatureWorkspaceQuota],
}
}
if claims.Features.HighAvailability > 0 {
entitlements.Features[codersdk.FeatureHighAvailability] = codersdk.Feature{
Entitlement: entitlement,
@ -248,7 +242,6 @@ type Features struct {
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
WorkspaceQuota int64 `json:"workspace_quota"`
TemplateRBAC int64 `json:"template_rbac"`
HighAvailability int64 `json:"high_availability"`
MultipleGitAuth int64 `json:"multiple_git_auth"`

View File

@ -23,7 +23,6 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureAuditLog: true,
codersdk.FeatureBrowserOnly: true,
codersdk.FeatureSCIM: true,
codersdk.FeatureWorkspaceQuota: true,
codersdk.FeatureHighAvailability: true,
codersdk.FeatureTemplateRBAC: true,
codersdk.FeatureMultipleGitAuth: true,
@ -66,7 +65,6 @@ func TestEntitlements(t *testing.T) {
AuditLog: true,
BrowserOnly: true,
SCIM: true,
WorkspaceQuota: true,
HighAvailability: true,
TemplateRBAC: true,
MultipleGitAuth: true,
@ -90,7 +88,6 @@ func TestEntitlements(t *testing.T) {
AuditLog: true,
BrowserOnly: true,
SCIM: true,
WorkspaceQuota: true,
HighAvailability: true,
TemplateRBAC: true,
GraceAt: time.Now().Add(-time.Hour),

View File

@ -105,7 +105,6 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
codersdk.FeatureHighAvailability: json.Number("0"),
codersdk.FeatureTemplateRBAC: json.Number("1"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
@ -118,7 +117,6 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
codersdk.FeatureHighAvailability: json.Number("0"),
codersdk.FeatureTemplateRBAC: json.Number("0"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),

View File

@ -171,7 +171,7 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("update template ACL by ID: %w", err)
}
return nil
})
}, nil)
if err != nil {
httpapi.InternalServerError(rw, err)
return

View File

@ -1,36 +1,102 @@
package coderd
import (
"context"
"database/sql"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/workspacequota"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
)
type enforcer struct {
userWorkspaceLimit int
type committer struct {
Database database.Store
}
func NewEnforcer(userWorkspaceLimit int) workspacequota.Enforcer {
return &enforcer{
userWorkspaceLimit: userWorkspaceLimit,
}
}
func (e *enforcer) UserWorkspaceLimit() int {
return e.userWorkspaceLimit
}
func (e *enforcer) CanCreateWorkspace(count int) bool {
if e.userWorkspaceLimit == 0 {
return true
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
}
return count < e.userWorkspaceLimit
build, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID)
if err != nil {
return nil, err
}
workspace, err := c.Database.GetWorkspaceByID(ctx, build.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
previousBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
WorkspaceID: workspace.ID,
BuildNumber: build.BuildNumber - 1,
})
if err == nil {
if build.DailyCost < previousBuild.DailyCost {
netIncrease = false
}
} else if !xerrors.Is(err, sql.ErrNoRows) {
return err
}
newConsumed := int64(request.DailyCost) + consumed
if newConsumed > budget && netIncrease {
return nil
}
_, err = s.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{
ID: build.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
}
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
@ -41,20 +107,35 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
return
}
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
OwnerID: user.ID,
})
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: "Internal error fetching workspaces.",
Message: "Failed to get consumed",
Detail: err.Error(),
})
return
}
e := *api.AGPL.WorkspaceQuotaEnforcer.Load()
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{
UserWorkspaceCount: len(workspaces),
UserWorkspaceLimit: e.UserWorkspaceLimit(),
CreditsConsumed: int(quotaConsumed),
Budget: int(quotaAllowance),
})
}

View File

@ -3,13 +3,11 @@ package coderd_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/provisioner/echo"
@ -17,49 +15,22 @@ import (
"github.com/coder/coder/testutil"
)
func TestWorkspaceQuota(t *testing.T) {
t.Parallel()
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdenttest.New(t, &coderdenttest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
WorkspaceQuota: true,
})
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceLimit, 0)
})
t.Run("Enabled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
max := 3
client := coderdenttest.New(t, &coderdenttest.Options{
UserWorkspaceQuota: max,
})
user := coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
WorkspaceQuota: true,
})
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, consumed, total int) {
t.Helper()
got, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceQuota{
Budget: total,
CreditsConsumed: consumed,
}, got)
}
func TestWorkspaceQuota(t *testing.T) {
// TODO: refactor for new impl
t.Parallel()
// ensure other user IDs work too
u2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
Email: "whatever@yo.com",
Username: "haha",
Password: "laskjdnvkaj",
OrganizationID: user.OrganizationID,
})
require.NoError(t, err)
q2, err := client.WorkspaceQuota(ctx, u2.ID.String())
require.NoError(t, err)
require.EqualValues(t, q1, q2)
})
t.Run("BlocksBuild", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@ -71,14 +42,38 @@ func TestWorkspaceQuota(t *testing.T) {
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
WorkspaceQuota: true,
TemplateRBAC: true,
})
verifyQuota(ctx, t, client, 0, 0)
// Add user to two groups, granting them a total budget of 3.
group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "test-1",
QuotaAllowance: 1,
})
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceCount, 0)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "test-2",
QuotaAllowance: 2,
})
require.NoError(t, err)
_, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user.UserID.String()},
})
require.NoError(t, err)
_, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user.UserID.String()},
})
require.NoError(t, err)
verifyQuota(ctx, t, client, 0, 3)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@ -87,8 +82,9 @@ func TestWorkspaceQuota(t *testing.T) {
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Name: "example",
Type: "aws_instance",
DailyCost: 1,
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
@ -103,20 +99,45 @@ func TestWorkspaceQuota(t *testing.T) {
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_, err = client.CreateWorkspace(context.Background(), user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: "ajksdnvksjd",
AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"),
TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()),
})
require.Error(t, err)
require.ErrorContains(t, err, "User workspace limit")
// ensure count increments
q1, err = client.WorkspaceQuota(ctx, codersdk.Me)
// Spin up three workspaces fine
for i := 0; i < 3; i++ {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
verifyQuota(ctx, t, client, i+1, 3)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
}
// Next one must fail
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Consumed shouldn't bump
verifyQuota(ctx, t, client, 3, 3)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
// Delete one random workspace, then quota should recover.
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceCount, 1)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
for _, w := range workspaces.Workspaces {
if w.LatestBuild.Status != codersdk.WorkspaceStatusRunning {
continue
}
build, err := client.CreateWorkspaceBuild(ctx, w.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
verifyQuota(ctx, t, client, 2, 3)
break
}
// Next one should now succeed
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
verifyQuota(ctx, t, client, 3, 3)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
})
}

View File

@ -14,6 +14,7 @@ import (
)
func Test_Runner(t *testing.T) {
t.Skip("This test is flakey, see https://github.com/coder/coder/actions/runs/3463709674/jobs/5784335013#step:9:215")
t.Parallel()
t.Run("NoSleep", func(t *testing.T) {

View File

@ -55,6 +55,7 @@ type metadataAttributes struct {
ResourceID string `mapstructure:"resource_id"`
Hide bool `mapstructure:"hide"`
Icon string `mapstructure:"icon"`
DailyCost int32 `mapstructure:"daily_cost"`
Items []metadataItem `mapstructure:"item"`
}
@ -301,6 +302,8 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res
resourceMetadata := map[string][]*proto.Resource_Metadata{}
resourceHidden := map[string]bool{}
resourceIcon := map[string]string{}
resourceCost := map[string]int32{}
for _, resource := range tfResourceByLabel {
if resource.Type != "coder_metadata" {
continue
@ -360,6 +363,7 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res
resourceHidden[targetLabel] = attrs.Hide
resourceIcon[targetLabel] = attrs.Icon
resourceCost[targetLabel] = attrs.DailyCost
for _, item := range attrs.Items {
resourceMetadata[targetLabel] = append(resourceMetadata[targetLabel],
&proto.Resource_Metadata{
@ -389,9 +393,10 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res
Name: resource.Name,
Type: resource.Type,
Agents: agents,
Metadata: resourceMetadata[label],
Hide: resourceHidden[label],
Icon: resourceIcon[label],
Metadata: resourceMetadata[label],
DailyCost: resourceCost[label],
InstanceType: applyInstanceType(resource),
})
}

View File

@ -145,10 +145,11 @@ func TestConvertResources(t *testing.T) {
}},
// Tests fetching metadata about workspace resources.
"resource-metadata": {{
Name: "about",
Type: "null_resource",
Hide: true,
Icon: "/icon/server.svg",
Name: "about",
Type: "null_resource",
Hide: true,
Icon: "/icon/server.svg",
DailyCost: 29,
Metadata: []*proto.Resource_Metadata{{
Key: "hello",
Value: "world",

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "f7ee18b5-2baf-461b-9d82-7654c669930c",
"id": "5c92d003-112d-4eb1-8e5f-d3009aa52fcb",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "2b47c5a8-1511-46f5-9821-95510c83afb2",
"token": "fedbf404-c42d-4360-815b-5ffc34198df3",
"troubleshooting_url": null
},
"sensitive_values": {}

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "9f9cf95a-77ea-40bf-b9bc-055f4971923d",
"id": "6cc2be0d-fe90-4256-944f-482787433587",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "0d846d9b-1fa9-4ab3-962c-f249395645a7",
"token": "1927809c-5fcf-4fdd-94d7-9a619fb86d13",
"troubleshooting_url": null
},
"sensitive_values": {}

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "aac3d245-52fa-4588-9759-694017908e7c",
"id": "bcaf2577-5dfd-4083-a446-789092a7babe",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "a9f32b40-d630-47f0-80a6-5727fd729fae",
"token": "862867af-cf08-4aea-a2af-70d0014f848b",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -34,7 +34,7 @@
"provider_name": "registry.terraform.io/hashicorp/null",
"schema_version": 0,
"values": {
"id": "5577006791947779410",
"id": "8674665223082153551",
"triggers": null
},
"sensitive_values": {},
@ -50,7 +50,7 @@
"provider_name": "registry.terraform.io/hashicorp/null",
"schema_version": 0,
"values": {
"id": "8674665223082153551",
"id": "5577006791947779410",
"triggers": null
},
"sensitive_values": {},

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "327a022c-8b88-4d57-8d25-4b1dd66d817b",
"id": "30431432-7afb-4d73-8eeb-ee464a28e157",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "a0082875-2172-456c-a8fe-ae45499443af",
"token": "3ce9bbd8-0f31-4460-842b-8e9c1de9a567",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -34,8 +34,8 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"agent_id": "327a022c-8b88-4d57-8d25-4b1dd66d817b",
"id": "ddb786af-b1a5-4b22-954b-387177e17f16",
"agent_id": "30431432-7afb-4d73-8eeb-ee464a28e157",
"id": "679f9bf2-8887-4201-a5cd-e53913e8d361",
"instance_id": "example"
},
"sensitive_values": {},

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "2777eff3-2f9f-4515-8cea-0dc7dbb53bf0",
"id": "e545d734-f852-4fda-ac8f-39e3ff094e58",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "0fb4dd96-6acd-48d2-a41a-396e957cf5f6",
"token": "c2c47266-af7a-467c-9ffc-30c3270ffecb",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -39,11 +39,11 @@
"connection_timeout": 1,
"dir": null,
"env": null,
"id": "48c7e389-c6a3-4cff-8331-aec26ee42cc4",
"id": "b5e18556-d202-478f-80d9-76f34a4cb105",
"init_script": "",
"os": "darwin",
"startup_script": null,
"token": "0e9a30ca-59a4-4070-8517-0f7ebc5d1ab8",
"token": "795082f9-642a-4647-a595-6539edaa74a3",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -61,11 +61,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "f4b435ff-47a5-4fd5-8529-5ca0288eec6d",
"id": "27e1114a-bc92-4e35-ab57-1680f3b7658f",
"init_script": "",
"os": "windows",
"startup_script": null,
"token": "763e2baa-36d0-45d6-9511-08034fa752ca",
"token": "c4fc1679-eb42-4d9f-bca8-fcf9641a7256",
"troubleshooting_url": "https://coder.com/troubleshoot"
},
"sensitive_values": {}

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "8cf70a43-80fa-4f84-b651-f6ff21f7fbcc",
"id": "f911bd98-54fc-476a-aec1-df6e525630a9",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "b41f44b7-f889-4ce9-9061-4f34952103d7",
"token": "fa05ad9c-2062-4707-a27f-12364c89641e",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -34,12 +34,12 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"agent_id": "8cf70a43-80fa-4f84-b651-f6ff21f7fbcc",
"agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9",
"command": null,
"display_name": null,
"healthcheck": [],
"icon": null,
"id": "927154ef-0b53-4b90-b6de-034581c46759",
"id": "038d0f6c-90b7-465b-915a-8a9f0cf21757",
"name": null,
"relative_path": null,
"share": "owner",
@ -62,7 +62,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"agent_id": "8cf70a43-80fa-4f84-b651-f6ff21f7fbcc",
"agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9",
"command": null,
"display_name": null,
"healthcheck": [
@ -73,7 +73,7 @@
}
],
"icon": null,
"id": "e8b2c750-ac93-4126-964f-603cb06aa12e",
"id": "c00ec121-a167-4418-8c4e-2ccae0a0cd6e",
"name": null,
"relative_path": null,
"share": "owner",
@ -98,12 +98,12 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"agent_id": "8cf70a43-80fa-4f84-b651-f6ff21f7fbcc",
"agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9",
"command": null,
"display_name": null,
"healthcheck": [],
"icon": null,
"id": "a1897b85-9691-4c7b-9a10-ff87c12efc89",
"id": "e9226aa6-a1a6-42a7-8557-64620cbf3dc2",
"name": null,
"relative_path": null,
"share": "owner",

View File

@ -2,7 +2,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = "0.6.1"
version = "0.6.3"
}
}
}
@ -18,6 +18,7 @@ resource "coder_metadata" "about_info" {
resource_id = null_resource.about.id
hide = true
icon = "/icon/server.svg"
daily_cost = 29
item {
key = "hello"
value = "world"

View File

@ -1,6 +1,6 @@
{
"format_version": "1.1",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"planned_values": {
"root_module": {
"resources": [
@ -31,6 +31,7 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"daily_cost": 29,
"hide": true,
"icon": "/icon/server.svg",
"item": [
@ -125,6 +126,7 @@
],
"before": null,
"after": {
"daily_cost": 29,
"hide": true,
"icon": "/icon/server.svg",
"item": [
@ -206,7 +208,7 @@
"coder": {
"name": "coder",
"full_name": "registry.terraform.io/coder/coder",
"version_constraint": "0.6.1"
"version_constraint": "0.6.3"
},
"null": {
"name": "null",
@ -238,6 +240,9 @@
"name": "about_info",
"provider_config_key": "coder",
"expressions": {
"daily_cost": {
"constant_value": 29
},
"hide": {
"constant_value": true
},

View File

@ -1,6 +1,6 @@
{
"format_version": "1.0",
"terraform_version": "1.3.4",
"terraform_version": "1.3.3",
"values": {
"root_module": {
"resources": [
@ -17,11 +17,11 @@
"connection_timeout": 120,
"dir": null,
"env": null,
"id": "3aeed2cf-2a5a-40f7-a0d6-2c3508f601a4",
"id": "7766b2a9-c00f-4cde-9acc-1fc05651dbdf",
"init_script": "",
"os": "linux",
"startup_script": null,
"token": "c22c9b1f-b077-4ed3-afa6-e10fc5485399",
"token": "5e54c173-a813-4df0-b87d-0617082769dc",
"troubleshooting_url": null
},
"sensitive_values": {}
@ -34,9 +34,10 @@
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"daily_cost": 29,
"hide": true,
"icon": "/icon/server.svg",
"id": "1f43c366-e7a6-49dc-ac19-894fd9fceac8",
"id": "e43f1cd6-5dbb-4d6b-8942-37f914b37be5",
"item": [
{
"is_null": false,

View File

@ -667,6 +667,124 @@ func (x *UpdateJobResponse) GetParameterValues() []*proto.ParameterValue {
return nil
}
type CommitQuotaRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
DailyCost int32 `protobuf:"varint,2,opt,name=daily_cost,json=dailyCost,proto3" json:"daily_cost,omitempty"`
}
func (x *CommitQuotaRequest) Reset() {
*x = CommitQuotaRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CommitQuotaRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CommitQuotaRequest) ProtoMessage() {}
func (x *CommitQuotaRequest) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CommitQuotaRequest.ProtoReflect.Descriptor instead.
func (*CommitQuotaRequest) Descriptor() ([]byte, []int) {
return file_provisionerd_proto_provisionerd_proto_rawDescGZIP(), []int{7}
}
func (x *CommitQuotaRequest) GetJobId() string {
if x != nil {
return x.JobId
}
return ""
}
func (x *CommitQuotaRequest) GetDailyCost() int32 {
if x != nil {
return x.DailyCost
}
return 0
}
type CommitQuotaResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
CreditsConsumed int32 `protobuf:"varint,2,opt,name=credits_consumed,json=creditsConsumed,proto3" json:"credits_consumed,omitempty"`
Budget int32 `protobuf:"varint,3,opt,name=budget,proto3" json:"budget,omitempty"`
}
func (x *CommitQuotaResponse) Reset() {
*x = CommitQuotaResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CommitQuotaResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CommitQuotaResponse) ProtoMessage() {}
func (x *CommitQuotaResponse) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CommitQuotaResponse.ProtoReflect.Descriptor instead.
func (*CommitQuotaResponse) Descriptor() ([]byte, []int) {
return file_provisionerd_proto_provisionerd_proto_rawDescGZIP(), []int{8}
}
func (x *CommitQuotaResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *CommitQuotaResponse) GetCreditsConsumed() int32 {
if x != nil {
return x.CreditsConsumed
}
return 0
}
func (x *CommitQuotaResponse) GetBudget() int32 {
if x != nil {
return x.Budget
}
return 0
}
type AcquiredJob_WorkspaceBuild struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@ -682,7 +800,7 @@ type AcquiredJob_WorkspaceBuild struct {
func (x *AcquiredJob_WorkspaceBuild) Reset() {
*x = AcquiredJob_WorkspaceBuild{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[7]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -695,7 +813,7 @@ func (x *AcquiredJob_WorkspaceBuild) String() string {
func (*AcquiredJob_WorkspaceBuild) ProtoMessage() {}
func (x *AcquiredJob_WorkspaceBuild) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[7]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -757,7 +875,7 @@ type AcquiredJob_TemplateImport struct {
func (x *AcquiredJob_TemplateImport) Reset() {
*x = AcquiredJob_TemplateImport{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[8]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -770,7 +888,7 @@ func (x *AcquiredJob_TemplateImport) String() string {
func (*AcquiredJob_TemplateImport) ProtoMessage() {}
func (x *AcquiredJob_TemplateImport) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[8]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -805,7 +923,7 @@ type AcquiredJob_TemplateDryRun struct {
func (x *AcquiredJob_TemplateDryRun) Reset() {
*x = AcquiredJob_TemplateDryRun{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[9]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -818,7 +936,7 @@ func (x *AcquiredJob_TemplateDryRun) String() string {
func (*AcquiredJob_TemplateDryRun) ProtoMessage() {}
func (x *AcquiredJob_TemplateDryRun) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[9]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -859,7 +977,7 @@ type FailedJob_WorkspaceBuild struct {
func (x *FailedJob_WorkspaceBuild) Reset() {
*x = FailedJob_WorkspaceBuild{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[10]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -872,7 +990,7 @@ func (x *FailedJob_WorkspaceBuild) String() string {
func (*FailedJob_WorkspaceBuild) ProtoMessage() {}
func (x *FailedJob_WorkspaceBuild) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[10]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[12]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -904,7 +1022,7 @@ type FailedJob_TemplateImport struct {
func (x *FailedJob_TemplateImport) Reset() {
*x = FailedJob_TemplateImport{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[11]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -917,7 +1035,7 @@ func (x *FailedJob_TemplateImport) String() string {
func (*FailedJob_TemplateImport) ProtoMessage() {}
func (x *FailedJob_TemplateImport) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[11]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[13]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -942,7 +1060,7 @@ type FailedJob_TemplateDryRun struct {
func (x *FailedJob_TemplateDryRun) Reset() {
*x = FailedJob_TemplateDryRun{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[12]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -955,7 +1073,7 @@ func (x *FailedJob_TemplateDryRun) String() string {
func (*FailedJob_TemplateDryRun) ProtoMessage() {}
func (x *FailedJob_TemplateDryRun) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[12]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[14]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -983,7 +1101,7 @@ type CompletedJob_WorkspaceBuild struct {
func (x *CompletedJob_WorkspaceBuild) Reset() {
*x = CompletedJob_WorkspaceBuild{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[13]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -996,7 +1114,7 @@ func (x *CompletedJob_WorkspaceBuild) String() string {
func (*CompletedJob_WorkspaceBuild) ProtoMessage() {}
func (x *CompletedJob_WorkspaceBuild) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[13]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[15]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1038,7 +1156,7 @@ type CompletedJob_TemplateImport struct {
func (x *CompletedJob_TemplateImport) Reset() {
*x = CompletedJob_TemplateImport{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[14]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1051,7 +1169,7 @@ func (x *CompletedJob_TemplateImport) String() string {
func (*CompletedJob_TemplateImport) ProtoMessage() {}
func (x *CompletedJob_TemplateImport) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[14]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[16]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1092,7 +1210,7 @@ type CompletedJob_TemplateDryRun struct {
func (x *CompletedJob_TemplateDryRun) Reset() {
*x = CompletedJob_TemplateDryRun{}
if protoimpl.UnsafeEnabled {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[15]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1105,7 +1223,7 @@ func (x *CompletedJob_TemplateDryRun) String() string {
func (*CompletedJob_TemplateDryRun) ProtoMessage() {}
func (x *CompletedJob_TemplateDryRun) ProtoReflect() protoreflect.Message {
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[15]
mi := &file_provisionerd_proto_provisionerd_proto_msgTypes[17]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1289,31 +1407,48 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{
0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74,
0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74,
0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x53,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49,
0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a,
0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x32, 0x98,
0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x44, 0x61,
0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x4a,
0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73,
0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a,
0x6f, 0x62, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12,
0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65,
0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69,
0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x4a, 0x0a, 0x12, 0x43, 0x6f, 0x6d, 0x6d,
0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15,
0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63,
0x6f, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79,
0x43, 0x6f, 0x73, 0x74, 0x22, 0x68, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75,
0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f,
0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x29, 0x0a, 0x10, 0x63,
0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x43, 0x6f,
0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74,
0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x2a, 0x34,
0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50,
0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f,
0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e,
0x45, 0x52, 0x10, 0x01, 0x32, 0xec, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x65, 0x72, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63,
0x71, 0x75, 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69,
0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e,
0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71,
0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x52, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d,
0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73,
0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f,
0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51,
0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a,
0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a,
0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61,
0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f,
0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13,
0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a,
0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13,
0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,
0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -1329,7 +1464,7 @@ func file_provisionerd_proto_provisionerd_proto_rawDescGZIP() []byte {
}
var file_provisionerd_proto_provisionerd_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_provisionerd_proto_provisionerd_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_provisionerd_proto_provisionerd_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
var file_provisionerd_proto_provisionerd_proto_goTypes = []interface{}{
(LogSource)(0), // 0: provisionerd.LogSource
(*Empty)(nil), // 1: provisionerd.Empty
@ -1339,55 +1474,59 @@ var file_provisionerd_proto_provisionerd_proto_goTypes = []interface{}{
(*Log)(nil), // 5: provisionerd.Log
(*UpdateJobRequest)(nil), // 6: provisionerd.UpdateJobRequest
(*UpdateJobResponse)(nil), // 7: provisionerd.UpdateJobResponse
(*AcquiredJob_WorkspaceBuild)(nil), // 8: provisionerd.AcquiredJob.WorkspaceBuild
(*AcquiredJob_TemplateImport)(nil), // 9: provisionerd.AcquiredJob.TemplateImport
(*AcquiredJob_TemplateDryRun)(nil), // 10: provisionerd.AcquiredJob.TemplateDryRun
(*FailedJob_WorkspaceBuild)(nil), // 11: provisionerd.FailedJob.WorkspaceBuild
(*FailedJob_TemplateImport)(nil), // 12: provisionerd.FailedJob.TemplateImport
(*FailedJob_TemplateDryRun)(nil), // 13: provisionerd.FailedJob.TemplateDryRun
(*CompletedJob_WorkspaceBuild)(nil), // 14: provisionerd.CompletedJob.WorkspaceBuild
(*CompletedJob_TemplateImport)(nil), // 15: provisionerd.CompletedJob.TemplateImport
(*CompletedJob_TemplateDryRun)(nil), // 16: provisionerd.CompletedJob.TemplateDryRun
(proto.LogLevel)(0), // 17: provisioner.LogLevel
(*proto.ParameterSchema)(nil), // 18: provisioner.ParameterSchema
(*proto.ParameterValue)(nil), // 19: provisioner.ParameterValue
(*proto.Provision_Metadata)(nil), // 20: provisioner.Provision.Metadata
(*proto.Resource)(nil), // 21: provisioner.Resource
(*CommitQuotaRequest)(nil), // 8: provisionerd.CommitQuotaRequest
(*CommitQuotaResponse)(nil), // 9: provisionerd.CommitQuotaResponse
(*AcquiredJob_WorkspaceBuild)(nil), // 10: provisionerd.AcquiredJob.WorkspaceBuild
(*AcquiredJob_TemplateImport)(nil), // 11: provisionerd.AcquiredJob.TemplateImport
(*AcquiredJob_TemplateDryRun)(nil), // 12: provisionerd.AcquiredJob.TemplateDryRun
(*FailedJob_WorkspaceBuild)(nil), // 13: provisionerd.FailedJob.WorkspaceBuild
(*FailedJob_TemplateImport)(nil), // 14: provisionerd.FailedJob.TemplateImport
(*FailedJob_TemplateDryRun)(nil), // 15: provisionerd.FailedJob.TemplateDryRun
(*CompletedJob_WorkspaceBuild)(nil), // 16: provisionerd.CompletedJob.WorkspaceBuild
(*CompletedJob_TemplateImport)(nil), // 17: provisionerd.CompletedJob.TemplateImport
(*CompletedJob_TemplateDryRun)(nil), // 18: provisionerd.CompletedJob.TemplateDryRun
(proto.LogLevel)(0), // 19: provisioner.LogLevel
(*proto.ParameterSchema)(nil), // 20: provisioner.ParameterSchema
(*proto.ParameterValue)(nil), // 21: provisioner.ParameterValue
(*proto.Provision_Metadata)(nil), // 22: provisioner.Provision.Metadata
(*proto.Resource)(nil), // 23: provisioner.Resource
}
var file_provisionerd_proto_provisionerd_proto_depIdxs = []int32{
8, // 0: provisionerd.AcquiredJob.workspace_build:type_name -> provisionerd.AcquiredJob.WorkspaceBuild
9, // 1: provisionerd.AcquiredJob.template_import:type_name -> provisionerd.AcquiredJob.TemplateImport
10, // 2: provisionerd.AcquiredJob.template_dry_run:type_name -> provisionerd.AcquiredJob.TemplateDryRun
11, // 3: provisionerd.FailedJob.workspace_build:type_name -> provisionerd.FailedJob.WorkspaceBuild
12, // 4: provisionerd.FailedJob.template_import:type_name -> provisionerd.FailedJob.TemplateImport
13, // 5: provisionerd.FailedJob.template_dry_run:type_name -> provisionerd.FailedJob.TemplateDryRun
14, // 6: provisionerd.CompletedJob.workspace_build:type_name -> provisionerd.CompletedJob.WorkspaceBuild
15, // 7: provisionerd.CompletedJob.template_import:type_name -> provisionerd.CompletedJob.TemplateImport
16, // 8: provisionerd.CompletedJob.template_dry_run:type_name -> provisionerd.CompletedJob.TemplateDryRun
10, // 0: provisionerd.AcquiredJob.workspace_build:type_name -> provisionerd.AcquiredJob.WorkspaceBuild
11, // 1: provisionerd.AcquiredJob.template_import:type_name -> provisionerd.AcquiredJob.TemplateImport
12, // 2: provisionerd.AcquiredJob.template_dry_run:type_name -> provisionerd.AcquiredJob.TemplateDryRun
13, // 3: provisionerd.FailedJob.workspace_build:type_name -> provisionerd.FailedJob.WorkspaceBuild
14, // 4: provisionerd.FailedJob.template_import:type_name -> provisionerd.FailedJob.TemplateImport
15, // 5: provisionerd.FailedJob.template_dry_run:type_name -> provisionerd.FailedJob.TemplateDryRun
16, // 6: provisionerd.CompletedJob.workspace_build:type_name -> provisionerd.CompletedJob.WorkspaceBuild
17, // 7: provisionerd.CompletedJob.template_import:type_name -> provisionerd.CompletedJob.TemplateImport
18, // 8: provisionerd.CompletedJob.template_dry_run:type_name -> provisionerd.CompletedJob.TemplateDryRun
0, // 9: provisionerd.Log.source:type_name -> provisionerd.LogSource
17, // 10: provisionerd.Log.level:type_name -> provisioner.LogLevel
19, // 10: provisionerd.Log.level:type_name -> provisioner.LogLevel
5, // 11: provisionerd.UpdateJobRequest.logs:type_name -> provisionerd.Log
18, // 12: provisionerd.UpdateJobRequest.parameter_schemas:type_name -> provisioner.ParameterSchema
19, // 13: provisionerd.UpdateJobResponse.parameter_values:type_name -> provisioner.ParameterValue
19, // 14: provisionerd.AcquiredJob.WorkspaceBuild.parameter_values:type_name -> provisioner.ParameterValue
20, // 15: provisionerd.AcquiredJob.WorkspaceBuild.metadata:type_name -> provisioner.Provision.Metadata
20, // 16: provisionerd.AcquiredJob.TemplateImport.metadata:type_name -> provisioner.Provision.Metadata
19, // 17: provisionerd.AcquiredJob.TemplateDryRun.parameter_values:type_name -> provisioner.ParameterValue
20, // 18: provisionerd.AcquiredJob.TemplateDryRun.metadata:type_name -> provisioner.Provision.Metadata
21, // 19: provisionerd.CompletedJob.WorkspaceBuild.resources:type_name -> provisioner.Resource
21, // 20: provisionerd.CompletedJob.TemplateImport.start_resources:type_name -> provisioner.Resource
21, // 21: provisionerd.CompletedJob.TemplateImport.stop_resources:type_name -> provisioner.Resource
21, // 22: provisionerd.CompletedJob.TemplateDryRun.resources:type_name -> provisioner.Resource
20, // 12: provisionerd.UpdateJobRequest.parameter_schemas:type_name -> provisioner.ParameterSchema
21, // 13: provisionerd.UpdateJobResponse.parameter_values:type_name -> provisioner.ParameterValue
21, // 14: provisionerd.AcquiredJob.WorkspaceBuild.parameter_values:type_name -> provisioner.ParameterValue
22, // 15: provisionerd.AcquiredJob.WorkspaceBuild.metadata:type_name -> provisioner.Provision.Metadata
22, // 16: provisionerd.AcquiredJob.TemplateImport.metadata:type_name -> provisioner.Provision.Metadata
21, // 17: provisionerd.AcquiredJob.TemplateDryRun.parameter_values:type_name -> provisioner.ParameterValue
22, // 18: provisionerd.AcquiredJob.TemplateDryRun.metadata:type_name -> provisioner.Provision.Metadata
23, // 19: provisionerd.CompletedJob.WorkspaceBuild.resources:type_name -> provisioner.Resource
23, // 20: provisionerd.CompletedJob.TemplateImport.start_resources:type_name -> provisioner.Resource
23, // 21: provisionerd.CompletedJob.TemplateImport.stop_resources:type_name -> provisioner.Resource
23, // 22: provisionerd.CompletedJob.TemplateDryRun.resources:type_name -> provisioner.Resource
1, // 23: provisionerd.ProvisionerDaemon.AcquireJob:input_type -> provisionerd.Empty
6, // 24: provisionerd.ProvisionerDaemon.UpdateJob:input_type -> provisionerd.UpdateJobRequest
3, // 25: provisionerd.ProvisionerDaemon.FailJob:input_type -> provisionerd.FailedJob
4, // 26: provisionerd.ProvisionerDaemon.CompleteJob:input_type -> provisionerd.CompletedJob
2, // 27: provisionerd.ProvisionerDaemon.AcquireJob:output_type -> provisionerd.AcquiredJob
7, // 28: provisionerd.ProvisionerDaemon.UpdateJob:output_type -> provisionerd.UpdateJobResponse
1, // 29: provisionerd.ProvisionerDaemon.FailJob:output_type -> provisionerd.Empty
1, // 30: provisionerd.ProvisionerDaemon.CompleteJob:output_type -> provisionerd.Empty
27, // [27:31] is the sub-list for method output_type
23, // [23:27] is the sub-list for method input_type
8, // 24: provisionerd.ProvisionerDaemon.CommitQuota:input_type -> provisionerd.CommitQuotaRequest
6, // 25: provisionerd.ProvisionerDaemon.UpdateJob:input_type -> provisionerd.UpdateJobRequest
3, // 26: provisionerd.ProvisionerDaemon.FailJob:input_type -> provisionerd.FailedJob
4, // 27: provisionerd.ProvisionerDaemon.CompleteJob:input_type -> provisionerd.CompletedJob
2, // 28: provisionerd.ProvisionerDaemon.AcquireJob:output_type -> provisionerd.AcquiredJob
9, // 29: provisionerd.ProvisionerDaemon.CommitQuota:output_type -> provisionerd.CommitQuotaResponse
7, // 30: provisionerd.ProvisionerDaemon.UpdateJob:output_type -> provisionerd.UpdateJobResponse
1, // 31: provisionerd.ProvisionerDaemon.FailJob:output_type -> provisionerd.Empty
1, // 32: provisionerd.ProvisionerDaemon.CompleteJob:output_type -> provisionerd.Empty
28, // [28:33] is the sub-list for method output_type
23, // [23:28] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
@ -1484,7 +1623,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AcquiredJob_WorkspaceBuild); i {
switch v := v.(*CommitQuotaRequest); i {
case 0:
return &v.state
case 1:
@ -1496,7 +1635,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AcquiredJob_TemplateImport); i {
switch v := v.(*CommitQuotaResponse); i {
case 0:
return &v.state
case 1:
@ -1508,7 +1647,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*AcquiredJob_TemplateDryRun); i {
switch v := v.(*AcquiredJob_WorkspaceBuild); i {
case 0:
return &v.state
case 1:
@ -1520,7 +1659,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*FailedJob_WorkspaceBuild); i {
switch v := v.(*AcquiredJob_TemplateImport); i {
case 0:
return &v.state
case 1:
@ -1532,7 +1671,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*FailedJob_TemplateImport); i {
switch v := v.(*AcquiredJob_TemplateDryRun); i {
case 0:
return &v.state
case 1:
@ -1544,7 +1683,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*FailedJob_TemplateDryRun); i {
switch v := v.(*FailedJob_WorkspaceBuild); i {
case 0:
return &v.state
case 1:
@ -1556,7 +1695,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CompletedJob_WorkspaceBuild); i {
switch v := v.(*FailedJob_TemplateImport); i {
case 0:
return &v.state
case 1:
@ -1568,7 +1707,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CompletedJob_TemplateImport); i {
switch v := v.(*FailedJob_TemplateDryRun); i {
case 0:
return &v.state
case 1:
@ -1580,6 +1719,30 @@ func file_provisionerd_proto_provisionerd_proto_init() {
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CompletedJob_WorkspaceBuild); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CompletedJob_TemplateImport); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_provisionerd_proto_provisionerd_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CompletedJob_TemplateDryRun); i {
case 0:
return &v.state
@ -1613,7 +1776,7 @@ func file_provisionerd_proto_provisionerd_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_provisionerd_proto_provisionerd_proto_rawDesc,
NumEnums: 1,
NumMessages: 16,
NumMessages: 18,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -106,12 +106,25 @@ message UpdateJobResponse {
repeated provisioner.ParameterValue parameter_values = 2;
}
message CommitQuotaRequest {
string job_id = 1;
int32 daily_cost = 2;
}
message CommitQuotaResponse {
bool ok = 1;
int32 credits_consumed = 2;
int32 budget = 3;
}
service ProvisionerDaemon {
// AcquireJob requests a job. Implementations should
// hold a lock on the job until CompleteJob() is
// called with the matching ID.
rpc AcquireJob(Empty) returns (AcquiredJob);
rpc CommitQuota(CommitQuotaRequest) returns (CommitQuotaResponse);
// UpdateJob streams periodic updates for a job.
// Implementations should buffer logs so this stream
// is non-blocking.

View File

@ -39,6 +39,7 @@ type DRPCProvisionerDaemonClient interface {
DRPCConn() drpc.Conn
AcquireJob(ctx context.Context, in *Empty) (*AcquiredJob, error)
CommitQuota(ctx context.Context, in *CommitQuotaRequest) (*CommitQuotaResponse, error)
UpdateJob(ctx context.Context, in *UpdateJobRequest) (*UpdateJobResponse, error)
FailJob(ctx context.Context, in *FailedJob) (*Empty, error)
CompleteJob(ctx context.Context, in *CompletedJob) (*Empty, error)
@ -63,6 +64,15 @@ func (c *drpcProvisionerDaemonClient) AcquireJob(ctx context.Context, in *Empty)
return out, nil
}
func (c *drpcProvisionerDaemonClient) CommitQuota(ctx context.Context, in *CommitQuotaRequest) (*CommitQuotaResponse, error) {
out := new(CommitQuotaResponse)
err := c.cc.Invoke(ctx, "/provisionerd.ProvisionerDaemon/CommitQuota", drpcEncoding_File_provisionerd_proto_provisionerd_proto{}, in, out)
if err != nil {
return nil, err
}
return out, nil
}
func (c *drpcProvisionerDaemonClient) UpdateJob(ctx context.Context, in *UpdateJobRequest) (*UpdateJobResponse, error) {
out := new(UpdateJobResponse)
err := c.cc.Invoke(ctx, "/provisionerd.ProvisionerDaemon/UpdateJob", drpcEncoding_File_provisionerd_proto_provisionerd_proto{}, in, out)
@ -92,6 +102,7 @@ func (c *drpcProvisionerDaemonClient) CompleteJob(ctx context.Context, in *Compl
type DRPCProvisionerDaemonServer interface {
AcquireJob(context.Context, *Empty) (*AcquiredJob, error)
CommitQuota(context.Context, *CommitQuotaRequest) (*CommitQuotaResponse, error)
UpdateJob(context.Context, *UpdateJobRequest) (*UpdateJobResponse, error)
FailJob(context.Context, *FailedJob) (*Empty, error)
CompleteJob(context.Context, *CompletedJob) (*Empty, error)
@ -103,6 +114,10 @@ func (s *DRPCProvisionerDaemonUnimplementedServer) AcquireJob(context.Context, *
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
func (s *DRPCProvisionerDaemonUnimplementedServer) CommitQuota(context.Context, *CommitQuotaRequest) (*CommitQuotaResponse, error) {
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
func (s *DRPCProvisionerDaemonUnimplementedServer) UpdateJob(context.Context, *UpdateJobRequest) (*UpdateJobResponse, error) {
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
@ -117,7 +132,7 @@ func (s *DRPCProvisionerDaemonUnimplementedServer) CompleteJob(context.Context,
type DRPCProvisionerDaemonDescription struct{}
func (DRPCProvisionerDaemonDescription) NumMethods() int { return 4 }
func (DRPCProvisionerDaemonDescription) NumMethods() int { return 5 }
func (DRPCProvisionerDaemonDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
switch n {
@ -131,6 +146,15 @@ func (DRPCProvisionerDaemonDescription) Method(n int) (string, drpc.Encoding, dr
)
}, DRPCProvisionerDaemonServer.AcquireJob, true
case 1:
return "/provisionerd.ProvisionerDaemon/CommitQuota", drpcEncoding_File_provisionerd_proto_provisionerd_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCProvisionerDaemonServer).
CommitQuota(
ctx,
in1.(*CommitQuotaRequest),
)
}, DRPCProvisionerDaemonServer.CommitQuota, true
case 2:
return "/provisionerd.ProvisionerDaemon/UpdateJob", drpcEncoding_File_provisionerd_proto_provisionerd_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCProvisionerDaemonServer).
@ -139,7 +163,7 @@ func (DRPCProvisionerDaemonDescription) Method(n int) (string, drpc.Encoding, dr
in1.(*UpdateJobRequest),
)
}, DRPCProvisionerDaemonServer.UpdateJob, true
case 2:
case 3:
return "/provisionerd.ProvisionerDaemon/FailJob", drpcEncoding_File_provisionerd_proto_provisionerd_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCProvisionerDaemonServer).
@ -148,7 +172,7 @@ func (DRPCProvisionerDaemonDescription) Method(n int) (string, drpc.Encoding, dr
in1.(*FailedJob),
)
}, DRPCProvisionerDaemonServer.FailJob, true
case 3:
case 4:
return "/provisionerd.ProvisionerDaemon/CompleteJob", drpcEncoding_File_provisionerd_proto_provisionerd_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCProvisionerDaemonServer).
@ -182,6 +206,22 @@ func (x *drpcProvisionerDaemon_AcquireJobStream) SendAndClose(m *AcquiredJob) er
return x.CloseSend()
}
type DRPCProvisionerDaemon_CommitQuotaStream interface {
drpc.Stream
SendAndClose(*CommitQuotaResponse) error
}
type drpcProvisionerDaemon_CommitQuotaStream struct {
drpc.Stream
}
func (x *drpcProvisionerDaemon_CommitQuotaStream) SendAndClose(m *CommitQuotaResponse) error {
if err := x.MsgSend(m, drpcEncoding_File_provisionerd_proto_provisionerd_proto{}); err != nil {
return err
}
return x.CloseSend()
}
type DRPCProvisionerDaemon_UpdateJobStream interface {
drpc.Stream
SendAndClose(*UpdateJobResponse) error

7
provisionerd/proto/quota.go generated Normal file
View File

@ -0,0 +1,7 @@
package proto
import context "context"
type QuotaCommitter interface {
CommitQuota(ctx context.Context, request *CommitQuotaRequest) (*CommitQuotaResponse, error)
}

View File

@ -324,6 +324,7 @@ func (p *Server) acquireJob(ctx context.Context) {
job,
runner.Options{
Updater: p,
QuotaCommitter: p,
Logger: p.opts.Logger,
Filesystem: p.opts.Filesystem,
WorkDirectory: p.opts.WorkDirectory,
@ -365,6 +366,17 @@ func (p *Server) clientDoWithRetries(
return nil, ctx.Err()
}
func (p *Server) CommitQuota(ctx context.Context, in *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error) {
out, err := p.clientDoWithRetries(ctx, func(ctx context.Context, client proto.DRPCProvisionerDaemonClient) (any, error) {
return client.CommitQuota(ctx, in)
})
if err != nil {
return nil, err
}
// nolint: forcetypeassert
return out.(*proto.CommitQuotaResponse), nil
}
func (p *Server) UpdateJob(ctx context.Context, in *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
out, err := p.clientDoWithRetries(ctx, func(ctx context.Context, client proto.DRPCProvisionerDaemonClient) (any, error) {
return client.UpdateJob(ctx, in)

View File

@ -481,6 +481,97 @@ func TestProvisionerd(t *testing.T) {
require.NoError(t, closer.Close())
})
t.Run("WorkspaceBuildQuotaExceeded", func(t *testing.T) {
t.Parallel()
var (
didComplete atomic.Bool
didLog atomic.Bool
didAcquireJob atomic.Bool
didFail atomic.Bool
completeChan = make(chan struct{})
completeOnce sync.Once
)
closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) {
return createProvisionerDaemonClient(t, provisionerDaemonTestServer{
acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) {
if !didAcquireJob.CAS(false, true) {
completeOnce.Do(func() { close(completeChan) })
return &proto.AcquiredJob{}, nil
}
return &proto.AcquiredJob{
JobId: "test",
Provisioner: "someprovisioner",
TemplateSourceArchive: createTar(t, map[string]string{
"test.txt": "content",
}),
Type: &proto.AcquiredJob_WorkspaceBuild_{
WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{
Metadata: &sdkproto.Provision_Metadata{},
},
},
}, nil
},
updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
if len(update.Logs) != 0 {
didLog.Store(true)
}
return &proto.UpdateJobResponse{}, nil
},
completeJob: func(ctx context.Context, job *proto.CompletedJob) (*proto.Empty, error) {
didComplete.Store(true)
return &proto.Empty{}, nil
},
commitQuota: func(ctx context.Context, com *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error) {
return &proto.CommitQuotaResponse{
Ok: com.DailyCost < 20,
}, nil
},
failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) {
didFail.Store(true)
return &proto.Empty{}, nil
},
}), nil
}, provisionerd.Provisioners{
"someprovisioner": createProvisionerClient(t, provisionerTestServer{
provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error {
err := stream.Send(&sdkproto.Provision_Response{
Type: &sdkproto.Provision_Response_Log{
Log: &sdkproto.Log{
Level: sdkproto.LogLevel_DEBUG,
Output: "wow",
},
},
})
require.NoError(t, err)
err = stream.Send(&sdkproto.Provision_Response{
Type: &sdkproto.Provision_Response_Complete{
Complete: &sdkproto.Provision_Complete{
Resources: []*sdkproto.Resource{
{
DailyCost: 10,
},
{
DailyCost: 15,
},
},
},
},
})
require.NoError(t, err)
return nil
},
}),
})
require.Condition(t, closedWithin(completeChan, testutil.WaitShort))
require.True(t, didLog.Load())
require.True(t, didFail.Load())
require.False(t, didComplete.Load())
require.NoError(t, closer.Close())
})
t.Run("WorkspaceBuildFailComplete", func(t *testing.T) {
t.Parallel()
var (
@ -1039,6 +1130,7 @@ func (p *provisionerTestServer) Provision(stream sdkproto.DRPCProvisioner_Provis
// passable functions for dynamic functionality.
type provisionerDaemonTestServer struct {
acquireJob func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error)
commitQuota func(ctx context.Context, com *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error)
updateJob func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error)
failJob func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error)
completeJob func(ctx context.Context, job *proto.CompletedJob) (*proto.Empty, error)
@ -1047,6 +1139,14 @@ type provisionerDaemonTestServer struct {
func (p *provisionerDaemonTestServer) AcquireJob(ctx context.Context, empty *proto.Empty) (*proto.AcquiredJob, error) {
return p.acquireJob(ctx, empty)
}
func (p *provisionerDaemonTestServer) CommitQuota(ctx context.Context, com *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error) {
if p.commitQuota == nil {
return &proto.CommitQuotaResponse{
Ok: true,
}, nil
}
return p.commitQuota(ctx, com)
}
func (p *provisionerDaemonTestServer) UpdateJob(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
return p.updateJob(ctx, update)

View File

@ -0,0 +1,11 @@
package runner
import "github.com/coder/coder/provisionersdk/proto"
func sumDailyCost(resources []*proto.Resource) int {
var sum int
for _, r := range resources {
sum += int(r.DailyCost)
}
return sum
}

View File

@ -42,6 +42,7 @@ type Runner struct {
metrics Metrics
job *proto.AcquiredJob
sender JobUpdater
quotaCommitter QuotaCommitter
logger slog.Logger
filesystem afero.Fs
workDirectory string
@ -85,9 +86,13 @@ type JobUpdater interface {
FailJob(ctx context.Context, in *proto.FailedJob) error
CompleteJob(ctx context.Context, in *proto.CompletedJob) error
}
type QuotaCommitter interface {
CommitQuota(ctx context.Context, in *proto.CommitQuotaRequest) (*proto.CommitQuotaResponse, error)
}
type Options struct {
Updater JobUpdater
QuotaCommitter QuotaCommitter
Logger slog.Logger
Filesystem afero.Fs
WorkDirectory string
@ -115,6 +120,7 @@ func New(
metrics: opts.Metrics,
job: job,
sender: opts.Updater,
quotaCommitter: opts.QuotaCommitter,
logger: opts.Logger.With(slog.F("job_id", job.JobId)),
filesystem: opts.Filesystem,
workDirectory: opts.WorkDirectory,
@ -843,7 +849,7 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto
}
r.logger.Debug(context.Background(), "provision complete no error")
r.logger.Info(context.Background(), "provision successful; marked job as complete",
r.logger.Info(context.Background(), "provision successful",
slog.F("resource_count", len(msgType.Complete.Resources)),
slog.F("resources", msgType.Complete.Resources),
slog.F("state_length", len(msgType.Complete.State)),
@ -856,35 +862,81 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto
}
}
func (r *Runner) commitQuota(ctx context.Context, resources []*sdkproto.Resource) *proto.FailedJob {
cost := sumDailyCost(resources)
if cost == 0 {
return nil
}
const stage = "Commit quota"
resp, err := r.quotaCommitter.CommitQuota(ctx, &proto.CommitQuotaRequest{
JobId: r.job.JobId,
DailyCost: int32(cost),
})
if err != nil {
r.queueLog(ctx, &proto.Log{
Source: proto.LogSource_PROVISIONER,
Level: sdkproto.LogLevel_ERROR,
CreatedAt: time.Now().UnixMilli(),
Output: fmt.Sprintf("Failed to commit quota: %+v", err),
Stage: stage,
})
return r.failedJobf("commit quota: %+v", err)
}
for _, line := range []string{
fmt.Sprintf("Build cost — %v", cost),
fmt.Sprintf("Budget — %v", resp.Budget),
fmt.Sprintf("Credits consumed — %v", resp.CreditsConsumed),
} {
r.queueLog(ctx, &proto.Log{
Source: proto.LogSource_PROVISIONER,
Level: sdkproto.LogLevel_INFO,
CreatedAt: time.Now().UnixMilli(),
Output: line,
Stage: stage,
})
}
if !resp.Ok {
r.queueLog(ctx, &proto.Log{
Source: proto.LogSource_PROVISIONER,
Level: sdkproto.LogLevel_WARN,
CreatedAt: time.Now().UnixMilli(),
Output: "This build would exceed your quota. Failing.",
Stage: stage,
})
return r.failedJobf("insufficient quota")
}
return nil
}
func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *proto.FailedJob) {
ctx, span := r.startTrace(ctx, tracing.FuncName())
defer span.End()
var (
applyStage string
applyStage string
commitQuota bool
)
switch r.job.GetWorkspaceBuild().Metadata.WorkspaceTransition {
case sdkproto.WorkspaceTransition_START:
applyStage = "Starting workspace"
commitQuota = true
case sdkproto.WorkspaceTransition_STOP:
applyStage = "Stopping workspace"
commitQuota = true
case sdkproto.WorkspaceTransition_DESTROY:
applyStage = "Destroying workspace"
}
r.queueLog(ctx, &proto.Log{
Source: proto.LogSource_PROVISIONER_DAEMON,
Level: sdkproto.LogLevel_INFO,
Stage: applyStage,
CreatedAt: time.Now().UnixMilli(),
})
config := &sdkproto.Provision_Config{
Directory: r.workDirectory,
Metadata: r.job.GetWorkspaceBuild().Metadata,
State: r.job.GetWorkspaceBuild().State,
}
completed, failed := r.buildWorkspace(ctx, "Planning infrastructure", &sdkproto.Provision_Request{
completedPlan, failed := r.buildWorkspace(ctx, "Planning infrastructure", &sdkproto.Provision_Request{
Type: &sdkproto.Provision_Request_Plan{
Plan: &sdkproto.Provision_Plan{
Config: config,
@ -895,18 +947,34 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
if failed != nil {
return nil, failed
}
r.flushQueuedLogs(ctx)
if commitQuota {
failed = r.commitQuota(ctx, completedPlan.GetResources())
r.flushQueuedLogs(ctx)
if failed != nil {
return nil, failed
}
}
r.queueLog(ctx, &proto.Log{
Source: proto.LogSource_PROVISIONER_DAEMON,
Level: sdkproto.LogLevel_INFO,
Stage: applyStage,
CreatedAt: time.Now().UnixMilli(),
})
completedApply, failed := r.buildWorkspace(ctx, applyStage, &sdkproto.Provision_Request{
Type: &sdkproto.Provision_Request_Apply{
Apply: &sdkproto.Provision_Apply{
Config: config,
Plan: completed.GetPlan(),
Plan: completedPlan.GetPlan(),
},
},
})
if failed != nil {
return nil, failed
}
r.flushQueuedLogs(ctx)
return &proto.CompletedJob{
JobId: r.job.JobId,

View File

@ -1092,7 +1092,7 @@ type Resource struct {
Hide bool `protobuf:"varint,5,opt,name=hide,proto3" json:"hide,omitempty"`
Icon string `protobuf:"bytes,6,opt,name=icon,proto3" json:"icon,omitempty"`
InstanceType string `protobuf:"bytes,7,opt,name=instance_type,json=instanceType,proto3" json:"instance_type,omitempty"`
Cost int32 `protobuf:"varint,8,opt,name=cost,proto3" json:"cost,omitempty"`
DailyCost int32 `protobuf:"varint,8,opt,name=daily_cost,json=dailyCost,proto3" json:"daily_cost,omitempty"`
}
func (x *Resource) Reset() {
@ -1176,9 +1176,9 @@ func (x *Resource) GetInstanceType() string {
return ""
}
func (x *Resource) GetCost() int32 {
func (x *Resource) GetDailyCost() int32 {
if x != nil {
return x.Cost
return x.DailyCost
}
return 0
}
@ -2201,7 +2201,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{
0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e,
0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68,
0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73,
0x68, 0x6f, 0x6c, 0x64, 0x22, 0xe6, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x68, 0x6f, 0x6c, 0x64, 0x22, 0xf1, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65,
@ -2215,128 +2215,128 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{
0x04, 0x68, 0x69, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x6e, 0x73,
0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12,
0x0a, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f,
0x73, 0x74, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74,
0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69,
0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18,
0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0xfc, 0x01,
0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79,
0x1a, 0x55, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x49, 0x0a, 0x11,
0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61,
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73,
0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53,
0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72,
0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c,
0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e,
0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70,
0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xf0, 0x08, 0x0a,
0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xd1, 0x02, 0x0a, 0x08, 0x4d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63,
0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69,
0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54,
0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72,
0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65,
0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77,
0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73,
0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72,
0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12,
0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70,
0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f,
0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d,
0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73,
0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x1a, 0x79,
0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65,
0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72,
0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3b, 0x0a, 0x08,
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52,
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x85, 0x01, 0x0a, 0x04, 0x50, 0x6c,
0x61, 0x6e, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72,
0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65,
0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65,
0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65,
0x73, 0x1a, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52,
0x04, 0x70, 0x6c, 0x61, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a,
0xb3, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x70,
0x6c, 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76,
0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d,
0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01,
0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x1a, 0x69, 0x0a,
0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03,
0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12,
0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08,
0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0xfc, 0x01, 0x0a, 0x05, 0x50, 0x61, 0x72,
0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a,
0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x55, 0x0a, 0x08, 0x43,
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d,
0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61,
0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d,
0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24,
0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52,
0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c,
0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42,
0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xf0, 0x08, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xd1, 0x02, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12,
0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e,
0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b,
0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52,
0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69,
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63,
0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f,
0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77,
0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f,
0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63,
0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b,
0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73,
0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20,
0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77,
0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61,
0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f,
0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x1a, 0x79, 0x0a, 0x06, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72,
0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f,
0x6e, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x34,
0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e,
0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x1a, 0x85, 0x01, 0x0a, 0x04, 0x50, 0x6c, 0x61, 0x6e, 0x12, 0x35, 0x0a,
0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e,
0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x48, 0x00, 0x52, 0x05, 0x61,
0x70, 0x70, 0x6c, 0x79, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e,
0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a,
0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0x7f, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c,
0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a,
0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61,
0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f,
0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70,
0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63,
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a,
0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54,
0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10,
0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57,
0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04,
0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65,
0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11,
0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10,
0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a,
0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69,
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12,
0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53,
0x54, 0x52, 0x4f, 0x59, 0x10, 0x02, 0x32, 0xa3, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69,
0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x12,
0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61,
0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x50, 0x0a, 0x09, 0x50, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73,
0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73,
0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x2d, 0x5a, 0x2b,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65,
0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b,
0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72,
0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72,
0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x52, 0x0a, 0x05,
0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f,
0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04,
0x70, 0x6c, 0x61, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e,
0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0xb3, 0x01, 0x0a, 0x07, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x50, 0x6c, 0x61,
0x6e, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x34, 0x0a, 0x05, 0x61, 0x70, 0x70,
0x6c, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69,
0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12,
0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72,
0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00,
0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65,
0x1a, 0x7f, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05,
0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61,
0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x33, 0x0a, 0x09,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a,
0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03,
0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f,
0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f,
0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f,
0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10,
0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04,
0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03,
0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41,
0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09,
0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54,
0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06,
0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b,
0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12,
0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54,
0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10,
0x02, 0x32, 0xa3, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65,
0x72, 0x12, 0x42, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f,
0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x50, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69,
0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72,
0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75,
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@ -132,7 +132,7 @@ message Resource {
bool hide = 5;
string icon = 6;
string instance_type = 7;
int32 cost = 8;
int32 daily_cost = 8;
}
// Parse consumes source-code from a directory to produce inputs.

View File

@ -21,6 +21,5 @@ export enum FeatureNames {
UserLimit = "user_limit",
BrowserOnly = "browser_only",
SCIM = "scim",
WorkspaceQuota = "workspace_quota",
TemplateRBAC = "template_rbac",
}

View File

@ -156,6 +156,7 @@ export interface CreateFirstUserResponse {
export interface CreateGroupRequest {
readonly name: string
readonly avatar_url: string
readonly quota_allowance: number
}
// From codersdk/users.go
@ -301,7 +302,6 @@ export interface DeploymentConfig {
readonly audit_logging: DeploymentConfigField<boolean>
readonly browser_only: DeploymentConfigField<boolean>
readonly scim_api_key: DeploymentConfigField<string>
readonly user_workspace_quota: DeploymentConfigField<number>
readonly provisioner: ProvisionerConfig
readonly api_rate_limit: DeploymentConfigField<number>
readonly experimental: DeploymentConfigField<boolean>
@ -374,6 +374,7 @@ export interface Group {
readonly organization_id: string
readonly members: User[]
readonly avatar_url: string
readonly quota_allowance: number
}
// From codersdk/workspaceapps.go
@ -502,6 +503,7 @@ export interface PatchGroupRequest {
readonly remove_users: string[]
readonly name: string
readonly avatar_url?: string
readonly quota_allowance?: number
}
// From codersdk/deploymentconfig.go
@ -881,6 +883,7 @@ export interface WorkspaceBuild {
readonly resources: WorkspaceResource[]
readonly deadline?: string
readonly status: WorkspaceStatus
readonly daily_cost: number
}
// From codersdk/workspaces.go
@ -899,10 +902,10 @@ export interface WorkspaceOptions {
readonly include_deleted?: boolean
}
// From codersdk/workspacequota.go
// From codersdk/quota.go
export interface WorkspaceQuota {
readonly user_workspace_count: number
readonly user_workspace_limit: number
readonly credits_consumed: number
readonly budget: number
}
// From codersdk/workspacebuilds.go
@ -917,6 +920,7 @@ export interface WorkspaceResource {
readonly icon: string
readonly agents?: WorkspaceAgent[]
readonly metadata?: WorkspaceResourceMetadata[]
readonly daily_cost: number
}
// From codersdk/workspacebuilds.go

View File

@ -48,7 +48,8 @@ const useStyles = makeStyles((theme) => ({
overflowX: "auto",
},
line: {
whiteSpace: "nowrap",
// Whitespace is significant in terminal output for alignment
whiteSpace: "pre",
},
space: {
userSelect: "none",

View File

@ -53,6 +53,16 @@ export const ResourceCard: FC<ResourceCardProps> = ({ resource, agentRow }) => {
<Stack alignItems="flex-start" direction="row" spacing={5}>
<div className={styles.metadataHeader}>
{resource.daily_cost > 0 && (
<div className={styles.metadata}>
<div className={styles.metadataLabel}>
<b>cost</b>
</div>
<div className={styles.metadataValue}>
{resource.daily_cost}
</div>
</div>
)}
{visibleMetadata.map((meta) => {
return (
<div className={styles.metadata} key={meta.key}>

View File

@ -55,6 +55,7 @@ export interface WorkspaceProps {
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
template?: TypesGen.Template
quota_budget?: number
}
/**
@ -77,6 +78,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
buildInfo,
applicationsHost,
template,
quota_budget,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
@ -187,7 +189,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleClick={() => navigate(`/templates`)}
/>
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
<WorkspaceStats
workspace={workspace}
quota_budget={quota_budget}
handleUpdate={handleUpdate}
/>
{isTransitioning !== undefined && isTransitioning && (
<WorkspaceBuildProgress

View File

@ -1,53 +0,0 @@
import { Story } from "@storybook/react"
import { WorkspaceQuota, WorkspaceQuotaProps } from "./WorkspaceQuota"
export default {
title: "components/WorkspaceQuota",
component: WorkspaceQuota,
}
const Template: Story<WorkspaceQuotaProps> = (args) => (
<WorkspaceQuota {...args} />
)
export const Example = Template.bind({})
Example.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 3,
},
}
export const LimitOf1 = Template.bind({})
LimitOf1.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 1,
},
}
export const Loading = Template.bind({})
Loading.args = {
quota: undefined,
}
export const Error = Template.bind({})
Error.args = {
quota: undefined,
error: {
response: {
data: {
message: "Failed to fetch workspace quotas!",
},
},
isAxiosError: true,
},
}
export const Disabled = Template.bind({})
Disabled.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 0,
},
}

View File

@ -1,123 +0,0 @@
import Box from "@material-ui/core/Box"
import LinearProgress from "@material-ui/core/LinearProgress"
import { makeStyles } from "@material-ui/core/styles"
import Skeleton from "@material-ui/lab/Skeleton"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
export const Language = {
of: "of",
workspace: "workspace",
workspaces: "workspaces",
}
export interface WorkspaceQuotaProps {
quota?: TypesGen.WorkspaceQuota
error: Error | unknown
}
export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
const styles = useStyles()
// error state
if (error !== undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Usage Quota</span>
<AlertBanner severity="error" error={error} />
</Stack>
</Box>
)
}
// loading
if (quota === undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Usage quota</span>
<LinearProgress color="primary" />
<div className={styles.label}>
<Skeleton className={styles.skeleton} />
</div>
</Stack>
</Box>
)
}
// don't show if limit is 0, this means the feature is disabled.
if (quota.user_workspace_limit === 0) {
return null
}
let value = Math.round(
(quota.user_workspace_count / quota.user_workspace_limit) * 100,
)
// we don't want to round down to zero if the count is > 0
if (quota.user_workspace_count > 0 && value === 0) {
value = 1
}
return (
<Box>
<Stack spacing={1.5} className={styles.stack}>
<Stack direction="row" justifyContent="space-between">
<span className={styles.title}>Usage Quota</span>
<div className={styles.label}>
<span className={styles.labelHighlight}>
{quota.user_workspace_count}
</span>{" "}
{Language.of}{" "}
<span className={styles.labelHighlight}>
{quota.user_workspace_limit}
</span>{" "}
{quota.user_workspace_limit === 1
? Language.workspace
: Language.workspaces}
{" used"}
</div>
</Stack>
<LinearProgress
className={
quota.user_workspace_count >= quota.user_workspace_limit
? styles.maxProgress
: undefined
}
value={value}
variant="determinate"
/>
</Stack>
</Box>
)
}
const useStyles = makeStyles((theme) => ({
stack: {
paddingTop: theme.spacing(2.5),
},
maxProgress: {
"& .MuiLinearProgress-colorPrimary": {
backgroundColor: theme.palette.error.main,
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: theme.palette.error.main,
},
},
title: {
fontSize: 16,
},
label: {
fontSize: 14,
display: "block",
color: theme.palette.text.secondary,
},
labelHighlight: {
color: theme.palette.text.primary,
},
skeleton: {
minWidth: "150px",
},
}))

View File

@ -13,19 +13,22 @@ const Language = {
templateLabel: "Template",
statusLabel: "Workspace Status",
versionLabel: "Version",
lastBuiltLabel: "Last Built",
lastBuiltLabel: "Last built",
outdated: "Outdated",
upToDate: "Up to date",
byLabel: "Last built by",
costLabel: "Daily cost",
}
export interface WorkspaceStatsProps {
workspace: Workspace
quota_budget?: number
handleUpdate: () => void
}
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
workspace,
quota_budget,
handleUpdate,
}) => {
const styles = useStyles()
@ -74,6 +77,14 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
<span className={styles.statsLabel}>{Language.byLabel}:</span>
<span className={styles.statsValue}>{initiatedBy}</span>
</div>
{workspace.latest_build.daily_cost > 0 && (
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.costLabel}:</span>
<span className={styles.statsValue}>
{workspace.latest_build.daily_cost} / {quota_budget}
</span>
</div>
)}
</div>
)
}

View File

@ -1,12 +1,10 @@
import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import { useActor, useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
import {
CreateWorkspaceErrors,
@ -19,19 +17,12 @@ const CreateWorkspacePage: FC = () => {
const { template } = useParams()
const templateName = template ? template : ""
const navigate = useNavigate()
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota]
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
context: {
organizationId,
templateName,
workspaceQuotaEnabled,
owner: me ?? null,
},
actions: {
@ -49,8 +40,6 @@ const CreateWorkspacePage: FC = () => {
getTemplatesError,
createWorkspaceError,
permissions,
workspaceQuota,
getWorkspaceQuotaError,
owner,
} = createWorkspaceState.context
@ -70,14 +59,11 @@ const CreateWorkspacePage: FC = () => {
templates={templates}
selectedTemplate={selectedTemplate}
templateSchema={templateSchema}
workspaceQuota={workspaceQuota}
createWorkspaceErrors={{
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError,
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]:
getTemplateSchemaError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
[CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]:
getWorkspaceQuotaError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
owner={owner}

View File

@ -4,7 +4,6 @@ import { FormFooter } from "components/FormFooter/FormFooter"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { i18n } from "i18n"
import { FC, useState } from "react"
@ -20,7 +19,6 @@ export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError",
CREATE_WORKSPACE_ERROR = "createWorkspaceError",
GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError",
}
export interface CreateWorkspacePageViewProps {
@ -32,7 +30,6 @@ export interface CreateWorkspacePageViewProps {
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
workspaceQuota?: TypesGen.WorkspaceQuota
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
owner: TypesGen.User | null
@ -94,12 +91,6 @@ export const CreateWorkspacePageView: FC<
},
})
const canSubmit =
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0
? props.workspaceQuota.user_workspace_count <
props.workspaceQuota.user_workspace_limit
: true
const isLoading = props.loadingTemplateSchema || props.loadingTemplates
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
@ -237,17 +228,6 @@ export const CreateWorkspacePageView: FC<
inputMargin="dense"
showAvatar
/>
{props.workspaceQuota && (
<WorkspaceQuota
quota={props.workspaceQuota}
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR
]
}
/>
)}
</Stack>
</div>
)}
@ -289,7 +269,6 @@ export const CreateWorkspacePageView: FC<
styles={formFooterStyles}
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitDisabled={!canSubmit}
submitLabel={t("createWorkspace")}
/>
</Stack>

View File

@ -29,6 +29,7 @@ export const CreateGroupPageView: React.FC<CreateGroupPageViewProps> = ({
initialValues: {
name: "",
avatar_url: "",
quota_allowance: 0,
},
validationSchema,
onSubmit,

View File

@ -22,10 +22,12 @@ import * as Yup from "yup"
type FormData = {
name: string
avatar_url: string
quota_allowance: number
}
const validationSchema = Yup.object({
name: nameValidator("Name"),
quota_allowance: Yup.number().required().positive().integer(),
})
const UpdateGroupForm: React.FC<{
@ -40,6 +42,7 @@ const UpdateGroupForm: React.FC<{
initialValues: {
name: group.name,
avatar_url: group.avatar_url,
quota_allowance: group.quota_allowance,
},
validationSchema,
onSubmit,
@ -121,6 +124,20 @@ const UpdateGroupForm: React.FC<{
/>
</Popover>
<TextField
{...getFieldHelpers("quota_allowance")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
type="number"
label="Quota Allowance"
variant="outlined"
/>
<span>
This group gives {form.values.quota_allowance} quota credits to each
of its members.
</span>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>

View File

@ -8,6 +8,7 @@ import { Loader } from "components/Loader/Loader"
import { firstOrItem } from "util/array"
import { workspaceMachine } from "xServices/workspace/workspaceXService"
import { WorkspaceReadyPage } from "./WorkspaceReadyPage"
import { quotaMachine } from "xServices/quotas/quotasXService"
export const WorkspacePage: FC = () => {
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
@ -21,6 +22,8 @@ export const WorkspacePage: FC = () => {
getTemplateWarning,
checkPermissionsError,
} = workspaceState.context
const [quotaState, quotaSend] = useMachine(quotaMachine)
const { getQuotaError } = quotaState.context
const styles = useStyles()
/**
@ -33,6 +36,10 @@ export const WorkspacePage: FC = () => {
workspaceSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, workspaceSend])
useEffect(() => {
username && quotaSend({ type: "GET_QUOTA", username })
}, [username, quotaSend])
return (
<ChooseOne>
<Cond condition={workspaceState.matches("error")}>
@ -46,11 +53,21 @@ export const WorkspacePage: FC = () => {
{Boolean(checkPermissionsError) && (
<AlertBanner severity="error" error={checkPermissionsError} />
)}
{Boolean(getQuotaError) && (
<AlertBanner severity="error" error={getQuotaError} />
)}
</div>
</Cond>
<Cond condition={Boolean(workspace) && workspaceState.matches("ready")}>
<Cond
condition={
Boolean(workspace) &&
workspaceState.matches("ready") &&
quotaState.matches("success")
}
>
<WorkspaceReadyPage
workspaceState={workspaceState}
quotaState={quotaState}
workspaceSend={workspaceSend}
/>
</Cond>

View File

@ -10,6 +10,7 @@ import {
getMinDeadline,
} from "util/schedule"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { quotaMachine } from "xServices/quotas/quotasXService"
import { StateFrom } from "xstate"
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
import {
@ -26,11 +27,13 @@ import {
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>
quotaState: StateFrom<typeof quotaMachine>
workspaceSend: (event: WorkspaceEvent) => void
}
export const WorkspaceReadyPage = ({
workspaceState,
quotaState,
workspaceSend,
}: WorkspaceReadyPageProps): JSX.Element => {
const [bannerState, bannerSend] = useActor(
@ -124,6 +127,7 @@ export const WorkspaceReadyPage = ({
buildInfo={buildInfoState.context.buildInfo}
applicationsHost={applicationsHost}
template={template}
quota_budget={quotaState.context.quota?.budget}
/>
<DeleteDialog
entity="workspace"

View File

@ -311,6 +311,7 @@ export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
{ key: "type", value: "a-workspace-resource", sensitive: false },
{ key: "api_key", value: "12345678", sensitive: true },
],
daily_cost: 10,
}
export const MockWorkspaceResource2: TypesGen.WorkspaceResource = {
@ -331,6 +332,7 @@ export const MockWorkspaceResource2: TypesGen.WorkspaceResource = {
{ key: "type", value: "google_compute_disk", sensitive: false },
{ key: "size", value: "32GB", sensitive: false },
],
daily_cost: 10,
}
export const MockWorkspaceResource3: TypesGen.WorkspaceResource = {
@ -351,6 +353,7 @@ export const MockWorkspaceResource3: TypesGen.WorkspaceResource = {
{ key: "type", value: "google_compute_disk", sensitive: false },
{ key: "size", value: "32GB", sensitive: false },
],
daily_cost: 20,
}
export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest =
@ -383,6 +386,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
reason: "initiator",
resources: [MockWorkspaceResource],
status: "running",
daily_cost: 20,
}
export const MockFailedWorkspaceBuild = (
@ -405,6 +409,7 @@ export const MockFailedWorkspaceBuild = (
reason: "initiator",
resources: [],
status: "running",
daily_cost: 20,
})
export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = {
@ -988,8 +993,8 @@ export const MockAuditLogWithWorkspaceBuild: TypesGen.AuditLog = {
}
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
user_workspace_count: 0,
user_workspace_limit: 100,
credits_consumed: 0,
budget: 100,
}
export const MockGroup: TypesGen.Group = {
@ -998,6 +1003,7 @@ export const MockGroup: TypesGen.Group = {
avatar_url: "https://example.com",
organization_id: MockOrganization.id,
members: [MockUser, MockUser2],
quota_allowance: 5,
}
export const MockTemplateACL: TypesGen.TemplateACL = {

View File

@ -3,7 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types"
import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities"
import { MockGroup } from "./entities"
import { MockGroup, MockWorkspaceQuota } from "./entities"
export const handlers = [
rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => {
@ -243,4 +243,8 @@ export const handlers = [
rest.delete("/api/v2/groups/:groupId", (req, res, ctx) => {
return res(ctx.status(204))
}),
rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockWorkspaceQuota))
}),
]

View File

@ -6,6 +6,7 @@ export const everyOneGroup = (organizationId: string): Group => ({
organization_id: organizationId,
members: [],
avatar_url: "",
quota_allowance: 0,
})
export const getGroupSubtitle = (group: Group): string => {

View File

@ -3,7 +3,6 @@ import {
createWorkspace,
getTemplates,
getTemplateVersionSchema,
getWorkspaceQuota,
} from "api/api"
import {
CreateWorkspaceRequest,
@ -11,7 +10,6 @@ import {
Template,
User,
Workspace,
WorkspaceQuota,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
@ -19,7 +17,6 @@ type CreateWorkspaceContext = {
organizationId: string
owner: User | null
templateName: string
workspaceQuotaEnabled: boolean
templates?: Template[]
selectedTemplate?: Template
templateSchema?: ParameterSchema[]
@ -30,8 +27,6 @@ type CreateWorkspaceContext = {
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
workspaceQuota?: WorkspaceQuota
getWorkspaceQuotaError?: Error | unknown
}
type CreateWorkspaceEvent = {
@ -60,9 +55,6 @@ export const createWorkspaceMachine = createMachine(
getTemplateSchema: {
data: ParameterSchema[]
}
getWorkspaceQuota: {
data: WorkspaceQuota
}
createWorkspace: {
data: Workspace
}
@ -110,25 +102,11 @@ export const createWorkspaceMachine = createMachine(
src: "checkPermissions",
id: "checkPermissions",
onDone: {
actions: ["assignPermissions"],
target: "gettingWorkspaceQuota",
},
onError: {
actions: ["assignCheckPermissionsError"],
},
},
},
gettingWorkspaceQuota: {
entry: "clearGetWorkspaceQuotaError",
invoke: {
src: "getWorkspaceQuota",
onDone: {
actions: ["assignWorkspaceQuota"],
actions: "assignPermissions",
target: "fillingParams",
},
onError: {
actions: ["assignGetWorkspaceQuotaError"],
target: "error",
actions: ["assignCheckPermissionsError"],
},
},
},
@ -140,7 +118,7 @@ export const createWorkspaceMachine = createMachine(
},
SELECT_OWNER: {
actions: ["assignOwner"],
target: "gettingWorkspaceQuota",
target: ["fillingParams"],
},
},
},
@ -212,17 +190,6 @@ export const createWorkspaceMachine = createMachine(
createWorkspaceRequest,
)
},
getWorkspaceQuota: (context) => {
if (!context.workspaceQuotaEnabled) {
// resolving with a limit of 0 will disable the component
return Promise.resolve({
user_workspace_count: 0,
user_workspace_limit: 0,
})
}
return getWorkspaceQuota(context.owner?.id ?? "me")
},
},
guards: {
areTemplatesEmpty: (_, event) => event.data.length === 0,
@ -278,15 +245,6 @@ export const createWorkspaceMachine = createMachine(
clearGetTemplateSchemaError: assign({
getTemplateSchemaError: (_) => undefined,
}),
assignWorkspaceQuota: assign({
workspaceQuota: (_, event) => event.data,
}),
assignGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_, event) => event.data,
}),
clearGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_) => undefined,
}),
},
},
)

View File

@ -29,7 +29,7 @@ export const editGroupMachine = createMachine(
},
events: {} as {
type: "UPDATE"
data: { name: string; avatar_url: string }
data: { name: string; avatar_url: string; quota_allowance: number }
},
},
tsTypes: {} as import("./editGroupXService.typegen").Typegen0,

Some files were not shown because too many files have changed in this diff Show More