@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.
- package/dist/bin/cli.js +1188 -1184
- package/dist/bin/dashboard-server.js +413 -409
- package/dist/{chunk-y5wrqyyz.js → chunk-3d4204h1.js} +365 -1
- package/dist/{chunk-fbwcv2vf.js → chunk-xp3txtg9.js} +458 -42
- 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/ui/access-denied.html +2 -2
- package/dist/ui/account/automation.html +3 -3
- package/dist/ui/applications/compose.html +4 -4
- package/dist/ui/applications/new.html +2 -2
- package/dist/ui/data/backups.html +4 -4
- package/dist/ui/data/services.html +4 -4
- package/dist/ui/data/volumes.html +4 -4
- 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 +4 -4
- 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 +3 -3
- package/dist/ui/server/database.html +123 -7
- package/dist/ui/server/deployments.html +4 -4
- 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/security.html +2 -2
- 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/assets.html +2 -2
- package/dist/ui/serverless/cost.html +2 -2
- package/dist/ui/serverless/data.html +4 -4
- 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/metrics.html +2 -2
- 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 +4 -4
- 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-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-
|
|
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 {};
|