@stacksjs/ts-cloud 0.7.92 → 0.7.94
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/dist/bin/cli.js +1235 -1183
- package/dist/bin/dashboard-server.js +451 -399
- package/dist/{chunk-29fwqkb7.js → chunk-0htae1c2.js} +456 -21
- package/dist/{chunk-fbwcv2vf.js → chunk-bs3jfnzp.js} +451 -41
- package/dist/control-plane/store.d.ts +1 -0
- package/dist/deploy/dashboard-database.d.ts +91 -3
- package/dist/deploy/dashboard-vitess.d.ts +154 -0
- package/dist/deploy/dashboard-vitess.test.d.ts +1 -0
- package/dist/deploy/index.js +2 -2
- package/dist/drivers/hetzner/client.d.ts +96 -0
- package/dist/drivers/hetzner/migrate-server.d.ts +124 -0
- package/dist/drivers/hetzner/migrate-server.test.d.ts +1 -0
- package/dist/drivers/hetzner/role-swap.d.ts +139 -0
- package/dist/drivers/hetzner/role-swap.test.d.ts +1 -0
- package/dist/drivers/index.js +1 -1
- package/dist/drivers/shared/db-provision.d.ts +4 -0
- package/dist/drivers/shared/deploy-script.d.ts +17 -1
- package/dist/drivers/shared/package-manager.d.ts +2 -0
- package/dist/drivers/shared/releases.d.ts +41 -2
- package/dist/drivers/shared/vitess-provision.d.ts +133 -0
- package/dist/drivers/shared/vitess-provision.test.d.ts +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/dist/telemetry/store.d.ts +1 -0
- package/dist/ui/account/automation.html +4 -4
- package/dist/ui/account/security.html +2 -2
- package/dist/ui/applications/compose.html +4 -4
- package/dist/ui/data/backups.html +4 -4
- package/dist/ui/data/services.html +4 -4
- package/dist/ui/data/volumes.html +3 -3
- package/dist/ui/index.html +4 -4
- package/dist/ui/integrations.html +2 -2
- package/dist/ui/operations/alerts.html +4 -4
- package/dist/ui/operations/configuration.html +4 -4
- package/dist/ui/operations/jobs.html +4 -4
- package/dist/ui/operations/maintenance.html +3 -3
- package/dist/ui/operations/observability.html +4 -4
- package/dist/ui/operations/previews.html +4 -4
- package/dist/ui/operations/queue.html +4 -4
- package/dist/ui/operations/regions.html +4 -4
- package/dist/ui/operations/releases.html +4 -4
- package/dist/ui/operations/workloads.html +4 -4
- package/dist/ui/security.html +2 -2
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +2 -2
- package/dist/ui/server/capacity.html +4 -4
- package/dist/ui/server/database.html +124 -8
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +2 -2
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/fleet.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +4 -4
- package/dist/ui/server/services.html +2 -2
- package/dist/ui/server/sites.html +4 -4
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +4 -4
- package/dist/ui/server/terminal.html +2 -2
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/data.html +3 -3
- package/dist/ui/serverless/firewall.html +2 -2
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +3 -3
- package/dist/ui-src/pages/server/database.stx +257 -9
- 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
|
-
|
|
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 {};
|
package/dist/deploy/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
synchronizeDashboardUsers,
|
|
31
31
|
trackDashboardOperation,
|
|
32
32
|
verifyStaticApiOrigin
|
|
33
|
-
} from "../chunk-
|
|
33
|
+
} from "../chunk-0htae1c2.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-
|
|
73
|
+
} from "../chunk-bs3jfnzp.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 {};
|