@stacksjs/ts-cloud 0.7.93 → 0.7.95

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.
Files changed (71) hide show
  1. package/dist/bin/cli.js +1188 -1184
  2. package/dist/bin/dashboard-server.js +413 -409
  3. package/dist/{chunk-y5wrqyyz.js → chunk-3d4204h1.js} +365 -1
  4. package/dist/{chunk-fbwcv2vf.js → chunk-xp3txtg9.js} +458 -42
  5. package/dist/deploy/dashboard-database.d.ts +91 -3
  6. package/dist/deploy/dashboard-vitess.d.ts +154 -0
  7. package/dist/deploy/dashboard-vitess.test.d.ts +1 -0
  8. package/dist/deploy/index.js +2 -2
  9. package/dist/drivers/hetzner/client.d.ts +96 -0
  10. package/dist/drivers/hetzner/migrate-server.d.ts +124 -0
  11. package/dist/drivers/hetzner/migrate-server.test.d.ts +1 -0
  12. package/dist/drivers/hetzner/role-swap.d.ts +139 -0
  13. package/dist/drivers/hetzner/role-swap.test.d.ts +1 -0
  14. package/dist/drivers/index.js +1 -1
  15. package/dist/drivers/shared/db-provision.d.ts +4 -0
  16. package/dist/drivers/shared/deploy-script.d.ts +17 -1
  17. package/dist/drivers/shared/package-manager.d.ts +2 -0
  18. package/dist/drivers/shared/releases.d.ts +41 -2
  19. package/dist/drivers/shared/vitess-provision.d.ts +133 -0
  20. package/dist/drivers/shared/vitess-provision.test.d.ts +1 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +6 -2
  23. package/dist/ui/access-denied.html +2 -2
  24. package/dist/ui/account/automation.html +3 -3
  25. package/dist/ui/applications/compose.html +4 -4
  26. package/dist/ui/applications/new.html +2 -2
  27. package/dist/ui/data/backups.html +4 -4
  28. package/dist/ui/data/services.html +4 -4
  29. package/dist/ui/data/volumes.html +4 -4
  30. package/dist/ui/index.html +4 -4
  31. package/dist/ui/integrations.html +2 -2
  32. package/dist/ui/operations/alerts.html +4 -4
  33. package/dist/ui/operations/configuration.html +4 -4
  34. package/dist/ui/operations/jobs.html +4 -4
  35. package/dist/ui/operations/maintenance.html +4 -4
  36. package/dist/ui/operations/observability.html +4 -4
  37. package/dist/ui/operations/previews.html +4 -4
  38. package/dist/ui/operations/queue.html +4 -4
  39. package/dist/ui/operations/regions.html +4 -4
  40. package/dist/ui/operations/releases.html +4 -4
  41. package/dist/ui/operations/workloads.html +4 -4
  42. package/dist/ui/security.html +2 -2
  43. package/dist/ui/server/actions.html +4 -4
  44. package/dist/ui/server/activity.html +2 -2
  45. package/dist/ui/server/capacity.html +3 -3
  46. package/dist/ui/server/database.html +123 -7
  47. package/dist/ui/server/deployments.html +4 -4
  48. package/dist/ui/server/firewall.html +4 -4
  49. package/dist/ui/server/fleet.html +4 -4
  50. package/dist/ui/server/logs.html +4 -4
  51. package/dist/ui/server/metrics.html +4 -4
  52. package/dist/ui/server/security.html +2 -2
  53. package/dist/ui/server/services.html +2 -2
  54. package/dist/ui/server/sites.html +4 -4
  55. package/dist/ui/server/ssh-keys.html +4 -4
  56. package/dist/ui/server/team.html +4 -4
  57. package/dist/ui/server/terminal.html +2 -2
  58. package/dist/ui/serverless/alarms.html +4 -4
  59. package/dist/ui/serverless/assets.html +2 -2
  60. package/dist/ui/serverless/cost.html +2 -2
  61. package/dist/ui/serverless/data.html +4 -4
  62. package/dist/ui/serverless/firewall.html +2 -2
  63. package/dist/ui/serverless/functions.html +4 -4
  64. package/dist/ui/serverless/logs.html +4 -4
  65. package/dist/ui/serverless/metrics.html +2 -2
  66. package/dist/ui/serverless/queues.html +4 -4
  67. package/dist/ui/serverless/secrets.html +4 -4
  68. package/dist/ui/serverless/traces.html +4 -4
  69. package/dist/ui/serverless.html +4 -4
  70. package/dist/ui-src/pages/server/database.stx +257 -9
  71. package/package.json +3 -3
@@ -5,11 +5,71 @@
5
5
  * path in {@link import('../drivers/shared/db-provision')}.
6
6
  */
