From 005718818e69a2327a661998a9534d894e1f083c Mon Sep 17 00:00:00 2001 From: Peter Kota Date: Tue, 27 Oct 2026 07:25:39 +0100 Subject: [PATCH 1/3] feat: create preview apps, set preview app settings and manage process disks - pipelines preview create-app creates a preview app for a branch or pull request - pipelines preview enable and update take the preview app settings as JSON with --preview-app - apps processes disk update and delete attach, grow, move and detach a process disk - resources disk-types lists the disk sizes - apps processes get shows the disk --- README.md | 6 +++ src/commands/apps/processes/disk/delete.ts | 34 ++++++++++++++ src/commands/apps/processes/disk/index.ts | 10 +++++ src/commands/apps/processes/disk/update.ts | 42 +++++++++++++++++ src/commands/apps/processes/get.ts | 7 +++ src/commands/apps/processes/index.ts | 2 + src/commands/pipelines/preview/create-app.ts | 25 +++++++++++ src/commands/pipelines/preview/index.ts | 2 + src/commands/pipelines/preview/update.ts | 15 +++++++ src/commands/resources/disk-types.ts | 17 +++++++ src/commands/resources/index.ts | 2 + .../e2e/tests/application-deployments.test.ts | 41 +++++++++++++++++ tests/e2e/tests/pipelines.test.ts | 45 +++++++++++++++++++ tests/e2e/tests/resources.test.ts | 15 +++++++ tests/unit/commands/pipelines-preview.test.ts | 30 +++++++++++++ 15 files changed, 293 insertions(+) create mode 100644 src/commands/apps/processes/disk/delete.ts create mode 100644 src/commands/apps/processes/disk/index.ts create mode 100644 src/commands/apps/processes/disk/update.ts create mode 100644 src/commands/pipelines/preview/create-app.ts create mode 100644 src/commands/resources/disk-types.ts diff --git a/README.md b/README.md index bc32f46..55a719e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ sevalla apps processes create --name web --type web --resource-type --app-id --instances 3 sevalla apps processes delete --app-id sevalla apps processes trigger --app-id +sevalla apps processes disk update --app-id --resource-type --mount-path /var/lib/data +sevalla apps processes disk delete --app-id --confirm ``` ### Process Metrics @@ -403,6 +405,9 @@ sevalla pipelines stages apps remove sevalla pipelines preview enable sevalla pipelines preview disable sevalla pipelines preview update --auto-create-app true +sevalla pipelines preview update --preview-app "$(cat preview-app.json)" +sevalla pipelines preview create-app --branch feature/checkout +sevalla pipelines preview create-app --branch feature/checkout --pull-request 42 ``` ### Projects @@ -468,6 +473,7 @@ sevalla global-env-vars delete ```bash sevalla resources clusters sevalla resources db-types +sevalla resources disk-types sevalla resources process-types ``` diff --git a/src/commands/apps/processes/disk/delete.ts b/src/commands/apps/processes/disk/delete.ts new file mode 100644 index 0000000..ff6ad89 --- /dev/null +++ b/src/commands/apps/processes/disk/delete.ts @@ -0,0 +1,34 @@ +import { Command } from 'commander'; +import { createClient } from '../../../../client/api-client.ts'; +import { handleError } from '../../../../errors/handler.ts'; +import { jsonOption, resolveJsonMode, confirmOption } from '../../../../helpers/flags.ts'; +import { printJson, printSuccess } from '../../../../output/formatter.ts'; +import { startSpinner, stopSpinner } from '../../../../output/spinner.ts'; + +export const processDiskDeleteCommand = new Command('delete') + .description('Detach the disk from a process and delete its data') + .requiredOption('--app-id ', 'Application ID') + .argument('', 'Process ID') + .addOption(jsonOption()) + .addOption(confirmOption()) + .action(async (processId: string, opts: Record) => { + const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); + const json = resolveJsonMode(opts); + if (!opts['confirm']) { + console.error('Use --confirm to confirm deletion.'); + process.exit(1); + } + try { + if (!json) startSpinner('Deleting...'); + await client.delete(`/applications/${opts['appId']}/processes/${processId}/disk`); + stopSpinner(); + if (json) { + printJson({ deleted: true }); + } else { + printSuccess('Process disk detached.'); + } + } catch (error) { + stopSpinner(); + handleError(error, json); + } + }); diff --git a/src/commands/apps/processes/disk/index.ts b/src/commands/apps/processes/disk/index.ts new file mode 100644 index 0000000..ac1169a --- /dev/null +++ b/src/commands/apps/processes/disk/index.ts @@ -0,0 +1,10 @@ +import { Command } from 'commander'; +import { processDiskUpdateCommand } from './update.ts'; +import { processDiskDeleteCommand } from './delete.ts'; + +export function makeProcessDiskCommands(): Command { + const cmd = new Command('disk').description('Manage the disk of a process'); + cmd.addCommand(processDiskUpdateCommand); + cmd.addCommand(processDiskDeleteCommand); + return cmd; +} diff --git a/src/commands/apps/processes/disk/update.ts b/src/commands/apps/processes/disk/update.ts new file mode 100644 index 0000000..028aaa6 --- /dev/null +++ b/src/commands/apps/processes/disk/update.ts @@ -0,0 +1,42 @@ +import { Command } from 'commander'; +import { createClient } from '../../../../client/api-client.ts'; +import { handleError } from '../../../../errors/handler.ts'; +import { jsonOption, resolveJsonMode } from '../../../../helpers/flags.ts'; +import { printJson, printResource, printSuccess } from '../../../../output/formatter.ts'; +import { startSpinner, stopSpinner } from '../../../../output/spinner.ts'; + +export const processDiskUpdateCommand = new Command('update') + .description('Attach a disk to a process, or grow or move its disk') + .requiredOption('--app-id ', 'Application ID') + .argument('', 'Process ID') + .requiredOption( + '--resource-type ', + 'Disk resource type ID, which sets the size (see `resources disk-types`). A disk can grow but not shrink.', + ) + .requiredOption('--mount-path ', 'Path inside the container where the disk is mounted') + .addOption(jsonOption()) + .action(async (processId: string, opts: Record) => { + const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); + const json = resolveJsonMode(opts); + try { + if (!json) startSpinner('Updating...'); + const result = await client.put>( + `/applications/${opts['appId']}/processes/${processId}/disk`, + { resource_type_id: opts['resourceType'], mount_path: opts['mountPath'] }, + ); + stopSpinner(); + if (json) { + printJson(result); + } else { + printSuccess('Process disk updated.'); + printResource({ + 'Resource type': result['resource_type_name'], + 'Size (GiB)': result['size_gib'], + 'Mount path': result['mount_path'], + }); + } + } catch (error) { + stopSpinner(); + handleError(error, json); + } + }); diff --git a/src/commands/apps/processes/get.ts b/src/commands/apps/processes/get.ts index c354379..1c11b63 100644 --- a/src/commands/apps/processes/get.ts +++ b/src/commands/apps/processes/get.ts @@ -5,6 +5,12 @@ import { jsonOption, resolveJsonMode } from '../../../helpers/flags.ts'; import { printJson, printResource } from '../../../output/formatter.ts'; import { startSpinner, stopSpinner } from '../../../output/spinner.ts'; +function formatDisk(disk: unknown): string { + if (!disk || typeof disk !== 'object') return 'None'; + const { size_gib, mount_path } = disk as { size_gib?: number; mount_path?: string | null }; + return `${size_gib} GiB at ${mount_path}`; +} + export const processesGetCommand = new Command('get') .description('Get process details') .requiredOption('--app-id ', 'Application ID') @@ -27,6 +33,7 @@ export const processesGetCommand = new Command('get') Name: result['name'], Type: result['type'], Status: result['status'], + Disk: formatDisk(result['disk']), }); } } catch (error) { diff --git a/src/commands/apps/processes/index.ts b/src/commands/apps/processes/index.ts index 8589a73..a89f622 100644 --- a/src/commands/apps/processes/index.ts +++ b/src/commands/apps/processes/index.ts @@ -6,6 +6,7 @@ import { processesUpdateCommand } from './update.ts'; import { processesDeleteCommand } from './delete.ts'; import { processesTriggerCommand } from './trigger.ts'; import { makeProcessMetricsCommands } from './metrics/index.ts'; +import { makeProcessDiskCommands } from './disk/index.ts'; export function makeProcessesCommands(): Command { const cmd = new Command('processes').description('Manage application processes'); @@ -16,5 +17,6 @@ export function makeProcessesCommands(): Command { cmd.addCommand(processesDeleteCommand); cmd.addCommand(processesTriggerCommand); cmd.addCommand(makeProcessMetricsCommands()); + cmd.addCommand(makeProcessDiskCommands()); return cmd; } diff --git a/src/commands/pipelines/preview/create-app.ts b/src/commands/pipelines/preview/create-app.ts new file mode 100644 index 0000000..701c9b0 --- /dev/null +++ b/src/commands/pipelines/preview/create-app.ts @@ -0,0 +1,25 @@ +import { makeActionCommand } from '../../../helpers/command-factory.ts'; + +export function previewAppBody(opts: Record): Record { + const body: Record = { branch: opts['branch'] }; + if (opts['pullRequest'] !== undefined) body['pull_request_number'] = Number(opts['pullRequest']); + return body; +} + +export const previewCreateAppCommand = makeActionCommand<{ id: string; display_name: string }>({ + name: 'create-app', + description: 'Create a preview app for a branch or pull request from the preview app settings', + idArg: 'pipeline-id', + options: [ + { flags: '--branch ', description: 'Git branch to build and deploy', required: true }, + { + flags: '--pull-request ', + description: + 'Pull request the preview app belongs to; it is deleted when the pull request closes', + }, + ], + apiCall: (client, id, opts) => client.post(`/pipelines/${id}/preview/apps`, previewAppBody(opts)), + successMessage: (app) => + `Preview app ${app.display_name} created. Its first deployment has started.`, + displayFields: (app) => ({ ID: app.id, Name: app.display_name }), +}); diff --git a/src/commands/pipelines/preview/index.ts b/src/commands/pipelines/preview/index.ts index 52c9798..aded68b 100644 --- a/src/commands/pipelines/preview/index.ts +++ b/src/commands/pipelines/preview/index.ts @@ -2,11 +2,13 @@ import { Command } from 'commander'; import { previewEnableCommand } from './enable.ts'; import { previewDisableCommand } from './disable.ts'; import { previewUpdateCommand } from './update.ts'; +import { previewCreateAppCommand } from './create-app.ts'; export function makePreviewCommands(): Command { const cmd = new Command('preview').description('Manage pipeline preview'); cmd.addCommand(previewEnableCommand); cmd.addCommand(previewDisableCommand); cmd.addCommand(previewUpdateCommand); + cmd.addCommand(previewCreateAppCommand); return cmd; } diff --git a/src/commands/pipelines/preview/update.ts b/src/commands/pipelines/preview/update.ts index cb68066..8f5958d 100644 --- a/src/commands/pipelines/preview/update.ts +++ b/src/commands/pipelines/preview/update.ts @@ -13,6 +13,11 @@ export const previewOptions = [ flags: '--stale-app-days ', description: 'Days without deployment before an app is stale', }, + { + flags: '--preview-app ', + description: + 'Preview app settings as JSON: location, build, processes, disks, TCP proxies and environment variables', + }, ]; function parseBool(value: unknown, flag: string): boolean { @@ -22,6 +27,14 @@ function parseBool(value: unknown, flag: string): boolean { return value === 'true'; } +function parseJson(value: unknown, flag: string): unknown { + try { + return JSON.parse(String(value)); + } catch { + throw new SevallaCliError(`${flag} must be valid JSON.`); + } +} + export function previewBody(opts: Record): Record { const body: Record = {}; if (opts['repoUrl']) body['repo_url'] = opts['repoUrl']; @@ -30,6 +43,8 @@ export function previewBody(opts: Record): Record({ + name: 'disk-types', + description: 'List available disk resource types', + columns: [ + { header: 'ID', key: 'id' }, + { header: 'Name', key: 'name' }, + { header: 'Size (GiB)', key: 'size_gib' }, + ], + apiCall: (client) => client.get('/resources/disk-resource-types'), + paginated: false, +}); diff --git a/src/commands/resources/index.ts b/src/commands/resources/index.ts index 9860678..6404952 100644 --- a/src/commands/resources/index.ts +++ b/src/commands/resources/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { resourcesClustersCommand } from './clusters.ts'; import { resourcesDbTypesCommand } from './db-types.ts'; +import { resourcesDiskTypesCommand } from './disk-types.ts'; import { resourcesProcessTypesCommand } from './process-types.ts'; import { resourcesRbacPermissionsCommand } from './rbac-permissions.ts'; import { resourcesRbacRolesCommand } from './rbac-roles.ts'; @@ -9,6 +10,7 @@ export function makeResourcesCommand(): Command { const cmd = new Command('resources').description('List available resources'); cmd.addCommand(resourcesClustersCommand); cmd.addCommand(resourcesDbTypesCommand); + cmd.addCommand(resourcesDiskTypesCommand); cmd.addCommand(resourcesProcessTypesCommand); cmd.addCommand(resourcesRbacPermissionsCommand); cmd.addCommand(resourcesRbacRolesCommand); diff --git a/tests/e2e/tests/application-deployments.test.ts b/tests/e2e/tests/application-deployments.test.ts index dbaef8d..f9e956e 100644 --- a/tests/e2e/tests/application-deployments.test.ts +++ b/tests/e2e/tests/application-deployments.test.ts @@ -174,6 +174,47 @@ describe('application deployments and processes', () => { assert.equal(proc.id, processId, 'returned process id should match') }) + it('should attach, grow and detach a disk on the web process', async () => { + assert.ok(createdAppId, 'createdAppId must be set from before hook') + + const listRes = await sevalla(['apps', 'processes', 'list', '--app-id', createdAppId]) + assert.equal(listRes.exitCode, 0, `apps processes list failed: ${listRes.stderr}`) + const web = listRes.json().find((proc) => proc['type'] === 'web') + assert.ok(web, 'the app should have a web process') + + const typesRes = await sevalla(['resources', 'disk-types']) + assert.equal(typesRes.exitCode, 0, `resources disk-types failed: ${typesRes.stderr}`) + const [smallest, larger] = typesRes.json<{ id: string; size_gib: number }[]>() + assert.ok(smallest, 'at least one disk resource type must exist') + + const attachRes = await sevalla([ + 'apps', 'processes', 'disk', 'update', web.id, + '--app-id', createdAppId, + '--resource-type', smallest.id, + '--mount-path', '/var/lib/data', + ]) + assert.equal(attachRes.exitCode, 0, `apps processes disk update failed: ${attachRes.stderr}`) + assert.equal(attachRes.json<{ size_gib: number }>().size_gib, smallest.size_gib) + + if (larger) { + const growRes = await sevalla([ + 'apps', 'processes', 'disk', 'update', web.id, + '--app-id', createdAppId, + '--resource-type', larger.id, + '--mount-path', '/var/lib/data', + ]) + assert.equal(growRes.exitCode, 0, `growing the disk failed: ${growRes.stderr}`) + assert.equal(growRes.json<{ size_gib: number }>().size_gib, larger.size_gib) + } + + const getRes = await sevalla(['apps', 'processes', 'get', web.id, '--app-id', createdAppId]) + assert.equal(getRes.exitCode, 0, `apps processes get failed: ${getRes.stderr}`) + assert.equal(getRes.json<{ disk: { mount_path: string } | null }>().disk?.mount_path, '/var/lib/data') + + const detachRes = await sevalla(['apps', 'processes', 'disk', 'delete', web.id, '--app-id', createdAppId, '--confirm']) + assert.equal(detachRes.exitCode, 0, `apps processes disk delete failed: ${detachRes.stderr}`) + }) + it('should create a worker process', async () => { assert.ok(createdAppId, 'createdAppId must be set from before hook') diff --git a/tests/e2e/tests/pipelines.test.ts b/tests/e2e/tests/pipelines.test.ts index 8c13c1e..c11cf73 100644 --- a/tests/e2e/tests/pipelines.test.ts +++ b/tests/e2e/tests/pipelines.test.ts @@ -11,6 +11,7 @@ describe(RESOURCE, () => { let clusterId: string let stageId: string | undefined let appId: string | undefined + let previewAppId: string | undefined before(async () => { const projName = `e2e-pipe-proj-${Date.now()}` @@ -26,6 +27,9 @@ describe(RESOURCE, () => { }) after(async () => { + if (previewAppId) { + await safeDeleteApp(previewAppId) + } if (appId) { await safeDeleteApp(appId) } @@ -186,6 +190,47 @@ describe(RESOURCE, () => { assert.equal(res.exitCode, 0, `preview update failed: ${res.stderr}`) }) + it('should set the preview app settings', async () => { + assert.ok(createdId, 'No pipeline was created') + const typesRes = await sevalla(['resources', 'process-types']) + assert.equal(typesRes.exitCode, 0, `resources process-types failed: ${typesRes.stderr}`) + const [resourceType] = typesRes.json<{ id: string }[]>() + assert.ok(resourceType, 'at least one process resource type must exist') + + const previewApp = { + cluster_id: clusterId, + build_type: 'nixpacks', + processes: [ + { + display_name: 'web', + type: 'web', + port: 8080, + resource_type_id: resourceType.id, + scaling_strategy: { type: 'manual', config: { instanceCount: 1 } }, + }, + ], + environment_variables: [{ key: 'E2E_PREVIEW', value: 'true' }], + } + const res = await sevalla([ + RESOURCE, 'preview', 'update', createdId, + '--preview-app', JSON.stringify(previewApp), + ], { timeout: 60_000 }) + assert.equal(res.exitCode, 0, `preview update --preview-app failed: ${res.stderr}`) + }) + + it('should create a preview app for a branch', async () => { + assert.ok(createdId, 'No pipeline was created') + const res = await sevalla([ + RESOURCE, 'preview', 'create-app', createdId, + '--branch', 'main', + ], { timeout: 60_000 }) + assert.equal(res.exitCode, 0, `preview create-app failed: ${res.stderr}`) + const app = res.json<{ id: string; default_branch: string }>() + assert.ok(app.id, 'created preview app should have an id') + assert.equal(app.default_branch, 'main') + previewAppId = app.id + }) + it('should disable pipeline preview', async () => { assert.ok(createdId, 'No pipeline was created') const res = await sevalla([RESOURCE, 'preview', 'disable', createdId], { timeout: 60_000 }) diff --git a/tests/e2e/tests/resources.test.ts b/tests/e2e/tests/resources.test.ts index 71f7a02..d4850f8 100644 --- a/tests/e2e/tests/resources.test.ts +++ b/tests/e2e/tests/resources.test.ts @@ -49,6 +49,21 @@ describe('resources', () => { } }) + // --------------------------------------------------------------------------- + // List disk resource types + // --------------------------------------------------------------------------- + it('sevalla resources disk-types should return the disk sizes', async () => { + const res = await sevalla(['resources', 'disk-types']) + assert.equal(res.exitCode, 0, `resources disk-types failed: ${res.stderr}`) + const body = res.json>() + assert.ok(Array.isArray(body), 'expected an array of disk types') + assert.ok(body.length > 0, 'expected at least one disk resource type') + for (const diskType of body) { + assert.ok(typeof diskType.id === 'string', 'diskType.id should be a string') + assert.ok(typeof diskType.size_gib === 'number', 'diskType.size_gib should be a number') + } + }) + // --------------------------------------------------------------------------- // Non-paginated lists (endpoints without limit/offset) // --------------------------------------------------------------------------- diff --git a/tests/unit/commands/pipelines-preview.test.ts b/tests/unit/commands/pipelines-preview.test.ts index c2b8622..d7bc9cd 100644 --- a/tests/unit/commands/pipelines-preview.test.ts +++ b/tests/unit/commands/pipelines-preview.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { previewBody } from '../../../src/commands/pipelines/preview/update.ts'; +import { previewAppBody } from '../../../src/commands/pipelines/preview/create-app.ts'; describe('Pipeline preview body', () => { it('should map flags to spec fields', () => { @@ -26,4 +27,33 @@ describe('Pipeline preview body', () => { /--auto-create-app must be true or false/, ); }); + + it('should send --preview-app as the parsed preview_app object', () => { + const previewApp = { cluster_id: 'c1', processes: [{ type: 'web', port: 8080 }] }; + assert.deepEqual(previewBody({ previewApp: JSON.stringify(previewApp) }), { + preview_app: previewApp, + }); + }); + + it('should reject --preview-app values that are not JSON', () => { + assert.throws( + () => previewBody({ previewApp: '{cluster_id' }), + /--preview-app must be valid JSON/, + ); + }); +}); + +describe('Preview app body', () => { + it('should send the branch alone when no pull request is given', () => { + assert.deepEqual(previewAppBody({ branch: 'feature/checkout' }), { + branch: 'feature/checkout', + }); + }); + + it('should send the pull request as a number', () => { + assert.deepEqual(previewAppBody({ branch: 'feature/checkout', pullRequest: '42' }), { + branch: 'feature/checkout', + pull_request_number: 42, + }); + }); }); From 4d67baa8609ca1cb1387392ba8a6c13fffdc61af Mon Sep 17 00:00:00 2001 From: Peter Kota Date: Tue, 27 Oct 2026 07:33:35 +0100 Subject: [PATCH 2/3] fix: validate disk command IDs and the preview app pull request number - apps processes disk update and delete reject IDs with ?, #, / or other characters that would change the request path - pipelines preview create-app rejects a --pull-request that isn't a positive whole number - apps processes get leaves out the mount path when the disk has none - the e2e test records the preview app before asserting on it, so cleanup always runs --- src/commands/apps/processes/disk/delete.ts | 3 ++ src/commands/apps/processes/disk/update.ts | 3 ++ src/commands/apps/processes/get.ts | 2 +- src/commands/pipelines/preview/create-app.ts | 9 +++- tests/e2e/tests/pipelines.test.ts | 2 +- tests/unit/commands/pipelines-preview.test.ts | 9 ++++ tests/unit/commands/process-disk.test.ts | 53 +++++++++++++++++++ 7 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/unit/commands/process-disk.test.ts diff --git a/src/commands/apps/processes/disk/delete.ts b/src/commands/apps/processes/disk/delete.ts index ff6ad89..fa95bab 100644 --- a/src/commands/apps/processes/disk/delete.ts +++ b/src/commands/apps/processes/disk/delete.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { createClient } from '../../../../client/api-client.ts'; import { handleError } from '../../../../errors/handler.ts'; import { jsonOption, resolveJsonMode, confirmOption } from '../../../../helpers/flags.ts'; +import { validateId } from '../../../../helpers/validate.ts'; import { printJson, printSuccess } from '../../../../output/formatter.ts'; import { startSpinner, stopSpinner } from '../../../../output/spinner.ts'; @@ -19,6 +20,8 @@ export const processDiskDeleteCommand = new Command('delete') process.exit(1); } try { + validateId(processId, 'process-id'); + validateId(opts['appId'] as string, 'app-id'); if (!json) startSpinner('Deleting...'); await client.delete(`/applications/${opts['appId']}/processes/${processId}/disk`); stopSpinner(); diff --git a/src/commands/apps/processes/disk/update.ts b/src/commands/apps/processes/disk/update.ts index 028aaa6..8d8bffa 100644 --- a/src/commands/apps/processes/disk/update.ts +++ b/src/commands/apps/processes/disk/update.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { createClient } from '../../../../client/api-client.ts'; import { handleError } from '../../../../errors/handler.ts'; import { jsonOption, resolveJsonMode } from '../../../../helpers/flags.ts'; +import { validateId } from '../../../../helpers/validate.ts'; import { printJson, printResource, printSuccess } from '../../../../output/formatter.ts'; import { startSpinner, stopSpinner } from '../../../../output/spinner.ts'; @@ -19,6 +20,8 @@ export const processDiskUpdateCommand = new Command('update') const client = createClient({ apiUrl: opts['apiUrl'] as string | undefined }); const json = resolveJsonMode(opts); try { + validateId(processId, 'process-id'); + validateId(opts['appId'] as string, 'app-id'); if (!json) startSpinner('Updating...'); const result = await client.put>( `/applications/${opts['appId']}/processes/${processId}/disk`, diff --git a/src/commands/apps/processes/get.ts b/src/commands/apps/processes/get.ts index 1c11b63..efbba98 100644 --- a/src/commands/apps/processes/get.ts +++ b/src/commands/apps/processes/get.ts @@ -8,7 +8,7 @@ import { startSpinner, stopSpinner } from '../../../output/spinner.ts'; function formatDisk(disk: unknown): string { if (!disk || typeof disk !== 'object') return 'None'; const { size_gib, mount_path } = disk as { size_gib?: number; mount_path?: string | null }; - return `${size_gib} GiB at ${mount_path}`; + return mount_path ? `${size_gib} GiB at ${mount_path}` : `${size_gib} GiB`; } export const processesGetCommand = new Command('get') diff --git a/src/commands/pipelines/preview/create-app.ts b/src/commands/pipelines/preview/create-app.ts index 701c9b0..6381693 100644 --- a/src/commands/pipelines/preview/create-app.ts +++ b/src/commands/pipelines/preview/create-app.ts @@ -1,8 +1,15 @@ import { makeActionCommand } from '../../../helpers/command-factory.ts'; +import { SevallaCliError } from '../../../errors/cli-error.ts'; export function previewAppBody(opts: Record): Record { const body: Record = { branch: opts['branch'] }; - if (opts['pullRequest'] !== undefined) body['pull_request_number'] = Number(opts['pullRequest']); + if (opts['pullRequest'] !== undefined) { + const pullRequest = Number(opts['pullRequest']); + if (!Number.isInteger(pullRequest) || pullRequest < 1) { + throw new SevallaCliError('--pull-request must be a pull request number.'); + } + body['pull_request_number'] = pullRequest; + } return body; } diff --git a/tests/e2e/tests/pipelines.test.ts b/tests/e2e/tests/pipelines.test.ts index c11cf73..3cac797 100644 --- a/tests/e2e/tests/pipelines.test.ts +++ b/tests/e2e/tests/pipelines.test.ts @@ -226,9 +226,9 @@ describe(RESOURCE, () => { ], { timeout: 60_000 }) assert.equal(res.exitCode, 0, `preview create-app failed: ${res.stderr}`) const app = res.json<{ id: string; default_branch: string }>() + previewAppId = app.id assert.ok(app.id, 'created preview app should have an id') assert.equal(app.default_branch, 'main') - previewAppId = app.id }) it('should disable pipeline preview', async () => { diff --git a/tests/unit/commands/pipelines-preview.test.ts b/tests/unit/commands/pipelines-preview.test.ts index d7bc9cd..f195f25 100644 --- a/tests/unit/commands/pipelines-preview.test.ts +++ b/tests/unit/commands/pipelines-preview.test.ts @@ -56,4 +56,13 @@ describe('Preview app body', () => { pull_request_number: 42, }); }); + + it('should reject a pull request that is not a positive whole number', () => { + for (const pullRequest of ['PR-42', 'https://github.com/a/b/pull/42', '0', '4.2']) { + assert.throws( + () => previewAppBody({ branch: 'main', pullRequest }), + /--pull-request must be a pull request number/, + ); + } + }); }); diff --git a/tests/unit/commands/process-disk.test.ts b/tests/unit/commands/process-disk.test.ts new file mode 100644 index 0000000..c936755 --- /dev/null +++ b/tests/unit/commands/process-disk.test.ts @@ -0,0 +1,53 @@ +import { describe, it, mock, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { processDiskDeleteCommand } from '../../../src/commands/apps/processes/disk/delete.ts'; +import { processDiskUpdateCommand } from '../../../src/commands/apps/processes/disk/update.ts'; + +describe('Process disk commands', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + process.env['SEVALLA_API_TOKEN'] = 'tok'; + fetchMock = mock.fn(async () => new Response('{}', { status: 200 })); + mock.method(globalThis, 'fetch', fetchMock); + mock.method(process, 'exit', () => { + throw new Error('exit'); + }); + mock.method(console, 'error', () => {}); + mock.method(console, 'log', () => {}); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + for (const [processId, appId] of [ + ['p1#', 'a1'], + ['p1', 'a1?x'], + ['p1/../..', 'a1'], + ]) { + it(`should send no request for process ${processId} and app ${appId}`, async () => { + await assert.rejects(() => + processDiskDeleteCommand.parseAsync([processId, '--app-id', appId, '--confirm', '--json'], { + from: 'user', + }), + ); + await assert.rejects(() => + processDiskUpdateCommand.parseAsync( + [ + processId, + '--app-id', + appId, + '--resource-type', + 'd1', + '--mount-path', + '/var/lib/data', + '--json', + ], + { from: 'user' }, + ), + ); + assert.equal(fetchMock.mock.callCount(), 0); + }); + } +}); From 7a57335213b4ad48dcce8815146f80bf6079d7b5 Mon Sep 17 00:00:00 2001 From: Peter Kota Date: Tue, 27 Oct 2026 07:36:13 +0100 Subject: [PATCH 3/3] test: sort disk types by size before the grow step --- tests/e2e/tests/application-deployments.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tests/application-deployments.test.ts b/tests/e2e/tests/application-deployments.test.ts index f9e956e..8e75a60 100644 --- a/tests/e2e/tests/application-deployments.test.ts +++ b/tests/e2e/tests/application-deployments.test.ts @@ -184,7 +184,7 @@ describe('application deployments and processes', () => { const typesRes = await sevalla(['resources', 'disk-types']) assert.equal(typesRes.exitCode, 0, `resources disk-types failed: ${typesRes.stderr}`) - const [smallest, larger] = typesRes.json<{ id: string; size_gib: number }[]>() + const [smallest, larger] = typesRes.json<{ id: string; size_gib: number }[]>().sort((a, b) => a.size_gib - b.size_gib) assert.ok(smallest, 'at least one disk resource type must exist') const attachRes = await sevalla([