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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ sevalla apps processes create <app-id> --name web --type web --resource-type <id
sevalla apps processes update <process-id> --app-id <id> --instances 3
sevalla apps processes delete <process-id> --app-id <id>
sevalla apps processes trigger <process-id> --app-id <id>
sevalla apps processes disk update <process-id> --app-id <id> --resource-type <id> --mount-path /var/lib/data
sevalla apps processes disk delete <process-id> --app-id <id> --confirm
```

### Process Metrics
Expand Down Expand Up @@ -403,6 +405,9 @@ sevalla pipelines stages apps remove <pipeline-id> <stage-id> <app-id>
sevalla pipelines preview enable <pipeline-id>
sevalla pipelines preview disable <pipeline-id>
sevalla pipelines preview update <pipeline-id> --auto-create-app true
sevalla pipelines preview update <pipeline-id> --preview-app "$(cat preview-app.json)"
sevalla pipelines preview create-app <pipeline-id> --branch feature/checkout
sevalla pipelines preview create-app <pipeline-id> --branch feature/checkout --pull-request 42
```

### Projects
Expand Down Expand Up @@ -468,6 +473,7 @@ sevalla global-env-vars delete <id>
```bash
sevalla resources clusters
sevalla resources db-types
sevalla resources disk-types
sevalla resources process-types
```

Expand Down
37 changes: 37 additions & 0 deletions src/commands/apps/processes/disk/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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';

export const processDiskDeleteCommand = new Command('delete')
.description('Detach the disk from a process and delete its data')
.requiredOption('--app-id <id>', 'Application ID')
.argument('<process-id>', 'Process ID')
.addOption(jsonOption())
.addOption(confirmOption())
.action(async (processId: string, opts: Record<string, unknown>) => {
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 {
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();
if (json) {
printJson({ deleted: true });
} else {
printSuccess('Process disk detached.');
}
} catch (error) {
stopSpinner();
handleError(error, json);
}
});
10 changes: 10 additions & 0 deletions src/commands/apps/processes/disk/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions src/commands/apps/processes/disk/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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';

export const processDiskUpdateCommand = new Command('update')
.description('Attach a disk to a process, or grow or move its disk')
.requiredOption('--app-id <id>', 'Application ID')
.argument('<process-id>', 'Process ID')
.requiredOption(
'--resource-type <id>',
'Disk resource type ID, which sets the size (see `resources disk-types`). A disk can grow but not shrink.',
)
.requiredOption('--mount-path <path>', 'Path inside the container where the disk is mounted')
.addOption(jsonOption())
.action(async (processId: string, opts: Record<string, unknown>) => {
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<Record<string, unknown>>(
`/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);
}
});
7 changes: 7 additions & 0 deletions src/commands/apps/processes/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 mount_path ? `${size_gib} GiB at ${mount_path}` : `${size_gib} GiB`;
}

export const processesGetCommand = new Command('get')
.description('Get process details')
.requiredOption('--app-id <id>', 'Application ID')
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/apps/processes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -16,5 +17,6 @@ export function makeProcessesCommands(): Command {
cmd.addCommand(processesDeleteCommand);
cmd.addCommand(processesTriggerCommand);
cmd.addCommand(makeProcessMetricsCommands());
cmd.addCommand(makeProcessDiskCommands());
return cmd;
}
32 changes: 32 additions & 0 deletions src/commands/pipelines/preview/create-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { makeActionCommand } from '../../../helpers/command-factory.ts';
import { SevallaCliError } from '../../../errors/cli-error.ts';

export function previewAppBody(opts: Record<string, unknown>): Record<string, unknown> {
const body: Record<string, unknown> = { branch: opts['branch'] };
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;
}

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 <branch>', description: 'Git branch to build and deploy', required: true },
{
flags: '--pull-request <number>',
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 }),
});
2 changes: 2 additions & 0 deletions src/commands/pipelines/preview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions src/commands/pipelines/preview/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const previewOptions = [
flags: '--stale-app-days <days>',
description: 'Days without deployment before an app is stale',
},
{
flags: '--preview-app <json>',
description:
'Preview app settings as JSON: location, build, processes, disks, TCP proxies and environment variables',
},
];

function parseBool(value: unknown, flag: string): boolean {
Expand All @@ -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<string, unknown>): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (opts['repoUrl']) body['repo_url'] = opts['repoUrl'];
Expand All @@ -30,6 +43,8 @@ export function previewBody(opts: Record<string, unknown>): Record<string, unkno
if (opts['deleteStaleApps'] !== undefined)
body['delete_stale_apps'] = parseBool(opts['deleteStaleApps'], '--delete-stale-apps');
if (opts['staleAppDays'] !== undefined) body['stale_app_days'] = Number(opts['staleAppDays']);
if (opts['previewApp'] !== undefined)
body['preview_app'] = parseJson(opts['previewApp'], '--preview-app');
return body;
}

Expand Down
17 changes: 17 additions & 0 deletions src/commands/resources/disk-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { makeListCommand } from '../../helpers/command-factory.ts';

export const resourcesDiskTypesCommand = makeListCommand<{
id: string;
name: string;
size_gib: number;
}>({
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,
});
2 changes: 2 additions & 0 deletions src/commands/resources/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions tests/e2e/tests/application-deployments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Process[]>().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 }[]>().sort((a, b) => a.size_gib - b.size_gib)
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')

Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/tests/pipelines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`
Expand All @@ -26,6 +27,9 @@ describe(RESOURCE, () => {
})

after(async () => {
if (previewAppId) {
await safeDeleteApp(previewAppId)
}
if (appId) {
await safeDeleteApp(appId)
}
Expand Down Expand Up @@ -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 }>()
previewAppId = app.id
assert.ok(app.id, 'created preview app should have an id')
assert.equal(app.default_branch, 'main')
})

it('should disable pipeline preview', async () => {
assert.ok(createdId, 'No pipeline was created')
const res = await sevalla([RESOURCE, 'preview', 'disable', createdId], { timeout: 60_000 })
Expand Down
Loading
Loading