7
7
  import type { CloudConfig, DatabaseConfig, EnvironmentType } from '@ts-cloud/core';
8
- export type DbEngine = 'mysql' | 'mariadb' | 'postgres';
8
+ export type DbEngine = 'mysql' | 'mariadb' | 'postgres' | 'singlestore' | 'vitess';
9
+ /** Whether this engine is an external cluster with no on-box socket. */
10
+ export declare function isExternalEngine(engine: DbEngine): boolean;
9
11
  /** Valid SQL identifier for a database/user name (kept strict for safety). */
10
12
  export declare function isValidDbIdentifier(value: string): boolean;
11
13
  export declare function resolveDbEngine(config: CloudConfig): DbEngine;
12
14
  export declare function buildListScript(engine: DbEngine, database?: DatabaseConfig): string[];
15
+ /**
16
+ * Run SQL against an external MySQL-wire cluster over TCP.
17
+ *
18
+ * Executed from the app box, which by definition can already reach the
19
+ * cluster (the application connects to it), so this needs no extra network
20
+ * path. The password goes through `MYSQL_PWD` rather than `-p<pass>`
21
+ * because a command-line password is visible to any `ps` on the box; this
22
+ * mirrors how `pgAdminCommand` handles `PGPASSWORD`.
23
+ */
24
+ export declare function externalMysqlExec(sql: string[], database: DatabaseConfig | undefined, port: number): string[];
25
+ /** vtgate's MySQL-protocol port. Not 3306, which would reach a tablet's mysqld. */
26
+ export declare const VTGATE_DEFAULT_PORT = 15306;
27
+ /**
28
+ * List a Vitess cluster's keyspaces in the same `DB=` shape the on-box
29
+ * engines emit, so {@link parseDbList} handles it unchanged.
30
+ *
31
+ * A keyspace is Vitess's unit of sharding and occupies the same slot a
32
+ * database does elsewhere, which makes it the honest thing to show in the
33
+ * dashboard's "Databases" list. Users are deliberately not listed: vtgate
34
+ * authentication is configured on the cluster, not through SQL, so there is
35
+ * nothing here to enumerate or create.
36
+ */
37
+ export declare function buildVitessListScript(database?: DatabaseConfig): string[];
38
+ /**
39
+ * Introspect a Vitess cluster: keyspaces, shards, and tablet health.
40
+ *
41
+ * These are vtgate's own `SHOW` commands, served over the MySQL protocol,
42
+ * so they need nothing beyond the connection the app already uses. Each row
43
+ * is prefixed so one round trip can carry all three lists.
44
+ */
45
+ export declare function buildVitessTopologyScript(database?: DatabaseConfig): string[];
46
+ export interface VitessShard {
47
+ keyspace: string;
48
+ shard: string;
49
+ }
50
+ export interface VitessTablet {
51
+ cell: string;
52
+ keyspace: string;
53
+ shard: string;
54
+ type: string;
55
+ state: string;
56
+ alias: string;
57
+ hostname: string;
58
+ }
59
+ export interface VitessTopology {
60
+ keyspaces: string[];
61
+ shards: VitessShard[];
62
+ tablets: VitessTablet[];
63
+ }
64
+ /**
65
+ * Parse the combined topology output.
66
+ *
67
+ * `SHOW VITESS_SHARDS` emits `keyspace/shard` rows and `SHOW VITESS_TABLETS`
68
+ * emits tab-separated columns, so the two are told apart by shape rather
69
+ * than by position — the command output order is not something to depend on
70
+ * when a cluster can legitimately return zero rows for either.
71
+ */
72
+ export declare function parseVitessTopology(output: string): VitessTopology;
13
73
  export declare function buildCreateDatabaseScript(engine: DbEngine, name: string, database?: DatabaseConfig): string[];
