Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/cli/src/admin/cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,64 @@ describe('remove-exceeding-tenant-instances', () => {
expect(summary()).toContain('1 instance failures');
expect(process.exitCode).toBe(1);
});

it.each(['PERSONAL', 'PROFESSIONAL', 'BUSINESS'])(
'suspends tenants on the paid plan %s below the threshold',
async (planType) => {
(getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: { planType } });
mockSuspend.mockResolvedValue(undefined);
await run();
expect(mockSuspend).toHaveBeenCalledTimes(1);
expect(summary()).toContain('suspended 1 instances');
}
);

it.each([
['ENTERPRISE', { planType: 'ENTERPRISE' }],
[
'managed sub-workspace',
{ planType: 'PROFESSIONAL', managedTenant: true }
],
[
'billing exception',
{ planType: 'BUSINESS', subWorkspaceBillingException: true }
],
['no plan type', { planType: '' }],
['an unknown plan type', { planType: 'FUTURE_PLAN' }]
])('does not suspend %s', async (_name, plan) => {
(getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: plan });
await run();
expect(mockSuspend).not.toHaveBeenCalled();
expect(summary()).toContain('suspended 0 instances');
});

it('does not suspend a paid tenant at or above the threshold', async () => {
(getTenantPlanMap as jest.Mock).mockResolvedValue({
t1: { planType: 'PERSONAL' }
});
(getTenantTokenCounts as jest.Mock).mockResolvedValue([
{ tenantId: 't1', remainingTokens: -200 }
]);
await run();
expect(mockSuspend).not.toHaveBeenCalled();
});

it('does not suspend a tenant that has no plan record', async () => {
(getTenantPlanMap as jest.Mock).mockResolvedValue({});
await run();
expect(mockSuspend).not.toHaveBeenCalled();
});

it.each([
['managed sub-workspace', { planType: 'FREE', managedTenant: true }],
[
'billing exception',
{ planType: 'FREE', subWorkspaceBillingException: true }
]
])('still suspends a FREE tenant flagged as %s', async (_name, plan) => {
(getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: plan });
mockSuspend.mockResolvedValue(undefined);
await run();
expect(mockSuspend).toHaveBeenCalledTimes(1);
});
});
13 changes: 9 additions & 4 deletions packages/cli/src/admin/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
removeSubscriptionForTenant
} from './subscription';
import { getTenantPlanMap, getTenantTokenCounts } from './money';
import {
isSuspendableByTokenBalance,
TOKEN_SUSPEND_THRESHOLD
} from './suspend-policy';

const ALREADY_SUSPENDED_REASON = 'Instance is already suspended';

Expand Down Expand Up @@ -97,7 +101,7 @@ export default function cmdAdmin() {
admin
.command('remove-exceeding-tenant-instances')
.description(
'Suspend all instances for all tenants with a negative usage token balance'
'Suspend all instances for tenants below the token balance threshold (-200), on the Free plan and on self-service paid plans'
)
.option('--apply', 'Actually suspend instances, otherwise just a dry run')
.action(async (options, command) => {
Expand All @@ -107,23 +111,24 @@ export default function cmdAdmin() {
const tenantPlanMap = await getTenantPlanMap(environment);
const tokenCounts = await getTenantTokenCounts(environment);
const tenantsExceeding = tokenCounts.filter(
(tenant) => tenant.remainingTokens < -200
(tenant) => tenant.remainingTokens < TOKEN_SUSPEND_THRESHOLD
);
let instancesSuspended = 0;
let instancesFailed = 0;
let instancesAlreadySuspended = 0;
let tenantsFailed = 0;
const tenantsExceedingCount = tenantsExceeding.length;
for (const tenant of tenantsExceeding) {
if (tenantPlanMap[tenant.tenantId]?.planType === 'FREE') {
const plan = tenantPlanMap[tenant.tenantId];
if (isSuspendableByTokenBalance(plan)) {
try {
const instancesToRemove = await getInstancesToRemove(
tenant.tenantId,
environment
);
if (instancesToRemove.length > 0) {
console.log(
`Tenant ${tenant.tenantId} has a negative or low token balance of ${tenant.remainingTokens} tokens and is on the 'FREE' plan`
`Tenant ${tenant.tenantId} has a negative or low token balance of ${tenant.remainingTokens} tokens and is on the '${plan.planType}' plan`
);
console.log('Suspending all instances for this tenant...');
for (const item of instancesToRemove) {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/admin/money.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type TenantTokenCount = {
export type TenantPlan = {
tenantId: string;
planType: string;
managedTenant?: boolean;
subWorkspaceBillingException?: boolean;
};

export async function getTenantTokenCounts(environment: string) {
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/admin/suspend-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { TenantPlan } from './money';

export const TOKEN_SUSPEND_THRESHOLD = -200;

const FREE_PLAN = 'FREE';
const PAID_SELF_SERVICE_PLANS = ['PERSONAL', 'PROFESSIONAL', 'BUSINESS'];

/**
* Whether the token-balance sweep may suspend instances for a tenant on this
* plan. Allowlist: FREE (always, regardless of flags) and the self-service
* paid plans PERSONAL, PROFESSIONAL and BUSINESS. A paid tenant that is a
* managed reseller sub-workspace, or has the sub-workspace billing exception
* (billed off-platform), is skipped. ENTERPRISE, unknown plan types and
* tenants without a plan record are never suspended by the sweep.
*/
export function isSuspendableByTokenBalance(plan?: TenantPlan): boolean {
if (!plan) return false;
if (plan.planType === FREE_PLAN) return true;
if (!PAID_SELF_SERVICE_PLANS.includes(plan.planType)) return false;
return !plan.managedTenant && !plan.subWorkspaceBillingException;
}
Loading