From 46ab8b4a0be473cb2b782b2dd6b767fa0aae8bab Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 12 Mar 2026 11:11:54 +0100 Subject: [PATCH 1/2] chore: fix tsconfig --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 6a24223..208cdd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "moduleDetection": "force" }, "include": ["src"], - "exclude": ["src/cli-binary.ts", "src/specs-embedded.ts", "src/command-registry.ts"] + "exclude": ["src/cli-binary.ts", "src/command-registry.ts"] } From fd0a936bb9c2ef1bdf10551aa3bf253748bd13d3 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 12 Mar 2026 12:57:12 +0100 Subject: [PATCH 2/2] feat: implement bucket set locations --- src/lib/buckets/create.ts | 64 ++--------------- src/lib/buckets/set-locations.ts | 59 ++++++++++++++++ src/specs.yaml | 23 +++++++ src/utils/locations.ts | 113 +++++++++++++++++++++++++++---- 4 files changed, 188 insertions(+), 71 deletions(-) create mode 100644 src/lib/buckets/set-locations.ts diff --git a/src/lib/buckets/create.ts b/src/lib/buckets/create.ts index 52f030e..91ca746 100644 --- a/src/lib/buckets/create.ts +++ b/src/lib/buckets/create.ts @@ -3,11 +3,7 @@ import enquirer from 'enquirer'; import { getArgumentSpec, buildPromptChoices } from '../../utils/specs.js'; import { StorageClass, createBucket } from '@tigrisdata/storage'; import { getStorageConfig } from '../../auth/s3-client'; -import { - parseLocations, - multiRegionChoices, - singleRegionChoices, -} from '../../utils/locations.js'; +import { parseLocations, promptLocations } from '../../utils/locations.js'; import type { BucketLocations } from '@tigrisdata/storage'; import { printStart, @@ -116,59 +112,11 @@ export default async function create(options: Record) { defaultTier = responses.defaultTier; enableSnapshots = responses.enableSnapshots; - // Location selection: type first, then region(s) based on type - const { locationType } = await prompt<{ locationType: string }>({ - type: 'select', - name: 'locationType', - message: 'Location type:', - choices: [ - { name: 'global', message: 'Global' }, - { name: 'multi', message: 'Multi-region (USA or Europe)' }, - { name: 'dual', message: 'Dual region' }, - { name: 'single', message: 'Single region' }, - ], - }); - - if (locationType === 'global') { - parsedLocations = { type: 'global' }; - } else if (locationType === 'multi') { - const { region } = await prompt<{ region: string }>({ - type: 'select', - name: 'region', - message: 'Multi-region:', - choices: multiRegionChoices.map((c) => ({ - name: c.value, - message: c.name, - })), - }); - parsedLocations = parseLocations(region); - } else if (locationType === 'single') { - const { region } = await prompt<{ region: string }>({ - type: 'select', - name: 'region', - message: 'Region:', - choices: singleRegionChoices.map((c) => ({ - name: c.value, - message: c.name, - })), - }); - parsedLocations = parseLocations(region); - } else { - const { regions } = await prompt<{ regions: string[] }>({ - type: 'multiselect', - name: 'regions', - message: - 'Press space key to select regions (multiple supported) and enter to confirm:', - choices: singleRegionChoices.map((c) => ({ - name: c.value, - message: c.name, - })), - } as Parameters[0]); - if (regions.length < 2) { - printFailure(context, 'Dual region requires at least two regions'); - process.exit(1); - } - parsedLocations = parseLocations(regions); + try { + parsedLocations = await promptLocations(); + } catch (err) { + printFailure(context, (err as Error).message); + process.exit(1); } } diff --git a/src/lib/buckets/set-locations.ts b/src/lib/buckets/set-locations.ts new file mode 100644 index 0000000..4b03ef3 --- /dev/null +++ b/src/lib/buckets/set-locations.ts @@ -0,0 +1,59 @@ +import { getOption } from '../../utils/options.js'; +import { getStorageConfig } from '../../auth/s3-client.js'; +import { getSelectedOrganization } from '../../auth/storage.js'; +import { updateBucket } from '@tigrisdata/storage'; +import type { BucketLocations } from '@tigrisdata/storage'; +import { parseLocations, promptLocations } from '../../utils/locations.js'; +import { + printStart, + printSuccess, + printFailure, + msg, +} from '../../utils/messages.js'; + +const context = msg('buckets', 'set-locations'); + +export default async function setLocations(options: Record) { + printStart(context); + + const name = getOption(options, ['name']); + const locations = getOption(options, ['locations']); + + if (!name) { + printFailure(context, 'Bucket name is required'); + process.exit(1); + } + + let parsedLocations: BucketLocations; + if (locations !== undefined) { + parsedLocations = parseLocations(locations); + } else { + try { + parsedLocations = await promptLocations(); + } catch (err) { + printFailure(context, (err as Error).message); + process.exit(1); + } + } + + const config = await getStorageConfig(); + const selectedOrg = getSelectedOrganization(); + const finalConfig = { + ...config, + ...(selectedOrg && !config.organizationId + ? { organizationId: selectedOrg } + : {}), + }; + + const { error } = await updateBucket(name, { + locations: parsedLocations, + config: finalConfig, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + printSuccess(context, { name }); +} diff --git a/src/specs.yaml b/src/specs.yaml index 1299df9..2ef0d08 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -747,6 +747,29 @@ commands: - name: disable description: Disable TTL on the bucket type: flag + # set-locations + - name: set-locations + description: Set the data locations for a bucket + examples: + - "tigris buckets set-locations my-bucket --locations iad" + - "tigris buckets set-locations my-bucket --locations iad,fra" + - "tigris buckets set-locations my-bucket --locations global" + messages: + onStart: 'Updating bucket locations...' + onSuccess: 'Locations updated for bucket {{name}}' + onFailure: 'Failed to update bucket locations' + arguments: + - name: name + description: Name of the bucket + type: positional + required: true + examples: + - my-bucket + - name: locations + description: Bucket location + alias: l + options: *location_options + multiple: true # set-migration - name: set-migration description: Configure data migration from an external S3-compatible source bucket. Tigris will pull objects on demand from the source diff --git a/src/utils/locations.ts b/src/utils/locations.ts index ad95e06..7e27fab 100644 --- a/src/utils/locations.ts +++ b/src/utils/locations.ts @@ -1,4 +1,7 @@ import type { BucketLocations } from '@tigrisdata/storage'; +import enquirer from 'enquirer'; + +const { prompt } = enquirer; type Multi = Extract; type Single = Extract; @@ -6,25 +9,109 @@ type Dual = Extract; const multiRegions: string[] = ['usa', 'eur']; -export const multiRegionChoices = [ +const multiRegionChoices = [ { name: 'USA', value: 'usa' }, { name: 'Europe', value: 'eur' }, ]; -export const singleRegionChoices = [ - { name: 'Amsterdam, Netherlands', value: 'ams' }, - { name: 'Frankfurt, Germany', value: 'fra' }, - { name: 'Sao Paulo, Brazil', value: 'gru' }, - { name: 'Ashburn, Virginia (US)', value: 'iad' }, - { name: 'Johannesburg, South Africa', value: 'jnb' }, - { name: 'London, United Kingdom', value: 'lhr' }, - { name: 'Tokyo, Japan', value: 'nrt' }, - { name: 'Chicago, Illinois (US)', value: 'ord' }, - { name: 'Singapore, Singapore', value: 'sin' }, - { name: 'San Jose, California (US)', value: 'sjc' }, - { name: 'Sydney, Australia', value: 'syd' }, +const singleRegionChoices = [ + { name: 'Amsterdam, Netherlands (AMS)', value: 'ams' }, + { name: 'Frankfurt, Germany (FRA)', value: 'fra' }, + { name: 'Sao Paulo, Brazil (GRU)', value: 'gru' }, + { name: 'Ashburn, Virginia (IAD)', value: 'iad' }, + { name: 'Johannesburg, South Africa (JNB)', value: 'jnb' }, + { name: 'London, United Kingdom (LHR)', value: 'lhr' }, + { name: 'Tokyo, Japan (NRT)', value: 'nrt' }, + { name: 'Chicago, Illinois (ORD)', value: 'ord' }, + { name: 'Singapore, Singapore (SIN)', value: 'sin' }, + { name: 'San Jose, California (SJC)', value: 'sjc' }, + { name: 'Sydney, Australia (SYD)', value: 'syd' }, ]; +async function promptRegion( + locationType: string +): Promise { + try { + if (locationType === 'multi') { + const { region } = await prompt<{ region: string }>({ + type: 'select', + name: 'region', + message: 'Multi-region:', + choices: multiRegionChoices.map((c) => ({ + name: c.value, + message: c.name, + })), + }); + return parseLocations(region); + } + + if (locationType === 'single') { + const { region } = await prompt<{ region: string }>({ + type: 'select', + name: 'region', + message: 'Region:', + choices: singleRegionChoices.map((c) => ({ + name: c.value, + message: c.name, + })), + }); + return parseLocations(region); + } + + // dual + const { regions } = await prompt<{ regions: string[] }>({ + type: 'multiselect', + name: 'regions', + message: + 'Press space key to select regions (multiple supported) and enter to confirm:', + choices: singleRegionChoices.map((c) => ({ + name: c.value, + message: c.name, + })), + } as Parameters[0]); + + if (regions.length < 2) { + throw new Error('Dual region requires at least two regions'); + } + + return parseLocations(regions); + } catch (err) { + // User pressed Escape — return null to go back + if (err === '') { + return null; + } + + throw err; + } +} + +export async function promptLocations(): Promise { + let locationType: string; + try { + ({ locationType } = await prompt<{ locationType: string }>({ + type: 'select', + name: 'locationType', + message: 'Location type:', + choices: [ + { name: 'global', message: 'Global' }, + { name: 'multi', message: 'Multi-region (USA or Europe)' }, + { name: 'dual', message: 'Dual region' }, + { name: 'single', message: 'Single region' }, + ], + })); + } catch { + throw new Error('Location selection cancelled'); + } + + if (locationType === 'global') { + return { type: 'global' }; + } + + const result = await promptRegion(locationType); + // Escape was pressed in sub-menu — go back to location type + return result ?? promptLocations(); +} + export function parseLocations(input: string | string[]): BucketLocations { const values = (Array.isArray(input) ? input : [input]) .flatMap((v) => v.split(','))