@francescomalatesta/laravel-forge-mcp 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/tools/backups/create-backup-configuration.js +89 -0
- package/dist/tools/backups/create-backup.js +56 -0
- package/dist/tools/backups/delete-backup-configuration.js +41 -0
- package/dist/tools/backups/delete-backup.js +42 -0
- package/dist/tools/backups/list-backup-configurations.js +55 -0
- package/dist/tools/backups/list-backups.js +49 -0
- package/dist/tools/backups/restore-backup.js +37 -0
- package/dist/tools/backups/shared.js +118 -0
- package/dist/tools/backups/update-backup-configuration.js +138 -0
- package/dist/tools/databases/create-database-user.js +65 -0
- package/dist/tools/databases/create-database.js +63 -0
- package/dist/tools/databases/delete-database-user.js +40 -0
- package/dist/tools/databases/delete-database.js +40 -0
- package/dist/tools/databases/list-database-users.js +49 -0
- package/dist/tools/databases/list-databases.js +49 -0
- package/dist/tools/databases/shared.js +61 -0
- package/dist/tools/databases/sync-databases.js +28 -0
- package/dist/tools/databases/update-database-root-password.js +32 -0
- package/dist/tools/databases/update-database-user.js +39 -0
- package/dist/tools/registry.js +45 -0
- package/dist/tools/shared/read.js +6 -0
- package/dist/tools/storage/create-storage-provider.js +54 -0
- package/dist/tools/storage/delete-storage-provider.js +37 -0
- package/dist/tools/storage/list-storage-providers.js +50 -0
- package/dist/tools/storage/shared.js +41 -0
- package/dist/tools/storage/update-storage-provider.js +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,6 +127,27 @@ npm run build
|
|
|
127
127
|
| `forge_set_php_opcache` | servers | Enable or disable OPcache |
|
|
128
128
|
| `forge_get_php_config` | servers | FPM or CLI php.ini, or the FPM pool configuration |
|
|
129
129
|
| `forge_update_php_config` | servers | Replace a PHP configuration file |
|
|
130
|
+
| `forge_list_databases` | databases | Database schemas on a server |
|
|
131
|
+
| `forge_create_database` | databases | Create a database (optionally with a user) and wait until installed |
|
|
132
|
+
| `forge_delete_database` | databases | Drop a database |
|
|
133
|
+
| `forge_sync_databases` | databases | Import databases created outside Forge |
|
|
134
|
+
| `forge_list_database_users` | databases | Database users on a server |
|
|
135
|
+
| `forge_create_database_user` | databases | Create a user with access to databases (by ID or name) |
|
|
136
|
+
| `forge_update_database_user` | databases | Change a user's password or databases |
|
|
137
|
+
| `forge_delete_database_user` | databases | Delete a database user |
|
|
138
|
+
| `forge_update_database_root_password` | databases | Change the database root password |
|
|
139
|
+
| `forge_list_backup_configurations` | databases | Scheduled database backups: storage, schedule, retention, next run |
|
|
140
|
+
| `forge_create_backup_configuration` | databases | Schedule backups of databases (by ID or name) to a storage provider |
|
|
141
|
+
| `forge_update_backup_configuration` | databases | Change storage, databases, schedule or retention |
|
|
142
|
+
| `forge_delete_backup_configuration` | databases | Stop scheduled backups |
|
|
143
|
+
| `forge_list_backups` | databases | Backups of a configuration with status and size |
|
|
144
|
+
| `forge_create_backup` | databases | Run a backup now and wait until it finishes |
|
|
145
|
+
| `forge_delete_backup` | databases | Delete a backup |
|
|
146
|
+
| `forge_restore_backup` | databases | Restore a database from a backup (overwrites it) |
|
|
147
|
+
| `forge_list_storage_providers` | storage | S3, Spaces, Hetzner, OVH, Scaleway or S3-compatible storage for backups |
|
|
148
|
+
| `forge_create_storage_provider` | storage | Add a storage provider (credentials are never returned) |
|
|
149
|
+
| `forge_update_storage_provider` | storage | Change name, location or credentials |
|
|
150
|
+
| `forge_delete_storage_provider` | storage | Remove a storage provider that no backup uses |
|
|
130
151
|
| `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
|
|
131
152
|
| `forge_get_deployment` | deployments | A deployment with the end of its log |
|
|
132
153
|
| `forge_get_deployment_status` | deployments | Whether a deployment is running |
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
6
|
+
import { databaseRefsInput, resolveDatabaseIds } from '../databases/shared.js';
|
|
7
|
+
import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration, newestFirst, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
|
|
8
|
+
const CHECK_WITH = 'forge_list_backup_configurations';
|
|
9
|
+
export const createBackupConfiguration = defineTool({
|
|
10
|
+
name: 'forge_create_backup_configuration',
|
|
11
|
+
title: 'Create backup configuration',
|
|
12
|
+
description: 'Schedule database backups on a server to a storage provider (S3, Spaces, …): which databases, how often, how many to keep. Waits until the configuration is installed unless `wait` is false. Run a backup immediately with forge_create_backup.',
|
|
13
|
+
toolset: 'databases',
|
|
14
|
+
operations: [
|
|
15
|
+
'organizations.servers.database.backups.store',
|
|
16
|
+
'organizations.servers.database.backups.index',
|
|
17
|
+
'organizations.servers.database.schemas.index',
|
|
18
|
+
'organizations.storage-providers.index',
|
|
19
|
+
],
|
|
20
|
+
permissions: ['server:create-backups', 'server:view', 'storage:manage'],
|
|
21
|
+
readOnly: false,
|
|
22
|
+
destructive: false,
|
|
23
|
+
idempotent: false,
|
|
24
|
+
async: true,
|
|
25
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
26
|
+
inputSchema: {
|
|
27
|
+
organization: organizationInput,
|
|
28
|
+
server: serverInput,
|
|
29
|
+
storage_provider: storageProviderRefInput,
|
|
30
|
+
databases: databaseRefsInput.min(1).describe('Databases to back up, by ID or name, e.g. [12, "shop"].'),
|
|
31
|
+
retention: z.number().int().min(1).max(8760).describe('Number of backups to keep (older ones are deleted).'),
|
|
32
|
+
...scheduleInput,
|
|
33
|
+
name: z.string().min(1).max(255).optional().describe('Name of the configuration.'),
|
|
34
|
+
include_new_databases: z.boolean().optional().describe('Also back up databases created later on this server.'),
|
|
35
|
+
bucket: z.string().min(1).optional().describe("Bucket, if different from the storage provider's."),
|
|
36
|
+
directory: z.string().min(1).optional().describe('Directory inside the bucket.'),
|
|
37
|
+
notification_email: z.string().email().optional().describe('Email notified when a backup fails.'),
|
|
38
|
+
...waitInput(120),
|
|
39
|
+
},
|
|
40
|
+
outputSchema: {
|
|
41
|
+
...operationOutput,
|
|
42
|
+
backup_configuration: backupConfigurationOutput.nullable().describe('The new configuration once it shows up.'),
|
|
43
|
+
},
|
|
44
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
45
|
+
const schedule = scheduleBody(args);
|
|
46
|
+
const org = organization(args.organization);
|
|
47
|
+
const storageProviderId = await resolveStorageProviderId(client, org, args.storage_provider, signal);
|
|
48
|
+
const databaseIds = await resolveDatabaseIds(client, org, args.server, args.databases, signal);
|
|
49
|
+
const base = backupConfigurationsPath(org, args.server);
|
|
50
|
+
// Forge returns no body: remember the existing configurations to spot the new one.
|
|
51
|
+
const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
|
|
52
|
+
await client.post(base, {
|
|
53
|
+
body: {
|
|
54
|
+
storage_provider_id: storageProviderId,
|
|
55
|
+
name: args.name,
|
|
56
|
+
bucket: args.bucket,
|
|
57
|
+
directory: args.directory,
|
|
58
|
+
...schedule,
|
|
59
|
+
include_new_databases: args.include_new_databases,
|
|
60
|
+
database_ids: databaseIds,
|
|
61
|
+
retention: args.retention,
|
|
62
|
+
notification_email: args.notification_email,
|
|
63
|
+
},
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
66
|
+
const action = `create the backup configuration${args.name ? ` ${args.name}` : ''}`;
|
|
67
|
+
if (!existing) {
|
|
68
|
+
return {
|
|
69
|
+
structured: { status: 'queued', check_with: CHECK_WITH, backup_configuration: null },
|
|
70
|
+
summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const result = await waitFor({
|
|
74
|
+
poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
|
|
75
|
+
phase: (configuration) => (configuration ? backupConfigurationPhase(configuration.status) : 'pending'),
|
|
76
|
+
describe: (configuration) => (configuration ? `Backup configuration is ${configuration.status}` : 'Waiting for the configuration to appear'),
|
|
77
|
+
timeoutSeconds: args.timeout_seconds,
|
|
78
|
+
context: { sleep, progress },
|
|
79
|
+
});
|
|
80
|
+
const configuration = result.value ? formatBackupConfiguration(result.value) : null;
|
|
81
|
+
const done = outcome(result, {
|
|
82
|
+
action,
|
|
83
|
+
checkWith: CHECK_WITH,
|
|
84
|
+
timeoutSeconds: args.timeout_seconds,
|
|
85
|
+
detail: configuration ? `Configuration ID: ${configuration.id}; next run: ${configuration.next_run_time}.` : undefined,
|
|
86
|
+
});
|
|
87
|
+
return { structured: { ...done.structured, backup_configuration: configuration }, summary: done.summary };
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
4
|
+
import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup, newestFirst, } from './shared.js';
|
|
5
|
+
const CHECK_WITH = 'forge_list_backups';
|
|
6
|
+
export const createBackup = defineTool({
|
|
7
|
+
name: 'forge_create_backup',
|
|
8
|
+
title: 'Run backup now',
|
|
9
|
+
description: 'Back up the databases of a backup configuration now, outside its schedule (e.g. before a risky deployment or migration). Waits until the backup finishes unless `wait` is false.',
|
|
10
|
+
toolset: 'databases',
|
|
11
|
+
operations: ['organizations.servers.database.backups.instances.store', 'organizations.servers.database.backups.instances.index'],
|
|
12
|
+
permissions: ['server:create-backups'],
|
|
13
|
+
readOnly: false,
|
|
14
|
+
destructive: false,
|
|
15
|
+
idempotent: false,
|
|
16
|
+
async: true,
|
|
17
|
+
notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
backup_configuration: backupConfigurationInput,
|
|
22
|
+
...waitInput(300),
|
|
23
|
+
},
|
|
24
|
+
outputSchema: {
|
|
25
|
+
...operationOutput,
|
|
26
|
+
backup: backupOutput.nullable().describe('The new backup once it shows up.'),
|
|
27
|
+
},
|
|
28
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
29
|
+
const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
|
|
30
|
+
// Forge returns no body: remember the existing backups to spot the new one.
|
|
31
|
+
const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
|
|
32
|
+
await client.post(base, { signal });
|
|
33
|
+
const action = `back up configuration ${args.backup_configuration}`;
|
|
34
|
+
if (!existing) {
|
|
35
|
+
return {
|
|
36
|
+
structured: { status: 'queued', check_with: CHECK_WITH, backup: null },
|
|
37
|
+
summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const result = await waitFor({
|
|
41
|
+
poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
|
|
42
|
+
phase: (backup) => (backup ? backupPhase(backup.status) : 'pending'),
|
|
43
|
+
describe: (backup) => (backup ? `Backup is ${backup.status}` : 'Waiting for the backup to start'),
|
|
44
|
+
timeoutSeconds: args.timeout_seconds,
|
|
45
|
+
context: { sleep, progress },
|
|
46
|
+
});
|
|
47
|
+
const backup = result.value ? formatBackup(result.value) : null;
|
|
48
|
+
const done = outcome(result, {
|
|
49
|
+
action,
|
|
50
|
+
checkWith: CHECK_WITH,
|
|
51
|
+
timeoutSeconds: args.timeout_seconds,
|
|
52
|
+
detail: backup ? `Backup ID: ${backup.id}.` : undefined,
|
|
53
|
+
});
|
|
54
|
+
return { structured: { ...done.structured, backup }, summary: done.summary };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { readResource } from '../shared/read.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupConfigurationPath } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_list_backup_configurations';
|
|
7
|
+
export const deleteBackupConfiguration = defineTool({
|
|
8
|
+
name: 'forge_delete_backup_configuration',
|
|
9
|
+
title: 'Delete backup configuration',
|
|
10
|
+
description: 'Stop scheduled backups by deleting a backup configuration. The databases are not touched.',
|
|
11
|
+
toolset: 'databases',
|
|
12
|
+
operations: ['organizations.servers.database.backups.destroy', 'organizations.servers.database.backups.show'],
|
|
13
|
+
permissions: ['server:delete-backups', 'server:view'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: true,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
backup_configuration: backupConfigurationInput,
|
|
23
|
+
...waitInput(60),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: operationOutput,
|
|
26
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
27
|
+
const path = backupConfigurationPath(organization(args.organization), args.server, args.backup_configuration);
|
|
28
|
+
await client.delete(path, { signal });
|
|
29
|
+
const action = `delete backup configuration ${args.backup_configuration}`;
|
|
30
|
+
if (!args.wait)
|
|
31
|
+
return queued(action, CHECK_WITH);
|
|
32
|
+
const result = await waitFor({
|
|
33
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
34
|
+
phase: (configuration) => (configuration === null ? 'completed' : 'pending'),
|
|
35
|
+
describe: (configuration) => `Backup configuration is ${configuration?.status ?? 'removing'}`,
|
|
36
|
+
timeoutSeconds: args.timeout_seconds,
|
|
37
|
+
context: { sleep, progress },
|
|
38
|
+
});
|
|
39
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { readResource } from '../shared/read.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { backupConfigurationInput, backupInput, backupsPath } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_list_backups';
|
|
7
|
+
export const deleteBackup = defineTool({
|
|
8
|
+
name: 'forge_delete_backup',
|
|
9
|
+
title: 'Delete backup',
|
|
10
|
+
description: 'Delete a backup from storage. It can no longer be restored.',
|
|
11
|
+
toolset: 'databases',
|
|
12
|
+
operations: ['organizations.servers.database.backups.instances.destroy', 'organizations.servers.database.backups.instances.show'],
|
|
13
|
+
permissions: ['server:delete-backups', 'server:create-backups'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: true,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: 'Check the backup configuration ID with forge_list_backup_configurations and the backup ID with forge_list_backups.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
backup_configuration: backupConfigurationInput,
|
|
23
|
+
backup: backupInput,
|
|
24
|
+
...waitInput(60),
|
|
25
|
+
},
|
|
26
|
+
outputSchema: operationOutput,
|
|
27
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
28
|
+
const path = `${backupsPath(organization(args.organization), args.server, args.backup_configuration)}/${encodeURIComponent(String(args.backup))}`;
|
|
29
|
+
await client.delete(path, { signal });
|
|
30
|
+
const action = `delete backup ${args.backup}`;
|
|
31
|
+
if (!args.wait)
|
|
32
|
+
return queued(action, CHECK_WITH);
|
|
33
|
+
const result = await waitFor({
|
|
34
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
35
|
+
phase: (backup) => (backup === null ? 'completed' : 'pending'),
|
|
36
|
+
describe: () => 'Waiting for the backup to be deleted',
|
|
37
|
+
timeoutSeconds: args.timeout_seconds,
|
|
38
|
+
context: { sleep, progress },
|
|
39
|
+
});
|
|
40
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
7
|
+
import { backupConfigurationInput, backupConfigurationOutput, backupConfigurationPath, backupConfigurationsPath, formatBackupConfiguration, } from './shared.js';
|
|
8
|
+
const SORT = ['name', '-name', 'created_at', '-created_at', 'updated_at', '-updated_at'];
|
|
9
|
+
export const listBackupConfigurations = defineTool({
|
|
10
|
+
name: 'forge_list_backup_configurations',
|
|
11
|
+
title: 'List backup configurations',
|
|
12
|
+
description: 'List the database backup configurations of a server (schedule, storage, databases, retention, next run), or get one with `backup_configuration`. Use forge_list_backups to see the backups it produced.',
|
|
13
|
+
toolset: 'databases',
|
|
14
|
+
operations: ['organizations.servers.database.backups.index', 'organizations.servers.database.backups.show'],
|
|
15
|
+
permissions: ['server:view'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
backup_configuration: backupConfigurationInput.optional().describe('Return only this backup configuration.'),
|
|
22
|
+
name: z.string().min(1).optional().describe('Filter by name.'),
|
|
23
|
+
status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
|
|
24
|
+
sort: z.array(z.enum(SORT)).min(1).optional(),
|
|
25
|
+
...paginationInput,
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
backup_configurations: z.array(backupConfigurationOutput),
|
|
29
|
+
...paginationOutput,
|
|
30
|
+
},
|
|
31
|
+
async handler(args, { client, organization, signal }) {
|
|
32
|
+
const org = organization(args.organization);
|
|
33
|
+
if (args.backup_configuration !== undefined) {
|
|
34
|
+
const configuration = formatBackupConfiguration(await readResource(client, backupConfigurationPath(org, args.server, args.backup_configuration), signal));
|
|
35
|
+
return {
|
|
36
|
+
structured: { backup_configurations: [configuration], next_cursor: null, has_more: false },
|
|
37
|
+
summary: `Backup configuration ${configuration.name} is ${configuration.status}; schedule: ${configuration.displayable_schedule}, next run: ${configuration.next_run_time}.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const response = await client.get(backupConfigurationsPath(org, args.server), {
|
|
41
|
+
query: { filter: { name: args.name, status: args.status }, sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
|
|
42
|
+
signal,
|
|
43
|
+
});
|
|
44
|
+
const page = flattenCollection(response.data);
|
|
45
|
+
const configurations = page.items.map(formatBackupConfiguration);
|
|
46
|
+
return {
|
|
47
|
+
structured: { backup_configurations: configurations, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
48
|
+
summary: configurations.length === 0
|
|
49
|
+
? 'No backup configurations found. Create one with forge_create_backup_configuration.'
|
|
50
|
+
: `Found ${configurations.length} backup configuration(s): ${configurations
|
|
51
|
+
.map((c) => `${c.name} (${c.id}, ${c.displayable_schedule})`)
|
|
52
|
+
.join(', ')}.${paginationSummary(page.nextCursor)}`,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupInput, backupOutput, backupsPath, formatBackup, } from './shared.js';
|
|
7
|
+
const SORT = ['created_at', '-created_at', 'updated_at', '-updated_at'];
|
|
8
|
+
export const listBackups = defineTool({
|
|
9
|
+
name: 'forge_list_backups',
|
|
10
|
+
title: 'List backups',
|
|
11
|
+
description: 'List the backups produced by a backup configuration (status, size, completion time), or get one with `backup`. Restore one with forge_restore_backup.',
|
|
12
|
+
toolset: 'databases',
|
|
13
|
+
operations: ['organizations.servers.database.backups.instances.index', 'organizations.servers.database.backups.instances.show'],
|
|
14
|
+
permissions: ['server:create-backups'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
organization: organizationInput,
|
|
19
|
+
server: serverInput,
|
|
20
|
+
backup_configuration: backupConfigurationInput,
|
|
21
|
+
backup: backupInput.optional().describe('Return only this backup.'),
|
|
22
|
+
status: z.string().min(1).optional().describe('Filter by status, e.g. "finished".'),
|
|
23
|
+
sort: z.array(z.enum(SORT)).min(1).optional().describe('Defaults to newest first.'),
|
|
24
|
+
...paginationInput,
|
|
25
|
+
},
|
|
26
|
+
outputSchema: {
|
|
27
|
+
backups: z.array(backupOutput),
|
|
28
|
+
...paginationOutput,
|
|
29
|
+
},
|
|
30
|
+
async handler(args, { client, organization, signal }) {
|
|
31
|
+
const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
|
|
32
|
+
if (args.backup !== undefined) {
|
|
33
|
+
const backup = formatBackup(await readResource(client, `${base}/${encodeURIComponent(String(args.backup))}`, signal));
|
|
34
|
+
return { structured: { backups: [backup], next_cursor: null, has_more: false }, summary: `Backup ${backup.id} is ${backup.status}.` };
|
|
35
|
+
}
|
|
36
|
+
const response = await client.get(base, {
|
|
37
|
+
query: { filter: { status: args.status }, sort: args.sort ?? ['-created_at'], page: { size: args.page_size, cursor: args.cursor } },
|
|
38
|
+
signal,
|
|
39
|
+
});
|
|
40
|
+
const page = flattenCollection(response.data);
|
|
41
|
+
const backups = page.items.map(formatBackup);
|
|
42
|
+
return {
|
|
43
|
+
structured: { backups, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
44
|
+
summary: backups.length === 0
|
|
45
|
+
? 'No backups found. Run one now with forge_create_backup.'
|
|
46
|
+
: `Found ${backups.length} backup(s); the first is ${backups[0].id} (${backups[0].status}).${paginationSummary(page.nextCursor)}`,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { operationOutput, queued } from '../shared/async.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { resolveDatabaseIds } from '../databases/shared.js';
|
|
6
|
+
import { backupConfigurationInput, backupInput, backupsPath } from './shared.js';
|
|
7
|
+
export const restoreBackup = defineTool({
|
|
8
|
+
name: 'forge_restore_backup',
|
|
9
|
+
title: 'Restore backup',
|
|
10
|
+
description: 'Restore a database from a backup. This OVERWRITES the current content of the database with the backup: data written since the backup is lost. Confirm with the user first and consider running forge_create_backup before.',
|
|
11
|
+
toolset: 'databases',
|
|
12
|
+
operations: ['organizations.servers.database.backups.instances.restores.store', 'organizations.servers.database.schemas.index'],
|
|
13
|
+
permissions: ['server:create-backups', 'server:view'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: false,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: 'Check the backup configuration ID with forge_list_backup_configurations and the backup ID with forge_list_backups.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
backup_configuration: backupConfigurationInput,
|
|
23
|
+
backup: backupInput,
|
|
24
|
+
database: z
|
|
25
|
+
.union([z.number().int().nonnegative(), z.string().min(1)])
|
|
26
|
+
.describe('Database to restore, by ID or name (it must be included in the backup).'),
|
|
27
|
+
},
|
|
28
|
+
outputSchema: operationOutput,
|
|
29
|
+
async handler(args, { client, organization, signal }) {
|
|
30
|
+
const org = organization(args.organization);
|
|
31
|
+
const [databaseId] = await resolveDatabaseIds(client, org, args.server, [args.database], signal);
|
|
32
|
+
const path = `${backupsPath(org, args.server, args.backup_configuration)}/${encodeURIComponent(String(args.backup))}/restores`;
|
|
33
|
+
await client.post(path, { body: { database_id: databaseId }, signal });
|
|
34
|
+
// The API exposes no restore status: Forge records the outcome as a server event.
|
|
35
|
+
return queued(`restore database ${args.database} from backup ${args.backup}`, 'forge_list_server_events');
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { ToolInputError } from '../errors.js';
|
|
4
|
+
import { phaseOf } from '../shared/async.js';
|
|
5
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
6
|
+
import { serverPath } from '../servers/shared.js';
|
|
7
|
+
import { storageProvidersPath } from '../storage/shared.js';
|
|
8
|
+
export function backupConfigurationsPath(org, server) {
|
|
9
|
+
return `${serverPath(org, server)}/database/backups`;
|
|
10
|
+
}
|
|
11
|
+
export function backupConfigurationPath(org, server, configuration) {
|
|
12
|
+
return `${backupConfigurationsPath(org, server)}/${encodeURIComponent(String(configuration))}`;
|
|
13
|
+
}
|
|
14
|
+
export function backupsPath(org, server, configuration) {
|
|
15
|
+
return `${backupConfigurationPath(org, server, configuration)}/instances`;
|
|
16
|
+
}
|
|
17
|
+
export const backupConfigurationInput = idInput('Backup configuration ID. Use forge_list_backup_configurations to find it.');
|
|
18
|
+
export const backupInput = idInput('Backup ID. Use forge_list_backups to find it.');
|
|
19
|
+
export const BACKUP_CONFIGURATION_NOT_FOUND_HINT = 'Check the server ID with forge_list_servers and the backup configuration ID with forge_list_backup_configurations.';
|
|
20
|
+
export const storageProviderRefInput = z
|
|
21
|
+
.union([z.number().int().nonnegative(), z.string().min(1)])
|
|
22
|
+
.describe('Storage provider (S3, Spaces, …) by ID or name. Use forge_list_storage_providers (storage toolset) to find it.');
|
|
23
|
+
export const FREQUENCIES = ['hourly', 'daily', 'weekly', 'custom'];
|
|
24
|
+
/** Backups start on the hour or half hour (server time). */
|
|
25
|
+
export const BACKUP_TIMES = Array.from({ length: 48 }, (_, i) => `${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`);
|
|
26
|
+
export const scheduleInput = {
|
|
27
|
+
frequency: z.enum(FREQUENCIES).describe('hourly, daily (at `time`), weekly (on `day` at `time`) or custom (`cron`).'),
|
|
28
|
+
day: z.number().int().min(0).max(6).optional().describe('Weekly backups only: day of the week, cron numbering (0 = Sunday … 6 = Saturday).'),
|
|
29
|
+
time: z.enum(BACKUP_TIMES).optional().describe('Daily and weekly backups only: time of day, on the hour or half hour, e.g. "03:30".'),
|
|
30
|
+
cron: z.string().min(1).optional().describe('Custom frequency only: cron expression, e.g. "0 */6 * * *".'),
|
|
31
|
+
};
|
|
32
|
+
/** Validates that schedule fields match the frequency and maps them to the request body. */
|
|
33
|
+
export function scheduleBody({ frequency, day, time, cron }) {
|
|
34
|
+
if (frequency === 'custom' && cron === undefined)
|
|
35
|
+
throw new ToolInputError('A custom frequency needs a `cron` expression.');
|
|
36
|
+
if (frequency !== 'custom' && cron !== undefined)
|
|
37
|
+
throw new ToolInputError('`cron` is only used with frequency "custom".');
|
|
38
|
+
if (frequency !== 'weekly' && day !== undefined)
|
|
39
|
+
throw new ToolInputError('`day` is only used with frequency "weekly".');
|
|
40
|
+
if ((frequency === 'hourly' || frequency === 'custom') && time !== undefined) {
|
|
41
|
+
throw new ToolInputError('`time` is only used with frequency "daily" or "weekly".');
|
|
42
|
+
}
|
|
43
|
+
return { frequency, day: day === undefined ? undefined : String(day), time, cron };
|
|
44
|
+
}
|
|
45
|
+
const CONFIGURATION_FIELDS = [
|
|
46
|
+
'id',
|
|
47
|
+
'name',
|
|
48
|
+
'status',
|
|
49
|
+
'storage_provider_id',
|
|
50
|
+
'provider',
|
|
51
|
+
'bucket',
|
|
52
|
+
'directory',
|
|
53
|
+
'displayable_schedule',
|
|
54
|
+
'next_run_time',
|
|
55
|
+
'schedule',
|
|
56
|
+
'day_of_week',
|
|
57
|
+
'time',
|
|
58
|
+
'cron_schedule',
|
|
59
|
+
'database_ids',
|
|
60
|
+
'include_new_databases',
|
|
61
|
+
'retention',
|
|
62
|
+
'notify_email',
|
|
63
|
+
];
|
|
64
|
+
export const backupConfigurationOutput = z.looseObject({
|
|
65
|
+
id: z.string(),
|
|
66
|
+
name: z.string().nullable(),
|
|
67
|
+
status: z.string().nullable().describe('e.g. installing, installed, removing.'),
|
|
68
|
+
storage_provider_id: z.number().nullable(),
|
|
69
|
+
provider: z.string().nullable(),
|
|
70
|
+
bucket: z.string().nullable(),
|
|
71
|
+
directory: z.string().nullable(),
|
|
72
|
+
displayable_schedule: z.string().nullable(),
|
|
73
|
+
next_run_time: z.string().nullable(),
|
|
74
|
+
schedule: z.string().nullable(),
|
|
75
|
+
day_of_week: z.number().nullable(),
|
|
76
|
+
time: z.string().nullable(),
|
|
77
|
+
cron_schedule: z.string().nullable(),
|
|
78
|
+
database_ids: z.array(z.unknown()).nullable(),
|
|
79
|
+
include_new_databases: z.boolean().nullable(),
|
|
80
|
+
retention: z.number().nullable().describe('Number of backups kept.'),
|
|
81
|
+
notify_email: z.string().nullable(),
|
|
82
|
+
});
|
|
83
|
+
export function formatBackupConfiguration(flat) {
|
|
84
|
+
return pick(flat, CONFIGURATION_FIELDS);
|
|
85
|
+
}
|
|
86
|
+
export function backupConfigurationPhase(status) {
|
|
87
|
+
return phaseOf(status, { pending: ['installing', 'updating', 'removing'], failed: ['failed'] });
|
|
88
|
+
}
|
|
89
|
+
export const backupOutput = z.looseObject({
|
|
90
|
+
id: z.string(),
|
|
91
|
+
status: z.string().nullable().describe('"finished" when the backup succeeded.'),
|
|
92
|
+
is_partial: z.unknown().nullable().describe('Whether some databases could not be backed up.'),
|
|
93
|
+
size: z.number().nullable().describe('Size in bytes.'),
|
|
94
|
+
finished_at: z.number().nullable().describe('Unix timestamp.'),
|
|
95
|
+
});
|
|
96
|
+
export function formatBackup(flat) {
|
|
97
|
+
return pick(flat, ['id', 'status', 'is_partial', 'size', 'finished_at']);
|
|
98
|
+
}
|
|
99
|
+
/** Only "finished" means the backup succeeded: any other value is still running. */
|
|
100
|
+
export function backupPhase(status) {
|
|
101
|
+
return phaseOf(status, { completed: ['finished'], failed: ['failed'] });
|
|
102
|
+
}
|
|
103
|
+
/** First page of a collection, newest first: used to spot items created by a write that returns no body. */
|
|
104
|
+
export async function newestFirst(client, path, signal) {
|
|
105
|
+
const response = await client.get(path, { query: { sort: ['-created_at'], page: { size: 100 } }, signal });
|
|
106
|
+
return flattenCollection(response.data).items;
|
|
107
|
+
}
|
|
108
|
+
/** Resolves a storage provider ID or name to its ID. */
|
|
109
|
+
export async function resolveStorageProviderId(client, org, ref, signal) {
|
|
110
|
+
if (typeof ref === 'number' || /^\d+$/.test(ref))
|
|
111
|
+
return Number(ref);
|
|
112
|
+
const response = await client.get(storageProvidersPath(org), { query: { page: { size: 100 } }, signal });
|
|
113
|
+
const match = flattenCollection(response.data).items.find((provider) => provider.name === ref);
|
|
114
|
+
if (!match) {
|
|
115
|
+
throw new ToolInputError(`Storage provider "${ref}" not found. Check the name with forge_list_storage_providers (storage toolset).`);
|
|
116
|
+
}
|
|
117
|
+
return Number(match.id);
|
|
118
|
+
}
|