14
74
  export interface CreateUserInput {
15
75
  username: string;
@@ -22,12 +82,13 @@ export declare function parseDbList(output: string): {
22
82
  databases: string[];
23
83
  users: string[];
24
84
  };
25
- interface DbRunResult {
85
+ export interface DbRunResult {
26
86
  ok: boolean;
27
87
  stdout?: string;
28
88
  stderr?: string;
29
89
  error?: string;
30
90
  }
91
+ export declare function runDb(config: CloudConfig, environment: EnvironmentType, commands: string[], comment: string): Promise<DbRunResult>;
31
92
  export declare function listDatabases(config: CloudConfig, environment: EnvironmentType): Promise<DbRunResult & {
32
93
  engine: DbEngine;
33
94
  databases: string[];
@@ -62,4 +123,31 @@ export declare function listDatabaseBackups(config: CloudConfig, environment: En
62
123
  database: string;
63
124
  }>;
64
125
  }>;
65
- export {};
126
+ /**
127
+ * Read a Vitess cluster's topology for the dashboard.
128
+ *
129
+ * Read-only by design. Creating keyspaces, resharding, and moving tables are
130
+ * vtctld operations with real blast radius, and exposing them behind a
131
+ * dashboard button would invite someone to reshard production by accident.
132
+ * The panel shows what exists and where it is unhealthy; changing the
133
+ * topology stays with `vtctldclient`.
134
+ */
135
+ export declare function describeVitess(config: CloudConfig, environment: EnvironmentType): Promise<DbRunResult & VitessTopology & {
136
+ engine: DbEngine;
137
+ }>;
138
+ /**
139
+ * Tablets that are not serving.
140
+ *
141
+ * Surfaced separately because it is the one thing in the topology worth
142
+ * acting on: a keyspace whose primary is missing is a write outage, and a
143
+ * flat list of a hundred healthy tablets buries that.
144
+ */
145
+ export declare function unhealthyTablets(tablets: VitessTablet[]): VitessTablet[];
146
+ /**
147
+ * Shards with no PRIMARY tablet.
148
+ *
149
+ * Vitess routes writes to a shard's primary; without one the shard accepts
150
+ * reads and silently fails writes, which is the failure most worth naming
151
+ * explicitly in a dashboard.
152
+ */
153
+ export declare function shardsMissingPrimary(topology: VitessTopology): VitessShard[];
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Vitess control plane: keyspaces, VSchema, and online schema changes.
3
+ *
4
+ * ## Two planes, two transports
5
+ *
6
+ * A Vitess cluster is administered through two different endpoints, and
7
+ * conflating them is the first thing that goes wrong:
8
+ *
9
+ * - **vtgate** speaks the MySQL wire protocol and serves queries. It also
10
+ * answers a surprising amount of observability (`SHOW VITESS_SHARDS`,
11
+ * `SHOW VITESS_MIGRATIONS`) and can even run online DDL, all over the
12
+ * connection the application already holds. Nothing extra is needed.
13
+ * - **vtctld** speaks gRPC and owns the topology. Creating a keyspace and
14
+ * applying a VSchema are only possible here, which is why those operations
15
+ * need `vitess.vtctldAddr` configured and the `vtctldclient` binary on the
16
+ * box.
17
+ *
18
+ * Everything in this module is written to prefer vtgate, because that path
19
+ * needs no extra binary, no extra network hole, and no extra credential. Only
20
+ * the two genuinely topology-level operations reach for vtctldclient.
21
+ *
22
+ * ## Why online DDL is the default
23
+ *
24
+ * A `direct` schema change locks the table on every shard at once. On a
25
+ * sharded keyspace that is an outage, not a migration. Vitess's own online
26
+ * DDL applies shard by shard, is revertible, and reports progress, so it is
27
+ * the default here and `direct` has to be asked for explicitly.
28
+ */
29
+ import type { CloudConfig, DatabaseConfig, EnvironmentType, VitessControlPlaneConfig } from '@ts-cloud/core';
30
+ import type { DbRunResult } from './dashboard-database';
31
+ /** Where `vtctldclient` is installed, matching the pantry CLI's location. */
32
+ export declare const VTCTLDCLIENT_BIN = "/usr/local/bin/vtctldclient";
33
+ /** Pinned default. See `VitessControlPlaneConfig.clientVersion` for why. */
34
+ export declare const DEFAULT_VTCTLDCLIENT_VERSION = "21.0.0";
35
+ export declare function resolveVitessConfig(config: CloudConfig): VitessControlPlaneConfig;
36
+ /**
37
+ * A keyspace or table identifier accepted by Vitess.
38
+ *
39
+ * Deliberately stricter than Vitess itself: these values are interpolated
40
+ * into a shell command line, so anything outside this set is rejected rather
41
+ * than escaped. A name that legitimately needs more than this is rare enough
42
+ * to be worth doing by hand.
43
+ */
44
+ export declare function isValidKeyspaceName(value: string): boolean;
45
+ /**
46
+ * A Vitess online-DDL migration UUID, e.g. `a1b2c3d4_1234_5678_9abc_def012345678`.
47
+ *
48
+ * Same reasoning as above: the UUID reaches a SQL string literal, and
49
+ * matching the exact shape is cheaper than reasoning about escaping.
50
+ */
51
+ export declare function isValidMigrationUuid(value: string): boolean;
52
+ /**
53
+ * Install `vtctldclient` from its GitHub release.
54
+ *
55
+ * Idempotent, and a no-op when the pinned version is already present so a
56
+ * re-provision does not re-download. Follows the same shape as the pantry
57
+ * CLI bootstrap: curl the release tarball, extract one binary, mark it
58
+ * executable.
59
+ *
60
+ * Pinned rather than "latest" on purpose. vtctldclient talks gRPC to vtctld
61
+ * and Vitess supports only a bounded version skew between them, so tracking
62
+ * latest means the control plane breaks the day the cluster is upgraded, at
63
+ * a moment nobody is expecting it to.
64
+ */
65
+ export declare function buildVtctldClientInstallScript(version?: string): string[];
66
+ /** The `vtctldclient --server <addr>` prefix, or null when unconfigured. */
67
+ export declare function vtctldCommand(vitess: VitessControlPlaneConfig): string | null;
68
+ /**
69
+ * Create a keyspace.
70
+ *
71
+ * `sharded` only declares intent: a keyspace becomes genuinely sharded when
72
+ * its VSchema names a vindex per table, which is the next step. Creating it
73
+ * sharded with no VSchema yields a keyspace vtgate cannot route to, so the
74
+ * dashboard flow always pairs the two.
75
+ */
76
+ export declare function buildCreateKeyspaceScript(vitess: VitessControlPlaneConfig, name: string, options?: {
77
+ sharded?: boolean;
78
+ }): string[] | null;
79
+ /**
80
+ * Apply a VSchema document to a keyspace.
81
+ *
82
+ * The JSON is written to a temp file rather than passed inline: a VSchema for
83
+ * a real application is many kilobytes, and shell argument limits are not
84
+ * where you want to discover that.
85
+ */
86
+ export declare function buildApplyVSchemaScript(vitess: VitessControlPlaneConfig, keyspace: string, vschemaJson: string): string[] | null;
87
+ /**
88
+ * Apply a schema change through Vitess online DDL.
89
+ *
90
+ * Routed through vtgate rather than vtctldclient so it works on a cluster
91
+ * where only the query endpoint is reachable. `SET @@ddl_strategy` tells
92
+ * vtgate to treat the following DDL as an online migration and return a
93
+ * migration UUID immediately instead of blocking.
94
+ */
95
+ export declare function buildApplySchemaScript(database: DatabaseConfig | undefined, sql: string, strategy?: 'vitess' | 'direct'): string[];
96
+ export interface VitessMigration {
97
+ uuid: string;
98
+ keyspace: string;
99
+ shard: string;
100
+ table: string;
101
+ status: string;
102
+ strategy: string;
103
+ added: string;
104
+ completed: string;
105
+ progress: string;
106
+ }
107
+ /**
108
+ * Read online-DDL migration state from vtgate.
109
+ *
110
+ * `SHOW VITESS_MIGRATIONS` is the only interface for this. An earlier version
111
+ * selected from `_vt.schema_migrations` directly, reasoning that an explicit
112
+ * column list is safer than SHOW's version-dependent layout. The reasoning was
113
+ * fine and the query was not: `_vt` is a per-tablet sidecar database that
114
+ * vtgate does not route to, so the statement never ran.
115
+ *
116
+ * The layout concern is real, so it is handled rather than avoided: the column
117
+ * header is kept (no `--skip-column-names`) and mapped by name in
118
+ * {@link parseMigrations}. That is strictly better than the positional parse
119
+ * the SELECT would have produced, because it survives upstream adding or
120
+ * reordering a column.
121
+ */
122
+ export declare function buildMigrationsScript(database: DatabaseConfig | undefined, keyspace?: string): string[];
123
+ export declare function parseMigrations(output: string): VitessMigration[];
124
+ /** Migrations needing attention: failed, or running long enough to notice. */
125
+ export declare function failedMigrations(migrations: VitessMigration[]): VitessMigration[];
126
+ export declare function runningMigrations(migrations: VitessMigration[]): VitessMigration[];
127
+ export type MigrationAction = 'retry' | 'cancel' | 'cleanup' | 'complete';
128
+ /**
129
+ * Act on a single online-DDL migration.
130
+ *
131
+ * `complete` exists because a migration started with a postponed completion
132
+ * waits for an explicit cutover; without it the change is applied but never
133
+ * swapped in, which looks like a migration that finished and did nothing.
134
+ */
135
+ export declare function buildMigrationActionScript(database: DatabaseConfig | undefined, uuid: string, action: MigrationAction): string[];
136
+ /** Install the pinned `vtctldclient` on the app box. */
137
+ export declare function installVtctldClient(config: CloudConfig, environment: EnvironmentType): Promise<DbRunResult>;
138
+ export declare function createKeyspace(config: CloudConfig, environment: EnvironmentType, name: string, options?: {
139
+ sharded?: boolean;
140
+ }): Promise<DbRunResult & {
141
+ keyspace: string;
142
+ }>;
143
+ export declare function applyVSchema(config: CloudConfig, environment: EnvironmentType, keyspace: string, vschemaJson: string): Promise<DbRunResult & {
144
+ keyspace: string;
145
+ }>;
146
+ export declare function applySchemaChange(config: CloudConfig, environment: EnvironmentType, sql: string): Promise<DbRunResult>;
147
+ export declare function listMigrations(config: CloudConfig, environment: EnvironmentType, keyspace?: string): Promise<DbRunResult & {
148
+ migrations: VitessMigration[];
149
+ failed: VitessMigration[];
150
+ running: VitessMigration[];
151
+ }>;
152
+ export declare function actOnMigration(config: CloudConfig, environment: EnvironmentType, uuid: string, action: MigrationAction): Promise<DbRunResult & {
153
+ uuid: string;
154
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -30,7 +30,7 @@ import {
30
30
  synchronizeDashboardUsers,
31
31
  trackDashboardOperation,
32
32
  verifyStaticApiOrigin
33
- } from "../chunk-y5wrqyyz.js";
33
+ } from "../chunk-3d4204h1.js";
34
34
  import {
35
35
  deleteStaticSite,
36
36
  deployStaticSite,
@@ -70,7 +70,7 @@ import {
70
70
  siteInstallBase,
71
71
  validateDeploymentConfig,
72
72
  verifyAddressRecord
73
- } from "../chunk-fbwcv2vf.js";
73
+ } from "../chunk-xp3txtg9.js";
74
74
  import"../chunk-hmehkeqx.js";
75
75
  import"../chunk-7m60qnc8.js";
76
76
  import"../chunk-4cjrg98a.js";
@@ -65,9 +65,11 @@ export interface HetznerServer {
65
65
  public_net: {
66
66
  ipv4?: {
67
67
  ip: string;
68
+ id?: number;
68
69
  };
69
70
  ipv6?: {
70
71
  ip: string;
72
+ id?: number;
71
73
  };
72
74
  };
73
75
  private_net?: Array<{
@@ -113,6 +115,39 @@ export interface HetznerSshKey {
113
115
  public_key: string;
114
116
  labels?: Record<string, string>;
115
117
  }
118
+ export interface HetznerImage {
119
+ id: number;
120
+ type: string;
121
+ status: string;
122
+ name?: string | null;
123
+ description?: string;
124
+ /** Size of the disk the image was taken from, in GB. */
125
+ disk_size?: number;
126
+ /** Size of the image itself once stored, in GB. */
127
+ image_size?: number | null;
128
+ architecture?: string;
129
+ created_from?: {
130
+ id: number;
131
+ name: string;
132
+ };
133
+ labels?: Record<string, string>;
134
+ }
135
+ export interface HetznerPrimaryIp {
136
+ id: number;
137
+ ip: string;
138
+ type: 'ipv4' | 'ipv6';
139
+ name?: string;
140
+ assignee_id?: number | null;
141
+ assignee_type?: string;
142
+ auto_delete?: boolean;
143
+ /** Current API shape omits this; retained for recorded fixtures. */
144
+ datacenter?: {
145
+ name: string;
146
+ location?: {
147
+ name: string;
148
+ };
149
+ };
150
+ }
116
151
  export interface HetznerAction {
117
152
  id: number;
118
153
  status: 'running' | 'success' | 'error';
@@ -261,6 +296,67 @@ export declare class HetznerClient {
261
296
  shutdownServer(serverId: number): Promise<HetznerAction>;
262
297
  powerOnServer(serverId: number): Promise<HetznerAction>;
263
298
  changeServerType(serverId: number, serverType: string, upgradeDisk: boolean): Promise<HetznerAction>;
299
+ /**
300
+ * Power a server off at the virtual power button.
301
+ *
302
+ * Distinct from {@link shutdownServer}, which asks the guest to shut down
303
+ * cleanly via ACPI and does nothing at all when the guest ignores it. Every
304
+ * operation that requires a stopped server — rebuild, primary-IP moves —
305
+ * needs the server to actually reach `off`, so callers shut down first and
306
+ * fall back to this.
307
+ */
308
+ powerOffServer(serverId: number): Promise<HetznerAction>;
309
+ /**
310
+ * Stop a server, preferring a clean guest shutdown.
311
+ *
312
+ * A guest that has not stopped within the grace period is powered off at the
313
+ * button: waiting forever on an unresponsive box is worse than a hard stop
314
+ * on a machine whose disk is about to be replaced anyway.
315
+ */
316
+ stopServer(serverId: number, options?: {
317
+ gracefulWaitMs?: number;
318
+ pollIntervalMs?: number;
319
+ }): Promise<void>;
320
+ renameServer(serverId: number, name: string): Promise<HetznerServer>;
321
+ listImages(options?: {
322
+ type?: string;
323
+ }): Promise<HetznerImage[]>;
324
+ getImage(id: number): Promise<HetznerImage>;
325
+ /**
326
+ * Snapshot a server's disk into a reusable image.
327
+ *
328
+ * Hetzner will snapshot a running server, but the result is a crash-consistent
329
+ * copy: whatever was mid-write is captured mid-write. For anything holding a
330
+ * database open that is a restore-time problem rather than a snapshot-time
331
+ * one, so callers that care stop the server first.
332
+ */
333
+ createImage(serverId: number, options: {
334
+ description: string;
335
+ type?: 'snapshot' | 'backup';
336
+ labels?: Record<string, string>;
337
+ }): Promise<{
338
+ image: HetznerImage;
339
+ action: HetznerAction;
340
+ }>;
341
+ deleteImage(imageId: number): Promise<void>;
342
+ /**
343
+ * Replace a server's disk with an image.
344
+ *
345
+ * Destructive and not undoable: everything on the target's disk is gone the
346
+ * moment this succeeds. The image's `disk_size` must be no larger than the
347
+ * target server type's disk, which is the asymmetry that decides which way a
348
+ * migration between two differently sized servers can run.
349
+ */
350
+ rebuildServer(serverId: number, image: number | string): Promise<HetznerAction>;
351
+ listPrimaryIps(): Promise<HetznerPrimaryIp[]>;
352
+ /**
353
+ * Detach a primary IP from whatever server currently holds it.
354
+ *
355
+ * The server has to be off. Hetzner rejects this outright on a running
356
+ * server rather than dropping its address underneath it.
357
+ */
358
+ unassignPrimaryIp(ipId: number): Promise<HetznerAction>;
359
+ assignPrimaryIp(ipId: number, serverId: number): Promise<HetznerAction>;
264
360
  waitForAction(actionId: number, options?: {
265
361
  pollIntervalMs?: number;
266
362
  maxWaitMs?: number;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Moving a workload from one server onto another.
3
+ *
4
+ * This is the half of a role swap that {@link ./role-swap} deliberately refuses
5
+ * to pretend it does. Exchanging two servers' primary IPs points each address
6
+ * at the other's disk; unless the workloads moved first, every address now
7
+ * serves the wrong application.
8
+ *
9
+ * There are two ways to move a workload, and which one is available is not a
10
+ * matter of taste:
11
+ *
12
+ * - **Snapshot and rebuild.** Hetzner images a server's disk and writes it
13
+ * over another server's. It carries everything - every service, every unit
14
+ * file, the certificates, the databases, the things nobody remembered were
15
+ * installed. For a box that accumulated twenty services over a year, this
16
+ * is the only honest option, because a reinstall reproduces what you
17
+ * documented rather than what you have.
18
+ *
19
+ * - **Redeploy and restore.** Deploy the application onto the target, then
20
+ * restore its data. Reproducible and auditable, but it only moves what the
21
+ * deployment knows about.
22
+ *
23
+ * The choice is forced by one asymmetry: **an image carries the disk size of
24
+ * the server it came from**, and a rebuild is refused unless the target's disk
25
+ * is at least that large. So a small server images onto a large one, and a
26
+ * large server does not image onto a small one - regardless of how little data
27
+ * it actually holds. An 11GB workload on a 160GB disk cannot be rebuilt onto an
28
+ * 80GB disk. That is the rule {@link planServerMigration} exists to apply
29
+ * before anything is destroyed, rather than after.
30
+ *
31
+ * Two further facts, learned the expensive way, are encoded here:
32
+ *
33
+ * - A rebuild is **irreversible the instant it succeeds**. The target's disk
34
+ * is gone. Anything on it that has not been captured elsewhere is gone with
35
+ * it, so the plan refuses to sequence a rebuild before its target's backup.
36
+ * - Rebuilding onto a **larger** disk does not grow the filesystem. The
37
+ * partition still reports the old size and the extra space is unreachable
38
+ * until `growpart` and the filesystem's own resize have run.
39
+ */
40
+ export type MigrationMethod = 'snapshot-rebuild' | 'redeploy-restore';
41
+ export interface AttachedVolume {
42
+ id: number;
43
+ name: string;
44
+ sizeGb?: number;
45
+ }
46
+ export interface MigrateServer {
47
+ id: number;
48
+ name: string;
49
+ serverType: string;
50
+ /** The server type's disk, in GB. An image taken here carries this size. */
51
+ diskSizeGb: number;
52
+ /** Bytes actually in use, so a move onto a smaller disk is refused early. */
53
+ usedGb: number;
54
+ architecture?: string;
55
+ /**
56
+ * Block-storage volumes attached to this server.
57
+ *
58
+ * These do NOT travel with a snapshot. A volume is a separate device that
59
+ * happens to be attached, so imaging the machine copies the system disk and
60
+ * silently leaves the volume behind on the old server - along with whatever
61
+ * lived on it. The migrated box then boots with an fstab entry for a device
62
+ * that is not there, and whatever depended on that mount stays down while
63
+ * every other check looks healthy.
64
+ */
65
+ attachedVolumes?: AttachedVolume[];
66
+ }
67
+ export type MigrationStepKind = 'back-up' | 'snapshot' | 'stop' | 'rebuild' | 'power-on' | 'grow-filesystem' | 'move-volume' | 'deploy' | 'restore' | 'verify';
68
+ export interface MigrationStep {
69
+ kind: MigrationStepKind;
70
+ /** The server the step acts on. */
71
+ serverId: number;
72
+ description: string;
73
+ /** True for steps that destroy data if the preceding ones were skipped. */
74
+ destructive?: boolean;
75
+ }
76
+ export interface MigrationPlan {
77
+ ok: boolean;
78
+ method: MigrationMethod | null;
79
+ blockers: string[];
80
+ /** Why this method rather than the other, for the operator to sanity-check. */
81
+ rationale: string;
82
+ steps: MigrationStep[];
83
+ }
84
+ /**
85
+ * Whether an image of `source` can be written onto `target`.
86
+ *
87
+ * Compares disk *sizes*, not disk *usage*. This is the constraint people get
88
+ * wrong, because it is counter-intuitive: a nearly empty 160GB server cannot be
89
+ * rebuilt onto an 80GB one. Hetzner is comparing the geometry the image was
90
+ * captured with, and it has no idea how much of it you were using.
91
+ */
92
+ export declare function canRebuildFrom(source: Pick<MigrateServer, 'diskSizeGb'>, target: Pick<MigrateServer, 'diskSizeGb'>): boolean;
93
+ /**
94
+ * Whether the workload's actual bytes fit the target, with room to restore.
95
+ *
96
+ * A disk that is exactly full fails on the next write, and a restore needs room
97
+ * for the archive as well as its expansion.
98
+ */
99
+ export declare function dataFits(usedGb: number, targetDiskGb: number, marginRatio?: number): boolean;
100
+ /** Whether a rebuild onto this target leaves unclaimed disk behind. */
101
+ export declare function needsFilesystemGrowth(source: Pick<MigrateServer, 'diskSizeGb'>, target: Pick<MigrateServer, 'diskSizeGb'>): boolean;
102
+ /**
103
+ * The ordered steps to move `source`'s workload onto `target`.
104
+ *
105
+ * `preferredMethod` is a request, not an instruction: asking for a snapshot
106
+ * rebuild that the disk geometry forbids returns the redeploy path with the
107
+ * reason attached, rather than a plan that fails partway.
108
+ */
109
+ export declare function planServerMigration(source: MigrateServer, target: MigrateServer, options?: {
110
+ preferredMethod?: MigrationMethod;
111
+ }): MigrationPlan;
112
+ /**
113
+ * The commands that claim disk a rebuild left unreachable.
114
+ *
115
+ * Split out because the order is load-bearing and easy to get backwards:
116
+ * `growpart` enlarges the partition, and only then can the filesystem be told
117
+ * to fill it. Running the filesystem resize first appears to succeed and
118
+ * changes nothing.
119
+ */
120
+ export declare function growFilesystemCommands(options?: {
121
+ device?: string;
122
+ partition?: number;
123
+ filesystem?: 'ext4' | 'xfs';
124
+ }): string[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Swapping the roles of two servers.
3
+ *
4
+ * The situation this exists for: a server type is no longer obtainable. Hetzner
5
+ * retires older lines, and `available_for_migration` goes false everywhere, so
6
+ * a `change_type` onto that type can never succeed again. If you already own an
7
+ * instance of it, that instance is the only one you will ever have — and the
8
+ * right move is to put your heaviest workload on it rather than to keep trying
9
+ * to buy another.
10
+ *
11
+ * A resize cannot do this. What can is exchanging the two servers' primary IPs,
12
+ * so each address keeps pointing at the workload that answers on it, with no
13
+ * DNS change and no waiting on propagation.
14
+ *
15
+ * Two constraints decide whether it is possible at all, and both are cheap to
16
+ * check before anything is powered off:
17
+ *
18
+ * - A primary IP belongs to a datacenter. Two servers in different
19
+ * datacenters cannot exchange addresses, and finding that out halfway
20
+ * through leaves both boxes down with their addresses detached.
21
+ * - Data has to fit. Moving a workload onto a smaller disk fails at restore
22
+ * time, which is the worst moment to discover it.
23
+ *
24
+ * The planning is pure so those rules can be tested without an account.
25
+ *
26
+ * IMPORTANT, and not yet handled here: exchanging addresses does not move the
27
+ * workload. Each disk keeps the software and data it already had, so a swap on
28
+ * its own points every address at the wrong application. A complete role swap
29
+ * is three phases - deploy each workload onto its new host, restore its data,
30
+ * and only then exchange the addresses - and this module plans the third.
31
+ * `planRoleSwap` deliberately refuses to look like the whole job.
32
+ */
33
+ export interface SwapServer {
34
+ id: number;
35
+ name: string;
36
+ /**
37
+ * Where the server sits, for the purpose of deciding whether an address can
38
+ * move to it. Derive it with {@link placementOf} rather than reading a field
39
+ * off the API response, and see that function for why. Null when unknown,
40
+ * which is a blocker rather than a pass.
41
+ */
42
+ placement: string | null;
43
+ serverType: string;
44
+ diskSizeGb: number;
45
+ /** Bytes actually in use, so a move onto a smaller disk can be refused early. */
46
+ usedGb: number;
47
+ primaryIpId: number | null;
48
+ primaryIp: string | null;
49
+ }
50
+ export type SwapStepKind = 'power-off' | 'unassign-ip' | 'assign-ip' | 'power-on' | 'rename';
51
+ export interface SwapStep {
52
+ kind: SwapStepKind;
53
+ serverId: number;
54
+ /** For an IP step, the address being moved. */
55
+ ipId?: number;
56
+ /** For a rename, the name to take. */
57
+ name?: string;
58
+ description: string;
59
+ }
60
+ export interface SwapPlan {
61
+ ok: boolean;
62
+ /** Why the swap cannot proceed. Empty when it can. */
63
+ blockers: string[];
64
+ steps: SwapStep[];
65
+ }
66
+ /**
67
+ * Whether a workload fits on the other server's disk.
68
+ *
69
+ * A margin is kept rather than comparing raw numbers: a disk that is exactly
70
+ * full is a disk that fails on the next write, and a restore needs room for
71
+ * both the archive and its expansion.
72
+ */
73
+ export declare function fits(usedGb: number, targetDiskGb: number, marginRatio?: number): boolean;
74
+ /**
75
+ * Where a server sits, from whichever field the API still reports.
76
+ *
77
+ * Primary IPs are bound at datacenter granularity, so `datacenter` is the
78
+ * field this wants. The API has stopped returning it — `datacenter` is now
79
+ * null on both the server and the primary IP, leaving only `location`.
80
+ *
81
+ * Reading the missing field directly is worse than useless: two servers in
82
+ * different datacenters both report `undefined`, `undefined === undefined`,
83
+ * and the guard that exists to stop a cross-datacenter swap waves it through.
84
+ * The failure then lands halfway, with both boxes off and their addresses
85
+ * detached, which is the exact outcome the check was written to prevent.
86
+ *
87
+ * So: prefer the datacenter when present, fall back to the location, and
88
+ * return null when neither is known so the caller can refuse. Location is a
89
+ * sound proxy today because every Hetzner location contains exactly one
90
+ * datacenter; if that ever stops being true, this returns the coarser answer
91
+ * and the assign call is the backstop.
92
+ */
93
+ export declare function placementOf(server: {
94
+ datacenter?: {
95
+ name?: string;
96
+ } | null;
97
+ location?: {
98
+ name?: string;
99
+ } | null;
100
+ }): string | null;
101
+ /**
102
+ * The parking name one server wears while the other takes its name.
103
+ *
104
+ * Hetzner rejects a duplicate server name, so the two cannot cross directly.
105
+ */
106
+ export declare function swapTempName(name: string): string;
107
+ /**
108
+ * The ordered steps to exchange two servers' addresses and names.
109
+ *
110
+ * Both are powered off before either address moves. Hetzner refuses to detach a
111
+ * primary IP from a running server, and doing them one at a time would leave
112
+ * the first server up with no address — reachable by nobody and, worse, still
113
+ * accepting work it can no longer be reached about.
114
+ */
115
+ export declare function planRoleSwap(a: SwapServer, b: SwapServer): SwapPlan;
116
+ /**
117
+ * Whether a type can still be migrated onto in a datacenter.
118
+ *
119
+ * `available` answers a different question — whether a NEW server of that type
120
+ * can be created — and reading it instead is why a doomed `change_type` looks
121
+ * like bad luck rather than a retired type. `available_for_migration` is the
122
+ * one `change_type` consults.
123
+ */
124
+ export declare function canMigrateTo(datacenter: {
125
+ server_types?: {
126
+ available_for_migration?: number[];
127
+ };
128
+ }, serverTypeId: number): boolean;
129
+ /**
130
+ * The advice to print when a resize is impossible.
131
+ *
132
+ * A type that is unobtainable everywhere is not a capacity blip to retry
133
+ * through; it is retired, and the instance you hold is the last one you get.
134
+ */
135
+ export declare function resizeAdvice(input: {
136
+ targetType: string;
137
+ migratableAnywhere: boolean;
138
+ ownedInstances: number;
139
+ }): string;
@@ -0,0 +1 @@
1
+ export {};