Skip to content

Commit e95887f

Browse files
committed
[CLI]: Use instance/replica limits from the limits API (#2737)
Synced from monorepo@f32f60aad9793428a57d314a2052011bb144ca24
1 parent 063825c commit e95887f

6 files changed

Lines changed: 89 additions & 54 deletions

File tree

.sync-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
89a52d932a3abd4ea053b95cfd51f110e739d138
1+
f32f60aad9793428a57d314a2052011bb144ca24

src/commands/branch/create.ts

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import chalk from 'chalk';
44
import { match } from 'ts-pattern';
55
import type { LocalContext } from '~/context';
66
import { branchConfig } from '~/lib/branch-config';
7+
import { getBranchLimits, instanceTypeUnavailableMessage, replicaChoicesFor } from '~/lib/branch-limits';
78
import { CLI_NAME, DEFAULT_API_BASE_URL } from '~/lib/constants';
89

910
import type { Types } from '@xata.io/api';
@@ -21,7 +22,7 @@ type Flags = {
2122
project?: string;
2223
name?: string;
2324
'parent-branch'?: string;
24-
replicas?: '0' | '1' | '2' | '3' | '4';
25+
replicas?: string;
2526
'instance-type'?: string;
2627
region?: string;
2728
'postgres-version'?: string;
@@ -30,14 +31,6 @@ type Flags = {
3031
json: boolean;
3132
};
3233

33-
export const replicaChoices = [
34-
{ name: '0', message: '0' },
35-
{ name: '1', message: '1' },
36-
{ name: '2', message: '2' },
37-
{ name: '3', message: '3' },
38-
{ name: '4', message: '4' }
39-
];
40-
4134
export async function instanceTypes(context: LocalContext, organizationId: string, region: string) {
4235
const instanceTypes = await context.api.projects.listInstanceTypes({
4336
pathParams: { organizationID: organizationId },
@@ -54,14 +47,17 @@ export function shouldShowInstanceTypePricing(context: LocalContext) {
5447

5548
export function buildInstanceTypeChoices(
5649
instances: Awaited<ReturnType<typeof instanceTypes>>,
57-
{ showPricing }: { showPricing: boolean }
50+
{ showPricing, maxAllowedVCPUs }: { showPricing: boolean; maxAllowedVCPUs?: number }
5851
) {
5952
return instances.map((instanceType) => {
6053
const parts = [`${instanceType.name} / ${instanceType.vcpus} milli-vCPU / ${instanceType.ram} GB RAM`];
6154
if (showPricing) {
6255
const instanceMonthlyCost = monthlyComputeCost(instanceType, 1);
6356
parts.push(chalk.gray(`$${instanceMonthlyCost.display} per mo`));
6457
}
58+
if (maxAllowedVCPUs !== undefined && instanceType.vcpus > maxAllowedVCPUs) {
59+
parts.push(chalk.yellow('not available on your current plan'));
60+
}
6561
return {
6662
name: instanceType.name,
6763
message: parts.join(' / ')
@@ -159,13 +155,13 @@ export async function getRegion(context: LocalContext, flags: { region?: string
159155

160156
export async function getReplicas(context: LocalContext, flags: { replicas?: string }, options: ProjectOptions) {
161157
const title = options?.title || 'Please select number of replicas for the branch';
158+
const { maxReplicas } = await getBranchLimits(context, options.organizationId);
159+
const choices = replicaChoicesFor(maxReplicas);
162160

163161
if (flags.replicas) {
164-
if (!replicaChoices.some((replica) => flags.replicas === replica.name)) {
162+
if (!choices.some((replica) => flags.replicas === replica.name)) {
165163
context.process.stderr.write(
166-
chalk.red(
167-
`Invalid replica count: ${flags.replicas}. Must be one of: ${replicaChoices.map((r) => r.name).join(', ')}.`
168-
)
164+
chalk.red(`Invalid replica count: ${flags.replicas}. Must be one of: ${choices.map((r) => r.name).join(', ')}.`)
169165
);
170166
context.process.exit(1);
171167
}
@@ -174,11 +170,7 @@ export async function getReplicas(context: LocalContext, flags: { replicas?: str
174170
}
175171

176172
if (!flags.replicas) {
177-
const replicas = (await context.enquirer.selectPrompt(
178-
context.isInteractive,
179-
title,
180-
replicaChoices
181-
)) as Flags['replicas'];
173+
const replicas = (await context.enquirer.selectPrompt(context.isInteractive, title, choices)) as Flags['replicas'];
182174
invariant(replicas, `Replicas should exist`);
183175
return replicas;
184176
}
@@ -192,33 +184,32 @@ export async function getInstanceType(
192184
options: ProjectOptions & { region: string }
193185
) {
194186
const title = options?.title || 'Please select the type of instance for this branch';
187+
const { maxAllowedVCPUs } = await getBranchLimits(context, options.organizationId);
195188
const instances = await instanceTypes(context, options.organizationId, options.region);
196-
const instanceChoices = buildInstanceTypeChoices(instances, { showPricing: shouldShowInstanceTypePricing(context) });
189+
const instanceChoices = buildInstanceTypeChoices(instances, {
190+
showPricing: shouldShowInstanceTypePricing(context),
191+
maxAllowedVCPUs
192+
});
197193

198-
if (flags['instance-type']) {
199-
if (!instances.some((instance) => flags['instance-type'] === instance.name)) {
200-
context.process.stderr.write(
201-
chalk.red(
202-
`Invalid instance type: ${flags['instance-type']}. This instance type is not available for this organization.`
203-
)
204-
);
205-
context.process.exit(1);
206-
}
207-
invariant(flags['instance-type'], `Instance type should exist`);
208-
return flags['instance-type'];
194+
const instanceType =
195+
flags['instance-type'] ??
196+
((await context.enquirer.selectPrompt(context.isInteractive, title, instanceChoices)) as Flags['instance-type']);
197+
invariant(instanceType, `Instance type should exist`);
198+
199+
const instance = instances.find((option) => option.name === instanceType);
200+
if (!instance) {
201+
context.process.stderr.write(
202+
chalk.red(`Invalid instance type: ${instanceType}. This instance type is not available for this organization.`)
203+
);
204+
context.process.exit(1);
209205
}
210206

211-
if (!flags['instance-type']) {
212-
const instanceType = (await context.enquirer.selectPrompt(
213-
context.isInteractive,
214-
title,
215-
instanceChoices
216-
)) as Flags['instance-type'];
217-
invariant(instanceType, `Instance type should exist`);
218-
return instanceType;
207+
if (maxAllowedVCPUs && instance.vcpus > maxAllowedVCPUs) {
208+
context.process.stderr.write(chalk.red(`${instanceTypeUnavailableMessage(instanceType)}\n`));
209+
context.process.exit(1);
219210
}
220211

221-
invariant(false, `Expected input for flag --instance-type`);
212+
return instanceType;
222213
}
223214

224215
export async function getImage(
@@ -445,8 +436,8 @@ export const BranchCreateCommand = buildCommand({
445436
optional: true
446437
},
447438
replicas: {
448-
kind: 'enum',
449-
values: ['0', '1', '2', '3', '4'],
439+
kind: 'parsed',
440+
parse: String,
450441
brief: 'Please select number of replicas for the branch',
451442
optional: true
452443
},

src/commands/branch/set.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import chalk from 'chalk';
44
import { match } from 'ts-pattern';
55
import type { LocalContext } from '~/context';
66
import { CLI_NAME } from '~/lib/constants';
7-
import { buildInstanceTypeChoices, instanceTypes, replicaChoices, shouldShowInstanceTypePricing } from './create';
7+
import { getBranchLimits, instanceTypeUnavailableMessage, replicaChoicesFor } from '~/lib/branch-limits';
8+
import { buildInstanceTypeChoices, instanceTypes, shouldShowInstanceTypePricing } from './create';
89
import { validScaleToZeroValues, validInactivityPeriodValues, scaleToZeroChoices, timeChoices } from '~/lib/config';
910

1011
type Flags = {
@@ -79,8 +80,13 @@ export async function implementation(this: LocalContext, flags: Flags, fieldArg:
7980
const defaultScaleToZero = isRootBranch ? scaleToZeroBase : scaleToZeroChild;
8081
const defaultInactivityPeriod = isRootBranch ? inactivityPeriodBase : inactivityPeriodChild;
8182

83+
const { maxReplicas, maxAllowedVCPUs } = await getBranchLimits(this, organizationId);
84+
const replicaChoices = replicaChoicesFor(maxReplicas);
8285
const instances = await instanceTypes(this, organizationId, branchRegion);
83-
const instanceChoices = buildInstanceTypeChoices(instances, { showPricing: shouldShowInstanceTypePricing(this) });
86+
const instanceChoices = buildInstanceTypeChoices(instances, {
87+
showPricing: shouldShowInstanceTypePricing(this),
88+
maxAllowedVCPUs
89+
});
8490

8591
let upgradeableImageChoices: { name: string; message: string }[] = [];
8692
if (field === 'postgres-version') {
@@ -172,7 +178,7 @@ export async function implementation(this: LocalContext, flags: Flags, fieldArg:
172178
}
173179
})
174180
.with('replicas', () => {
175-
const validReplicas = ['0', '1', '2', '3', '4'];
181+
const validReplicas = replicaChoices.map((choice) => choice.name);
176182
if (!validReplicas.includes(value)) {
177183
this.process.stderr.write(
178184
chalk.red(`Invalid replicas value: ${value}. Valid values are: ${validReplicas.join(', ')}`)
@@ -181,13 +187,18 @@ export async function implementation(this: LocalContext, flags: Flags, fieldArg:
181187
}
182188
})
183189
.with('instance-type', () => {
184-
const validInstanceTypes = instances.map((t) => t.name);
185-
if (!validInstanceTypes.includes(value)) {
190+
const instance = instances.find((t) => t.name === value);
191+
if (!instance) {
192+
const validInstanceTypes = instances.map((t) => t.name);
186193
this.process.stderr.write(
187194
chalk.red(`Invalid instance type: ${value}. Valid values are: ${validInstanceTypes.join(', ')}`)
188195
);
189196
this.process.exit(1);
190197
}
198+
if (maxAllowedVCPUs && instance.vcpus > maxAllowedVCPUs) {
199+
this.process.stderr.write(chalk.red(`${instanceTypeUnavailableMessage(value)}\n`));
200+
this.process.exit(1);
201+
}
191202
})
192203
.with('hibernate', () => {
193204
const validHibernateValues = ['true', 'false'];

src/commands/onboard.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type Flags = {
1313
'project-name'?: string;
1414
'branch-name'?: string;
1515

16-
replicas?: '0' | '1' | '2' | '3' | '4';
16+
replicas?: string;
1717
'instance-type'?: string;
1818
region?: string;
1919
'scale-to-zero-base'?: 'true' | 'false';
@@ -229,8 +229,8 @@ export const OnboardCommand = buildCommand({
229229
optional: true
230230
},
231231
replicas: {
232-
kind: 'enum',
233-
values: ['0', '1', '2', '3', '4'],
232+
kind: 'parsed',
233+
parse: String,
234234
brief: 'Please select number of replicas for the branch',
235235
optional: true
236236
},

src/commands/project/create.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type Flags = {
77
organization?: string;
88
name: string;
99
'branch-name'?: string;
10-
replicas?: '0' | '1' | '2' | '3' | '4';
10+
replicas?: string;
1111
'instance-type'?: string;
1212
region?: string;
1313
'scale-to-zero-base'?: 'true' | 'false';
@@ -144,8 +144,8 @@ export const ProjectCreateCommand = buildCommand({
144144
optional: true
145145
},
146146
replicas: {
147-
kind: 'enum',
148-
values: ['0', '1', '2', '3', '4'],
147+
kind: 'parsed',
148+
parse: String,
149149
brief: 'Please select number of replicas for the branch',
150150
optional: true
151151
},

src/lib/branch-limits.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { LocalContext } from '~/context';
2+
3+
const DEFAULT_MAX_INSTANCES_PER_BRANCH = 5;
4+
5+
export type BranchLimits = { maxReplicas: number; maxAllowedVCPUs: number | undefined };
6+
7+
// Effective instance/replica limits for the org. Falls back to today's defaults
8+
// (4 replicas, no instance-type cap) if the limits endpoint is unavailable, so
9+
// commands never fail on limits alone.
10+
export async function getBranchLimits(context: LocalContext, organizationId: string): Promise<BranchLimits> {
11+
try {
12+
const limits = await context.api.projects.getOrganizationLimits({
13+
pathParams: { organizationID: organizationId }
14+
});
15+
return {
16+
maxReplicas: (limits.maxInstancesPerBranch ?? DEFAULT_MAX_INSTANCES_PER_BRANCH) - 1,
17+
maxAllowedVCPUs: limits.maxAllowedInstanceType
18+
};
19+
} catch {
20+
return { maxReplicas: DEFAULT_MAX_INSTANCES_PER_BRANCH - 1, maxAllowedVCPUs: undefined };
21+
}
22+
}
23+
24+
export function replicaChoicesFor(maxReplicas: number) {
25+
return Array.from({ length: Math.max(0, maxReplicas) + 1 }, (_, count) => ({
26+
name: String(count),
27+
message: String(count)
28+
}));
29+
}
30+
31+
export function instanceTypeUnavailableMessage(instanceType: string): string {
32+
return `Instance type ${instanceType} is not available on your current plan; please add a payment method in your billing settings or contact support to enable larger instances.`;
33+
}

0 commit comments

Comments
 (0)