@selfhost.dev/mcp-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +205 -43
  2. package/dist/client.d.ts +7 -0
  3. package/dist/client.js +2 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.js +21 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/tools/alerts.js +12 -2
  8. package/dist/tools/alerts.js.map +1 -1
  9. package/dist/tools/backups.js +29 -5
  10. package/dist/tools/backups.js.map +1 -1
  11. package/dist/tools/billing.js +233 -39
  12. package/dist/tools/billing.js.map +1 -1
  13. package/dist/tools/database-users.js +53 -6
  14. package/dist/tools/database-users.js.map +1 -1
  15. package/dist/tools/deployments.js +47 -3
  16. package/dist/tools/deployments.js.map +1 -1
  17. package/dist/tools/github.js +21 -4
  18. package/dist/tools/github.js.map +1 -1
  19. package/dist/tools/instance-logs.js +2 -2
  20. package/dist/tools/instance-logs.js.map +1 -1
  21. package/dist/tools/instances.js +423 -88
  22. package/dist/tools/instances.js.map +1 -1
  23. package/dist/tools/organizations.js +5 -1
  24. package/dist/tools/organizations.js.map +1 -1
  25. package/dist/tools/pitr.d.ts +22 -0
  26. package/dist/tools/pitr.js +75 -10
  27. package/dist/tools/pitr.js.map +1 -1
  28. package/dist/tools/postgres-extensions.js +176 -57
  29. package/dist/tools/postgres-extensions.js.map +1 -1
  30. package/dist/tools/project-backups.js +130 -0
  31. package/dist/tools/project-backups.js.map +1 -1
  32. package/dist/tools/project-databases.js +16 -23
  33. package/dist/tools/project-databases.js.map +1 -1
  34. package/dist/tools/project-services.js +544 -53
  35. package/dist/tools/project-services.js.map +1 -1
  36. package/dist/tools/project-ssh.d.ts +2 -0
  37. package/dist/tools/project-ssh.js +338 -0
  38. package/dist/tools/project-ssh.js.map +1 -0
  39. package/dist/tools/projects.js +21 -3
  40. package/dist/tools/projects.js.map +1 -1
  41. package/dist/tools/scaling.js +1 -1
  42. package/dist/tools/scaling.js.map +1 -1
  43. package/dist/types/tiers.js +27 -4
  44. package/dist/types/tiers.js.map +1 -1
  45. package/package.json +2 -1
@@ -37,48 +37,54 @@ function validateStorageParams(type, iops, throughput) {
37
37
  return null;
38
38
  }
39
39
  // --- Engine reference data (mirrors the backend adapter registry + the console) ---
40
- const ENGINES = ["postgres", "mysql", "redis", "clickhouse"];
40
+ const ENGINES = ["postgres", "mysql", "redis", "clickhouse", "opensearch"];
41
41
  /**
42
- * ClickHouse versions in the backend's own order (`ClickhouseAdapter.supported_versions`).
43
- * Order matters: the in-place upgrade path walks this list one step at a time.
42
+ * ClickHouse versions (`ClickhouseAdapter.supported_versions`). One rung: selfhost#2580 pinned
43
+ * the platform to the 26.8 LTS and deleted 26.5 once no live instance was left on it. The list
44
+ * stays an array because the next LTS lands here, not because the caller gets a choice today.
44
45
  */
45
- const CLICKHOUSE_VERSIONS = ["24.3", "24.8", "25.1", "26.5"];
46
+ const CLICKHOUSE_VERSIONS = ["26.8"];
46
47
  /** Versions each engine's adapter accepts. Anything else is a 422 at create. */
47
48
  const ENGINE_VERSIONS = {
48
49
  postgres: ["18", "17", "16"],
49
50
  mysql: ["8.4", "8.0"],
50
51
  redis: ["7.2", "7.0"],
51
52
  clickhouse: CLICKHOUSE_VERSIONS,
53
+ opensearch: ["2.19", "2.16", "2.13"],
52
54
  };
53
55
  /**
54
56
  * Version sent when the caller passes none.
55
57
  *
56
58
  * This has to be explicit on every engine: the backend's fallback is
57
59
  * `supported_versions.first`, which is the OLDEST major each adapter still accepts
58
- * (postgres 14, redis 7.0, clickhouse 24.3), so omitting `db_version` silently
60
+ * (postgres 14, redis 7.0, opensearch 2.13), so omitting `db_version` silently
59
61
  * provisions a years-old release. These mirror the console's create-wizard defaults
60
62
  * (`CreateDatabaseOptions`) so both surfaces hand out the same database - newest for
61
- * postgres/redis, and the stable release rather than the newest for mysql (8.0 over 8.4)
62
- * and clickhouse (the 24.8 LTS over 25.1/26.5).
63
+ * postgres/redis/opensearch, and the stable release rather than the newest for mysql
64
+ * (8.0 over 8.4). ClickHouse has exactly one supported version, so its default is the list.
63
65
  */
64
66
  const ENGINE_DEFAULT_VERSION = {
65
67
  postgres: "18",
66
68
  mysql: "8.0",
67
69
  redis: "7.2",
68
- clickhouse: "24.8",
70
+ clickhouse: "26.8",
71
+ opensearch: "2.19",
69
72
  };
70
73
  const CLICKHOUSE_DEFAULT_VERSION = ENGINE_DEFAULT_VERSION.clickhouse;
71
74
  /**
72
- * Hard RAM floor per engine - backend `InstancesController::MIN_RAM_GB`. ClickHouse below
73
- * 4 GiB fails its systemd Type=notify startup, so create is rejected outright.
75
+ * Hard RAM floor per engine, from the backend's capacity-ladder YAML (`RAM_TIERS`). ClickHouse
76
+ * below 4 GiB fails its systemd Type=notify startup; OpenSearch gives its JVM heap 50% of
77
+ * available memory, so below 4 GiB it cannot hold a usable heap. Create is rejected outright
78
+ * on both. 512 MiB and 1 GiB remain postgres/mysql-only territory and both are `not_supported`
79
+ * for these two at every tier.
74
80
  */
75
- const ENGINE_MIN_RAM_GIB = { clickhouse: 4 };
81
+ const ENGINE_MIN_RAM_GIB = { clickhouse: 4, opensearch: 4 };
76
82
  /** Highest replica count offered with multi-AZ on. ClickHouse scales out; others keep Basic(1)/Enhanced(2). */
77
- const ENGINE_MAX_REPLICAS = { postgres: 2, mysql: 2, redis: 2, clickhouse: 5 };
83
+ const ENGINE_MAX_REPLICAS = { postgres: 2, mysql: 2, redis: 2, clickhouse: 5, opensearch: 5 };
78
84
  /** Replica count used when multi_az is on and none was passed (ClickHouse wants an odd node count). */
79
- const ENGINE_DEFAULT_REPLICAS = { postgres: 1, mysql: 1, redis: 1, clickhouse: 2 };
85
+ const ENGINE_DEFAULT_REPLICAS = { postgres: 1, mysql: 1, redis: 1, clickhouse: 2, opensearch: 1 };
80
86
  /** Default client port per engine. ClickHouse speaks HTTP on 8123; native TCP is 9000. */
81
- const ENGINE_DEFAULT_PORT = { postgres: 5432, mysql: 3306, redis: 6379, clickhouse: 8123 };
87
+ const ENGINE_DEFAULT_PORT = { postgres: 5432, mysql: 3306, redis: 6379, clickhouse: 8123, opensearch: 9200 };
82
88
  /** ClickHouse native TCP port, alongside the HTTP port above. */
83
89
  const CLICKHOUSE_NATIVE_PORT = 9000;
84
90
  /** Distributed-query cluster name on multi-AZ ClickHouse, e.g. cluster('selfhost_cluster', system.one). */
@@ -96,6 +102,65 @@ const CLICKHOUSE_KEEPER_STORAGE_GB = 20;
96
102
  function needsClickhouseKeeper(engine, replicaCount) {
97
103
  return engine === "clickhouse" && replicaCount === 1;
98
104
  }
105
+ /**
106
+ * OpenSearch cluster topology - `OpensearchTopology`. OpenSearch has no master/replica
107
+ * relationship; a cluster is a set of nodes carrying roles, and `replica_count` buys DATA
108
+ * nodes. The moment redundancy is asked for, cluster-manager duties move onto three dedicated
109
+ * nodes, because folding the manager role onto two all-in-one nodes leaves an even,
110
+ * split-brain-prone manager set:
111
+ *
112
+ * replica_count 0 → 1 node (one all-in-one [cluster_manager,data,ingest]; dev / non-HA)
113
+ * replica_count 1 → 5 nodes (3 manager-only + 2 data) ← HA minimum
114
+ * replica_count 2 → 6 nodes (3 manager-only + 3 data)
115
+ * replica_count N → N+4 (3 manager-only + N+1 data)
116
+ *
117
+ * THE THREE MANAGERS BILL LIKE ANY OTHER NODE, and the pricing endpoint knows nothing about
118
+ * them - the same trap as the ClickHouse Keeper, three times over. Going from 0 to 1 replica
119
+ * is a 1 → 5 node jump, not 1 → 2. Managers inherit the endpoint node's storage size and
120
+ * default to `t4g.medium`; `manager_instance_type` overrides that, subject to the same 4 GiB
121
+ * floor (a 2 GiB manager OOMs its JVM under quorum load and is rejected).
122
+ */
123
+ const OPENSEARCH_MANAGER_COUNT = 3;
124
+ const OPENSEARCH_DEFAULT_MANAGER_TYPE = "t4g.medium";
125
+ const OPENSEARCH_MANAGER_RAM_FLOOR_GIB = 4;
126
+ /** Total nodes provisioned for an OpenSearch `replica_count`, managers included. */
127
+ function opensearchNodeCount(replicaCount) {
128
+ const dataNodes = Math.max(replicaCount, 0) + 1;
129
+ return dataNodes >= 2 ? dataNodes + OPENSEARCH_MANAGER_COUNT : dataNodes;
130
+ }
131
+ /** Whether this topology provisions the three dedicated cluster-manager nodes. */
132
+ function needsOpensearchManagers(engine, replicaCount) {
133
+ return engine === "opensearch" && Math.max(replicaCount, 0) + 1 >= 2;
134
+ }
135
+ /**
136
+ * OpenSearch Dashboards - the opt-in UI, co-located on the endpoint node as a Node.js process
137
+ * competing with the JVM for memory. Hence its own RAM gate, above the engine's own floor.
138
+ * Never part of provisioning or readiness: the instance is ready when 9200 is, whatever
139
+ * Dashboards is doing.
140
+ */
141
+ /**
142
+ * PostgreSQL HA durability (selfhost#2582) - how long a COMMIT waits for a standby to flush
143
+ * the WAL. Three modes, and the difference between them is what happens when no standby is
144
+ * healthy:
145
+ *
146
+ * - `async` - the primary acks locally. Fastest, and RPO > 0: a commit can be lost if the
147
+ * primary dies before a standby has it.
148
+ * - `preferred`- waits for healthy standbys, and DEGRADES to no-wait when none are healthy. It
149
+ * never blocks writes, which is also why it cannot promise zero data loss.
150
+ * - `required` - RPO 0, and the honest version of that promise: dead standby names stay in the
151
+ * wait set, so when the SLA cannot be held THE PRIMARY STOPS ACCEPTING WRITES
152
+ * rather than quietly acking data no survivor holds. Only `required` while
153
+ * `sla_held` is true may be described to a user as zero-data-loss.
154
+ *
155
+ * `sync_number` is how many standbys must ack (default 1) and cannot exceed the group's HA
156
+ * standby count. A 2-node `required` cluster has NO automated failover - promoting the single
157
+ * standby would be the thing that loses data - so it needs `no_auto_failover_ack`.
158
+ */
159
+ const PG_DURABILITY_MODES = ["async", "preferred", "required"];
160
+ const PG_DURABILITY_DEFAULT_SYNC_NUMBER = 1;
161
+ const OPENSEARCH_DASHBOARDS_PORT = 5601;
162
+ const DASHBOARDS_MIN_RAM_GIB = 4;
163
+ const DASHBOARDS_RECOMMENDED_RAM_GIB = 8;
99
164
  /**
100
165
  * RAM of an instance type, from the same reference-data endpoint `get_instance_types_for_region`
101
166
  * serves (rate-limit exempt). Returns null when the type or the lookup is unavailable - callers
@@ -285,10 +350,13 @@ platform margin is folded into each line rather than billed separately, matching
285
350
  console and the website - so quote these numbers to the user as-is and never add a
286
351
  platform fee on top.
287
352
 
288
- Pass type_of_dbms whenever you know the engine. ClickHouse prices differently: with
289
- replica_count=1 the platform provisions a third, keeper-only node (${CLICKHOUSE_KEEPER_INSTANCE_TYPE} + ${CLICKHOUSE_KEEPER_STORAGE_GB} GB) to hold
290
- the Raft quorum, and this tool only includes it when it knows the engine. Leave the engine
291
- out for a ClickHouse cluster and the estimate understates the real bill.
353
+ PASS type_of_dbms WHENEVER YOU KNOW THE ENGINE. Two engines provision nodes the pricing
354
+ endpoint cannot see, and this tool only adds them when it knows the engine:
355
+ - clickhouse with replica_count=1 gets a dedicated keeper-only node (${CLICKHOUSE_KEEPER_INSTANCE_TYPE} + ${CLICKHOUSE_KEEPER_STORAGE_GB} GB)
356
+ holding the Raft quorum. At 2+ replicas the quorum is embedded and there is no extra node.
357
+ - opensearch with replica_count >= 1 gets ${OPENSEARCH_MANAGER_COUNT} dedicated cluster-manager nodes on top of the
358
+ data nodes, each inheriting the data nodes' storage size. replica_count=1 is ${opensearchNodeCount(1)} machines,
359
+ not 2, so leaving the engine out understates that cluster by three whole nodes.
292
360
 
293
361
  CLICKHOUSE ESTIMATES RUN LOW EVEN WITH THE ENGINE PASSED, and the keeper node is not the
294
362
  reason. The estimate endpoint applies ONE flat platform margin to every engine, while
@@ -308,7 +376,7 @@ different margin entirely. Use \`list_server_types\`, which returns real per-loc
308
376
  public_ipv4_count: z.number().int().min(0).optional().describe("Number of public IPv4 addresses (default: 0)"),
309
377
  iops: z.number().optional().describe("Provisioned IOPS (for gp3/io1/io2). Defaults to storage type baseline if omitted."),
310
378
  throughput_mbps: z.number().optional().describe("Throughput in MB/s (for gp3). Defaults to storage type baseline if omitted."),
311
- type_of_dbms: z.enum(ENGINES).optional().describe("Engine being priced. Only changes the maths for clickhouse with replica_count=1, where the billed Keeper node is added to the estimate."),
379
+ type_of_dbms: z.enum(ENGINES).optional().describe("Engine being priced. PASS IT FOR CLICKHOUSE AND OPENSEARCH OR THE ESTIMATE IS WRONG: clickhouse at replica_count=1 adds a billed Keeper node, and opensearch at replica_count>=1 adds 3 billed cluster-manager nodes. Both are invisible to the pricing endpoint."),
312
380
  }, async ({ region, instance_type, storage_type, storage_size_gb, database_count, replica_count, public_ipv4_count, iops, throughput_mbps, type_of_dbms }) => {
313
381
  const estimate = (overrides) => {
314
382
  const body = {
@@ -341,6 +409,25 @@ different margin entirely. Use \`list_server_types\`, which returns real per-loc
341
409
  // The pricing endpoint is engine-agnostic, so the standalone ClickHouse Keeper node has
342
410
  // to be priced as its own unit and folded in: a t4g.small with a 20 GB volume that
343
411
  // inherits the primary's storage settings, and no public IPv4 of its own.
412
+ let managers = null;
413
+ let managerError = null;
414
+ if (needsOpensearchManagers(type_of_dbms ?? "", replica_count ?? 0)) {
415
+ // Managers inherit the data nodes' storage and carry no public IPv4 of their own.
416
+ // Priced as ONE node here and multiplied out below, so the per-node figure stays
417
+ // readable in the breakdown.
418
+ const managerResult = await estimate({
419
+ instance_type: OPENSEARCH_DEFAULT_MANAGER_TYPE,
420
+ database_count: 1,
421
+ replica_count: 0,
422
+ public_ipv4_count: 0,
423
+ });
424
+ if (managerResult.success) {
425
+ managers = managerResult.data;
426
+ }
427
+ else {
428
+ managerError = `Could not price the ${OPENSEARCH_MANAGER_COUNT} cluster-manager nodes (${managerResult.statusCode}): ${managerResult.message}`;
429
+ }
430
+ }
344
431
  let keeper = null;
345
432
  let keeperError = null;
346
433
  if (needsClickhouseKeeper(type_of_dbms ?? "", replica_count ?? 0)) {
@@ -378,14 +465,25 @@ different margin entirely. Use \`list_server_types\`, which returns real per-loc
378
465
  const keeperAllIn = foldMarkup(keeper);
379
466
  lines.push("", `ClickHouse Keeper node (${CLICKHOUSE_KEEPER_INSTANCE_TYPE} + ${CLICKHOUSE_KEEPER_STORAGE_GB} GiB ${keeper.storage_type}):`, ` Compute: $${keeperAllIn.compute.toFixed(2)}`, ` Storage: $${keeperAllIn.storage.toFixed(2)}`, ` Subtotal: $${keeper.total_monthly.toFixed(2)}`, " (Required third quorum member because replica_count=1. Two replicas instead run the", " quorum embedded on the data nodes - no Keeper node and no third line item.)");
380
467
  }
381
- const total = data.total_monthly + (keeper?.total_monthly ?? 0);
468
+ const managersMonthly = managers ? managers.total_monthly * OPENSEARCH_MANAGER_COUNT : 0;
469
+ if (managers) {
470
+ const managerAllIn = foldMarkup(managers);
471
+ lines.push("", `OpenSearch cluster managers (${OPENSEARCH_MANAGER_COUNT} x ${OPENSEARCH_DEFAULT_MANAGER_TYPE} + ${managers.storage_size_gb} GiB ${managers.storage_type}):`, ` Per node: compute $${managerAllIn.compute.toFixed(2)} + storage $${managerAllIn.storage.toFixed(2)} = $${managers.total_monthly.toFixed(2)}`, ` Subtotal: $${managersMonthly.toFixed(2)} (${OPENSEARCH_MANAGER_COUNT} nodes)`, ` (A quorum needs an odd number of managers, so HA starts at ${OPENSEARCH_MANAGER_COUNT} dedicated nodes rather than`, " folding the role onto the data nodes. They carry no search load but bill in full.", ` Priced at the ${OPENSEARCH_DEFAULT_MANAGER_TYPE} default - pass manager_instance_type to create_instance to change it.)`);
472
+ }
473
+ const total = data.total_monthly + (keeper?.total_monthly ?? 0) + managersMonthly;
382
474
  lines.push("", `TOTAL: $${total.toFixed(2)}/month`);
383
475
  if (keeper) {
384
476
  lines.push(` (${data.replica_count + 1} data nodes $${data.total_monthly.toFixed(2)} + Keeper $${keeper.total_monthly.toFixed(2)})`);
385
477
  }
478
+ if (managers) {
479
+ lines.push(` (${data.replica_count + 1} data nodes $${data.total_monthly.toFixed(2)} + ${OPENSEARCH_MANAGER_COUNT} managers $${managersMonthly.toFixed(2)} = ${opensearchNodeCount(replica_count ?? 0)} nodes total)`);
480
+ }
386
481
  if (keeperError) {
387
482
  lines.push("", `⚠️ ${keeperError}`, "This total EXCLUDES the Keeper node that a 1-replica ClickHouse cluster requires - the real bill will be higher.");
388
483
  }
484
+ if (managerError) {
485
+ lines.push("", `⚠️ ${managerError}`, `This total EXCLUDES the ${OPENSEARCH_MANAGER_COUNT} cluster-manager nodes an HA OpenSearch cluster requires - the real bill will be materially higher.`);
486
+ }
389
487
  lines.push("", `Pricing model: ${data.pricing_model}`, `Currency: ${data.currency}`, "All-in: every line already includes the platform margin. Charged hourly against wallet credit.");
390
488
  return { content: [{ type: "text", text: lines.join("\n") }] };
391
489
  });
@@ -423,45 +521,94 @@ CLICKHOUSE SPECIFICS (they differ from the other engines - read before creating
423
521
  - Connect over HTTP on ${ENGINE_DEFAULT_PORT.clickhouse} (native TCP on ${CLICKHOUSE_NATIVE_PORT}) as user \`admin\`. Multi-AZ clusters
424
522
  expose the distributed cluster \`${CLICKHOUSE_CLUSTER_NAME}\`.
425
523
  - Use ${ENGINE_DEFAULT_PORT.clickhouse} as the port on allowed_cidr_ranges entries, not 5432/3306.
426
- - Versions: ${CLICKHOUSE_VERSIONS.join(", ")} (defaults to the ${CLICKHOUSE_DEFAULT_VERSION} LTS).
524
+ - Version: ${CLICKHOUSE_DEFAULT_VERSION} LTS, the only one the platform supports. It is pinned at create and
525
+ CANNOT be changed later - moving versions means a new instance plus a restore or migration into it.
526
+
527
+ OPENSEARCH SPECIFICS (a search/analytics engine, not a relational database - read before creating one):
528
+ - Minimum 4 GiB RAM: the JVM heap takes 50% of available memory. Find eligible sizes with
529
+ get_instance_types_for_region using min_memory=4.
530
+ - HA IS A CLUSTER TOPOLOGY, AND \`replica_count\` BUYS FAR MORE MACHINES THAN IT LOOKS:
531
+ replica_count=0 → 1 node (one all-in-one node; dev / non-HA only)
532
+ replica_count=1 → ${opensearchNodeCount(1)} NODES: 3 dedicated cluster-manager nodes + 2 data nodes ← HA minimum
533
+ replica_count=2 → ${opensearchNodeCount(2)} nodes: 3 managers + 3 data
534
+ replica_count=N → N+4 nodes
535
+ Going from no HA to HA is a 1 → ${opensearchNodeCount(1)} node jump, not 1 → 2, because a quorum needs an odd
536
+ number of managers and two would be split-brain-prone. ALL of those nodes bill. Price it
537
+ with estimate_instance_cost using type_of_dbms="opensearch" and the real replica_count, and
538
+ quote that number before creating - this is the single most expensive surprise on the
539
+ platform if you skip it.
540
+ - multi_az REQUIRES replica_count >= 1, and unlike every other engine it CANNOT BE TOGGLED
541
+ LATER in either direction. Enabling afterwards would build a 2-manager set with 0-replica
542
+ indices; disabling would terminate nodes with no shard drain. Decide HA at create or plan on
543
+ creating a new cluster and restoring into it.
544
+ - manager_instance_type sets the three managers' size (default ${OPENSEARCH_DEFAULT_MANAGER_TYPE}). Same ${OPENSEARCH_MANAGER_RAM_FLOOR_GIB} GiB floor -
545
+ managers carry no indexing or search load, so they can be smaller than the data nodes, but
546
+ not smaller than that. They inherit the endpoint node's storage size.
547
+ - ALWAYS HTTPS ON ${ENGINE_DEFAULT_PORT.opensearch}, never plaintext. Use ${ENGINE_DEFAULT_PORT.opensearch} on allowed_cidr_ranges entries. Transport
548
+ port 9300 is node-to-node only and is never customer-facing.
549
+ - TLS CAVEAT WORTH STATING UP FRONT: certs are per-cluster and CA-signed, and the node cert's
550
+ SAN covers the private IP rather than the public DNS name - so strict hostname verification
551
+ against the endpoint fails even with the CA trusted. Tell users to trust the CA with relaxed
552
+ hostname checking, or connect from inside the VPC. \`curl -k\` for a quick start. The cluster
553
+ CA is not downloadable through the API yet.
554
+ - No named databases (like redis) - do not ask for or display a database name.
555
+ - Vector search is BUILT IN (the k-NN plugin ships with the engine), so an embeddings workload
556
+ needs no extension here - that is a postgres/pgvector concern.
557
+ - dashboards_enabled turns on the OpenSearch Dashboards UI on port ${OPENSEARCH_DASHBOARDS_PORT}. It needs ${DASHBOARDS_MIN_RAM_GIB} GiB minimum
558
+ (${DASHBOARDS_RECOMMENDED_RAM_GIB} GiB recommended) because it runs alongside the JVM on the endpoint node, and it can be
559
+ turned on or off later with set_opensearch_dashboards. It never blocks readiness.
560
+ - Version: ${ENGINE_DEFAULT_VERSION.opensearch} is sent by default. Do not omit db_version - the backend adapter has no
561
+ default override, so an omitted field resolves to ${ENGINE_VERSIONS.opensearch[ENGINE_VERSIONS.opensearch.length - 1]}, the oldest.
427
562
 
428
563
  ENGINE-SPECIFIC FOLLOW-UPS (route to the right tools per engine after creating):
429
- - Connection pooling: PgBouncer for postgres, ProxySQL for mysql, CHProxy for clickhouse, none for redis.
430
- - Performance tuning: the pg-config tools for postgres, the mysql-config tools for mysql; none for redis or clickhouse.
431
- - PITR applies to postgres and mysql only - not redis, not clickhouse.
432
- - database-users applies to postgres, mysql and clickhouse - not redis.
433
- - postgresql_config and the two extension flags are postgres-only.
434
- - db_version can be upgraded in place on clickhouse only (upgrade_clickhouse_version).
435
-
436
- POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
437
- - timescaledb_enabled → TimescaleDB (hypertables, continuous aggregates, compression) for
438
- time-series workloads. Installing it at create costs nothing extra; enabling it later
439
- restarts PostgreSQL once (enable_timescaledb).
440
- - vector_enabled → pgvector (the \`vector\` type + similarity search) for embeddings.
441
- - Both can be on at once here, and both are ENABLE-ONLY - the backend refuses to turn
442
- either off afterwards, so ask the user before setting them.`, {
564
+ - Connection pooling: PgBouncer for postgres, ProxySQL for mysql, CHProxy for clickhouse, none for redis or opensearch.
565
+ - Performance tuning: the pg-config tools for postgres, the mysql-config tools for mysql; none for redis, clickhouse or opensearch.
566
+ - PITR applies to postgres and mysql only - not redis, not clickhouse, not opensearch (there is no
567
+ WAL-G equivalent, so opensearch durability is EBS volume snapshots and whole-volume restores only).
568
+ - database-users applies to postgres, mysql, clickhouse and opensearch - not redis.
569
+ - postgresql_config and \`extensions\` are postgres-only.
570
+ - db_version is fixed for the life of the instance on every engine. There is no in-place version
571
+ upgrade anywhere on the platform any more (selfhost#2580 withdrew the ClickHouse one).
572
+
573
+ POSTGRES EXTENSIONS - pass \`extensions: [...]\` (an ARRAY of catalog names, not booleans):
574
+ - TimescaleDB is \`timescaledb\`, pgvector is \`pgvector\` (or its SQL alias \`vector\`). The old
575
+ \`timescaledb_enabled\` / \`vector_enabled\` flags were REMOVED backend-side and are now
576
+ IGNORED rather than rejected - sending them provisions an instance with no extensions at all.
577
+ - Call \`list_postgres_extension_catalog\` with the \`db_version\` you are about to use to see
578
+ what is on offer; compatibility is per entry (\`pg_textsearch\` needs PostgreSQL 17 or 18).
579
+ Do not work from a memorised list.
580
+ - Extensions chosen here are installed during provisioning, so the ones that need a PostgreSQL
581
+ restart cost nothing extra at create - there is no restart to warn about on this path.
582
+ - Readiness accounts for them: the instance is not marked ready while any requested extension
583
+ is missing. Any of them can be added later with \`enable_instance_extensions\`, but NONE can
584
+ ever be removed, so ask the user before adding to the list.`, {
443
585
  organization_id: z.string().optional().describe("Organization PID (defaults to active org)"),
444
586
  region: z.string().min(1, "AWS region is required (e.g. us-east-1)"),
445
587
  identifier: z.string().min(1, "Database instance name"),
446
588
  instance_type: z.string().min(1, "EC2 instance type (e.g. t3.micro, r6i.xlarge)"),
447
589
  type_of_dbms: z.enum(ENGINES).describe("Database engine"),
448
590
  storage: storageSchema,
449
- db_version: z.string().optional().describe(`Database version. postgres: ${ENGINE_VERSIONS.postgres.join(", ")} (default ${ENGINE_DEFAULT_VERSION.postgres}). mysql: ${ENGINE_VERSIONS.mysql.join(", ")} (default ${ENGINE_DEFAULT_VERSION.mysql}, the stable release). redis: ${ENGINE_VERSIONS.redis.join(", ")} (default ${ENGINE_DEFAULT_VERSION.redis}). clickhouse: ${ENGINE_VERSIONS.clickhouse.join(", ")} (default ${ENGINE_DEFAULT_VERSION.clickhouse}, the LTS). Omitting this sends the default explicitly - the backend's own fallback is the oldest major it still supports.`),
450
- username: z.string().regex(/^[a-z_][a-z0-9_-]{0,30}$/i).optional().describe("Database username (default: admin). Redis ignores this (auth is a single requirepass/password); it applies to postgres, mysql and clickhouse. On clickhouse the customer user is `admin` - the built-in `default` user is platform-internal and not usable."),
591
+ db_version: z.string().optional().describe(`Database version. postgres: ${ENGINE_VERSIONS.postgres.join(", ")} (default ${ENGINE_DEFAULT_VERSION.postgres}). mysql: ${ENGINE_VERSIONS.mysql.join(", ")} (default ${ENGINE_DEFAULT_VERSION.mysql}, the stable release). redis: ${ENGINE_VERSIONS.redis.join(", ")} (default ${ENGINE_DEFAULT_VERSION.redis}). clickhouse: ${ENGINE_VERSIONS.clickhouse.join(", ")} (default ${ENGINE_DEFAULT_VERSION.clickhouse}, the LTS and the only supported version). opensearch: ${ENGINE_VERSIONS.opensearch.join(", ")} (default ${ENGINE_DEFAULT_VERSION.opensearch}). Omitting this sends the default explicitly - the backend's own fallback is the oldest major it still supports.`),
592
+ username: z.string().regex(/^[a-z_][a-z0-9_-]{0,30}$/i).optional().describe("Database username (default: admin). Redis ignores this (auth is a single requirepass/password); it applies to postgres, mysql, clickhouse and opensearch. On clickhouse the customer user is `admin` - the built-in `default` user is platform-internal and not usable. On opensearch the customer user holds `all_access` and the internal `admin` superuser is never surfaced."),
451
593
  custom_password: z.string().min(8).max(128).optional().describe("Database password (8-128 printable ASCII chars). Auto-generated if omitted."),
452
594
  description: z.string().optional(),
453
595
  cloud_credential_id: z.string().optional().describe("Credential PID from list_credentials. Use is_platform_managed=true credential for SelfHost's cloud, or is_platform_managed=false for BYOC. Always ask the user which they prefer."),
454
596
  vpc_id: z.string().optional().describe("VPC ID - REQUIRED when using BYOC credentials (is_platform_managed=false). Use list_vpcs and only pick from the 'public' group. Not needed for platform-managed."),
455
- multi_az: z.boolean().optional().describe("Enable multi-AZ replication (default: false). Required for any replica_count on clickhouse - its replicas only join the cluster when the Keeper quorum is configured, which happens on multi-AZ creates."),
456
- replica_count: z.number().int().min(1).max(5).optional().describe("Replicas to provision alongside the primary when multi_az is on. postgres/mysql/redis: 1 or 2. clickhouse: 1-5, default 2 (1 adds a billed Keeper node - see the ClickHouse notes)."),
597
+ multi_az: z.boolean().optional().describe("Enable multi-AZ replication (default: false). Required for any replica_count on clickhouse and opensearch. On opensearch it is PERMANENT - the only engine where HA cannot be toggled after create."),
598
+ replica_count: z.number().int().min(1).max(5).optional().describe("Replicas to provision alongside the primary when multi_az is on. postgres/mysql/redis: 1 or 2. clickhouse: 1-5, default 2 (1 adds a billed Keeper node). opensearch: buys DATA nodes and silently adds 3 billed manager nodes on top - 1 means 5 machines. See the engine notes."),
599
+ manager_instance_type: z.string().optional().describe("opensearch only: size of the 3 dedicated cluster-manager nodes when replica_count >= 1 (default t4g.medium, 4 GiB floor). They carry no search load, so they can be smaller than the data nodes."),
600
+ dashboards_enabled: z.boolean().optional().describe("opensearch only: install the OpenSearch Dashboards UI on port 5601. Needs 4 GiB RAM minimum (8 recommended) since it shares the endpoint node with the JVM. Can be changed later with set_opensearch_dashboards."),
601
+ confirm_node_count: z.boolean().optional().describe("opensearch only: required acknowledgement that replica_count >= 1 provisions 3 extra billed cluster-manager nodes. Omit it first to be told the real machine count, then set true once the user has seen the price."),
602
+ data_durability: z.enum(PG_DURABILITY_MODES).optional().describe("postgres only: how long a COMMIT waits for a standby. `async` = local ack (RPO > 0). `preferred` = wait when standbys are healthy, degrade when not (never blocks). `required` = RPO 0, and BLOCKS WRITES when it cannot be held. Defaults to `preferred` with multi_az, `async` without."),
603
+ sync_number: z.number().int().min(1).optional().describe("postgres only: how many standbys must ack a commit (default 1). Cannot exceed replica_count."),
604
+ no_auto_failover_ack: z.boolean().optional().describe("postgres only: required when data_durability=`required` with exactly 1 replica - that topology has NO automated failover, and this is the explicit acceptance of it."),
457
605
  delete_protection: z.boolean().optional().describe("Prevent accidental deletion (default: true recommended)"),
458
606
  backup_enabled: z.boolean().optional().describe("Enable automated backups (default: true recommended)"),
459
607
  dlm_policy_config: dlmPolicySchema.optional().describe("Backup schedule config (required if backup_enabled)"),
460
608
  allowed_cidr_ranges: z.array(cidrRangeSchema).max(24).optional().describe("IP whitelist (max 24 CIDR ranges)"),
461
609
  tags: z.array(tagSchema).optional().describe("AWS resource tags"),
462
610
  snapshot_id: z.string().optional().describe("Restore from a backup snapshot ID"),
463
- timescaledb_enabled: z.boolean().optional().describe("Install TimescaleDB for time-series data (postgres only). Enable-only - it cannot be turned off later, so confirm with the user first."),
464
- vector_enabled: z.boolean().optional().describe("Install pgvector for embeddings / similarity search (postgres only). Enable-only - it cannot be turned off later, so confirm with the user first."),
611
+ extensions: z.array(z.string().min(1)).optional().describe("PostgreSQL extensions to install during provisioning, by catalog name, e.g. [\"pgvector\", \"pg_stat_statements\"]. postgres only. Read `list_postgres_extension_catalog` first - names are validated server-side and an incompatible one is a 422. None can be removed later."),
465
612
  postgresql_config: z.record(z.unknown()).optional().describe("PostgreSQL parameter overrides (use preview_pg_config to see available params)"),
466
613
  iam_role_arn: z.string().optional().describe("IAM role ARN for the EC2 instance"),
467
614
  db_port: z.number().int().min(0).max(65535).optional().describe(`Database port (default ${ENGINE_DEFAULT_PORT.postgres} postgres, ${ENGINE_DEFAULT_PORT.mysql} mysql, ${ENGINE_DEFAULT_PORT.redis} redis, ${ENGINE_DEFAULT_PORT.clickhouse} clickhouse HTTP)`),
@@ -484,19 +631,16 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
484
631
  if (params.db_version && !ENGINE_VERSIONS[engine].includes(params.db_version)) {
485
632
  return { content: [{ type: "text", text: `'${params.db_version}' is not a supported ${engine} version. Supported: ${ENGINE_VERSIONS[engine].join(", ")}.` }] };
486
633
  }
487
- // Extension flags are postgres-only. The backend silently forces them false on other
488
- // engines, which would look like a successful "TimescaleDB instance" that has none -
489
- // so refuse instead of quietly dropping the request.
634
+ // `extensions` is postgres-only. The backend IGNORES the list on other engines, which
635
+ // would look like a successful "pgvector instance" that has none - so refuse instead of
636
+ // quietly dropping the request.
490
637
  if (engine !== "postgres") {
491
- const requested = [
492
- params.timescaledb_enabled ? "timescaledb_enabled" : null,
493
- params.vector_enabled ? "vector_enabled" : null,
494
- ].filter(Boolean);
638
+ const requested = params.extensions ?? [];
495
639
  if (requested.length > 0) {
496
640
  return {
497
641
  content: [{
498
642
  type: "text",
499
- text: `${requested.join(" and ")} ${requested.length > 1 ? "are" : "is"} PostgreSQL-only and would be ignored on ${engine}. Create this as postgres to get ${requested.length > 1 ? "them" : "it"}, or drop the flag${requested.length > 1 ? "s" : ""}.${engine === "clickhouse" ? " (For analytics workloads ClickHouse is already columnar - it needs no extension.)" : ""}`,
643
+ text: `extensions (${requested.join(", ")}) ${requested.length > 1 ? "are" : "is"} PostgreSQL-only and would be IGNORED on ${engine} - the instance would come up with none. Create this as postgres, or drop the list.${engine === "clickhouse" ? " (For analytics workloads ClickHouse is already columnar - it needs no extension.)" : ""}${engine === "opensearch" ? " (OpenSearch ships k-NN vector search in the engine - it needs no extension for embeddings.)" : ""}`,
500
644
  }],
501
645
  };
502
646
  }
@@ -520,6 +664,26 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
520
664
  }],
521
665
  };
522
666
  }
667
+ else if (engine === "opensearch" && replicaCount !== undefined && replicaCount > 0) {
668
+ return {
669
+ content: [{
670
+ type: "text",
671
+ text: `OpenSearch replicas require multi_az=true - the backend refuses any replica_count without it. Pass multi_az=true (replica_count defaults to ${ENGINE_DEFAULT_REPLICAS.opensearch}), or drop replica_count for a single all-in-one node.\n\nBefore you do: multi_az=true with replica_count=${ENGINE_DEFAULT_REPLICAS.opensearch} provisions ${opensearchNodeCount(ENGINE_DEFAULT_REPLICAS.opensearch)} BILLED NODES (3 cluster managers + 2 data), and it cannot be undone later. Price it with estimate_instance_cost first.`,
672
+ }],
673
+ };
674
+ }
675
+ // The three cluster managers are as real as any other node on the bill, and nothing
676
+ // downstream says so: `replica_count` reads like "extra machines" everywhere else.
677
+ // Refuse to provision five machines for a caller who asked for two without being told.
678
+ if (needsOpensearchManagers(engine, replicaCount ?? 0) && params.confirm_node_count !== true) {
679
+ const total = opensearchNodeCount(replicaCount);
680
+ return {
681
+ content: [{
682
+ type: "text",
683
+ text: `replica_count=${replicaCount} on OpenSearch provisions ${total} NODES, not ${replicaCount + 1}: 3 dedicated cluster-manager nodes (${params.manager_instance_type ?? OPENSEARCH_DEFAULT_MANAGER_TYPE}) plus ${replicaCount + 1} data nodes (${params.instance_type}). Every one of them bills, and the managers inherit the data nodes' ${params.storage.size} GiB storage.\n\nThis topology is also PERMANENT - OpenSearch HA cannot be toggled off later.\n\nPrice it with estimate_instance_cost (type_of_dbms="opensearch", replica_count=${replicaCount}), show the user the total, then call again with confirm_node_count=true.`,
684
+ }],
685
+ };
686
+ }
523
687
  // Hard RAM floor (backend MIN_RAM_GB). Best-effort pre-check so a doomed create fails
524
688
  // here with a useful message instead of after the provisioning call.
525
689
  const minRamGiB = ENGINE_MIN_RAM_GIB[engine];
@@ -534,6 +698,53 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
534
698
  };
535
699
  }
536
700
  }
701
+ // PostgreSQL HA durability. Mirrors `validate_ha_durability_create_params!` so a doomed
702
+ // create explains itself instead of returning a bare 422, and so the write-blocking
703
+ // consequence of `required` is stated before any money is spent.
704
+ if (engine === "postgres") {
705
+ const mode = params.data_durability ?? (multiAz ? "preferred" : "async");
706
+ if (multiAz && mode !== "async") {
707
+ const syncNumber = params.sync_number ?? PG_DURABILITY_DEFAULT_SYNC_NUMBER;
708
+ const standbys = replicaCount ?? 0;
709
+ if (syncNumber > standbys) {
710
+ return {
711
+ content: [{
712
+ type: "text",
713
+ text: `sync_number ${syncNumber} needs ${syncNumber} HA standbys but this create asks for ${standbys}. Raise replica_count to at least ${syncNumber}, or lower sync_number.`,
714
+ }],
715
+ };
716
+ }
717
+ if (mode === "required" && standbys === 1 && params.no_auto_failover_ack !== true) {
718
+ return {
719
+ content: [{
720
+ type: "text",
721
+ text: "A 2-node `required` cluster (1 replica) has NO AUTOMATED FAILOVER: promoting the only standby is exactly what would lose the data `required` exists to protect, so the platform will not do it by itself. If the primary fails, someone has to intervene.\n\nThat is a deliberate trade - RPO 0 at the cost of automatic recovery. Either raise replica_count to 2 so failover is safe, or confirm the user accepts manual failover and pass no_auto_failover_ack=true.",
722
+ }],
723
+ };
724
+ }
725
+ }
726
+ }
727
+ else if (params.data_durability || params.sync_number !== undefined || params.no_auto_failover_ack !== undefined) {
728
+ return {
729
+ content: [{
730
+ type: "text",
731
+ text: `data_durability / sync_number / no_auto_failover_ack are PostgreSQL-only and would be ignored on ${engine}. Drop them, or create this as postgres.`,
732
+ }],
733
+ };
734
+ }
735
+ // Managers have their own floor, checked separately because they are usually smaller
736
+ // than the data nodes - a 2 GiB manager OOMs its JVM under quorum load.
737
+ if (engine === "opensearch" && params.manager_instance_type && needsOpensearchManagers(engine, replicaCount ?? 0)) {
738
+ const managerRam = await lookupInstanceMemoryGiB(params.region, params.manager_instance_type);
739
+ if (managerRam !== null && managerRam < OPENSEARCH_MANAGER_RAM_FLOOR_GIB) {
740
+ return {
741
+ content: [{
742
+ type: "text",
743
+ text: `manager_instance_type ${params.manager_instance_type} has ${managerRam} GiB RAM, below the ${OPENSEARCH_MANAGER_RAM_FLOOR_GIB} GiB floor for an OpenSearch cluster manager (the backend rejects it). ${OPENSEARCH_DEFAULT_MANAGER_TYPE} is the smallest production-eligible manager.`,
744
+ }],
745
+ };
746
+ }
747
+ }
537
748
  // Build request body - only include defined fields
538
749
  const body = {
539
750
  organization_id: orgId,
@@ -568,6 +779,16 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
568
779
  // Resolved, not raw: multi-AZ ClickHouse without an explicit count would 422 otherwise.
569
780
  if (replicaCount !== undefined)
570
781
  body.replica_count = replicaCount;
782
+ if (params.manager_instance_type)
783
+ body.manager_instance_type = params.manager_instance_type;
784
+ if (params.dashboards_enabled !== undefined)
785
+ body.dashboards_enabled = params.dashboards_enabled;
786
+ if (params.data_durability)
787
+ body.data_durability = params.data_durability;
788
+ if (params.sync_number !== undefined)
789
+ body.sync_number = params.sync_number;
790
+ if (params.no_auto_failover_ack !== undefined)
791
+ body.no_auto_failover_ack = params.no_auto_failover_ack;
571
792
  if (params.delete_protection !== undefined)
572
793
  body.delete_protection = params.delete_protection;
573
794
  if (params.backup_enabled !== undefined)
@@ -580,10 +801,8 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
580
801
  body.tags = params.tags;
581
802
  if (params.snapshot_id)
582
803
  body.snapshot_id = params.snapshot_id;
583
- if (params.timescaledb_enabled)
584
- body.timescaledb_enabled = true;
585
- if (params.vector_enabled)
586
- body.vector_enabled = true;
804
+ if (params.extensions?.length)
805
+ body.extensions = params.extensions;
587
806
  if (params.postgresql_config)
588
807
  body.postgresql_config = params.postgresql_config;
589
808
  if (params.iam_role_arn)
@@ -615,19 +834,16 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
615
834
  `Group ID: ${data.group_id}`,
616
835
  `Organization: ${data.organization_id}`,
617
836
  ];
618
- if (engine === "postgres" && (params.timescaledb_enabled || params.vector_enabled)) {
619
- const extensions = [
620
- params.timescaledb_enabled ? "TimescaleDB" : null,
621
- params.vector_enabled ? "pgvector" : null,
622
- ].filter(Boolean);
623
- lines.push("", `${extensions.join(" + ")} ${extensions.length > 1 ? "are" : "is"} installed during provisioning (no restart needed at create).`);
624
- if (params.timescaledb_enabled) {
625
- lines.push("- Start with: CREATE TABLE ... then SELECT create_hypertable('your_table', 'time');", "- The timescaledb_* metrics (hypertable/chunk counts, compression ratio, total size) join this instance's `postgresql.database` alert set.");
837
+ if (engine === "postgres" && params.extensions?.length) {
838
+ const requested = params.extensions;
839
+ lines.push("", `Extensions installed during provisioning (no restart needed on this path): ${requested.join(", ")}.`, "- Each starts as a `pending` row and is only marked `enabled` once it is created AND its smoke test passes. The instance is not marked ready while any is missing, so check `list_instance_extensions` if readiness stalls.");
840
+ if (requested.includes("timescaledb")) {
841
+ lines.push("- TimescaleDB: start with CREATE TABLE ... then SELECT create_hypertable('your_table', 'time');", "- The timescaledb_* metrics (hypertable/chunk counts, compression ratio, total size) join this instance's `postgresql.database` alert set.");
626
842
  }
627
- if (params.vector_enabled) {
628
- lines.push("- The `vector` column type and similarity operators are ready to use.");
843
+ if (requested.includes("pgvector") || requested.includes("vector")) {
844
+ lines.push("- pgvector: the `vector` column type and similarity operators are ready to use.");
629
845
  }
630
- lines.push(`- Neither extension can be disabled later.`);
846
+ lines.push("- None of them can be removed later - there is no disable path.");
631
847
  }
632
848
  if (engine === "clickhouse") {
633
849
  lines.push("", `ClickHouse ${dbVersion}`, `- Connect over HTTP on port ${ENGINE_DEFAULT_PORT.clickhouse} (native TCP on ${CLICKHOUSE_NATIVE_PORT}).`);
@@ -638,7 +854,21 @@ POSTGRES EXTENSIONS (neither is a separate engine - both are postgres + a flag):
638
854
  }
639
855
  lines.push("- Provisioning a ClickHouse cluster runs ~11 tasks and takes longer than Postgres/MySQL. Poll refresh_instance_group.");
640
856
  }
641
- lines.push("- Follow-ups: enable_chproxy for connection pooling, database-users for SQL users, upgrade_clickhouse_version for version bumps. No PITR and no config tuning on this engine.");
857
+ lines.push("- Follow-ups: enable_chproxy for connection pooling, database-users for SQL users. No PITR, no config tuning and no version change on this engine.");
858
+ }
859
+ if (engine === "opensearch") {
860
+ lines.push("", `OpenSearch ${dbVersion}`, `- Connect over HTTPS on port ${ENGINE_DEFAULT_PORT.opensearch} - never plaintext. There is no database name to supply.`, "- Quick check once ready: curl -k -u \"<username>:<password>\" \"https://<dns_name>:" + ENGINE_DEFAULT_PORT.opensearch + "/_cluster/health?pretty\"", "- Use `dns_name`, not the public DNS: it is the endpoint node's CNAME and it survives failover and resize.", "- TLS: the cert's SAN covers the node's private IP, not the public endpoint, so strict hostname verification fails even with the cluster CA trusted. Use CA-trust with relaxed hostname checking, or connect from inside the VPC. `-k` is for quick starts only, and the CA is not downloadable through the API yet.");
861
+ if (multiAz) {
862
+ const dataNodes = replicaCount + 1;
863
+ lines.push(`- Cluster: ${dataNodes} data/ingest nodes + ${OPENSEARCH_MANAGER_COUNT} dedicated cluster managers = ${opensearchNodeCount(replicaCount)} NODES, all billed. The managers hold the leader-election quorum and carry no indexing or search load.`, "- This topology is PERMANENT. multi_az cannot be toggled on an OpenSearch cluster in either direction - changing it means creating a new cluster and restoring into it.", "- Poll `ha_ready` and `readiness_detail` rather than inferring health from `status`; `opensearch_node_role` tells you which node is which.");
864
+ }
865
+ else {
866
+ lines.push("- Single all-in-one node: no redundancy, so this is a dev / non-HA shape. HA cannot be added later - it needs a new cluster at replica_count >= 1 and a restore into it.");
867
+ }
868
+ if (params.dashboards_enabled) {
869
+ lines.push(`- Dashboards was requested and installs on port ${OPENSEARCH_DASHBOARDS_PORT} AFTER the instance is ready - it is never part of provisioning or readiness. Watch it with get_opensearch_dashboards; a \`failed\` or \`degraded\` state leaves the cluster itself perfectly healthy.`);
870
+ }
871
+ lines.push("- Follow-ups: database-users for security-plugin users, set_opensearch_dashboards for the UI. No pooling, no config tuning, no PITR and no version change on this engine.", "- Vector search (k-NN) is already in the engine - no extension needed.");
642
872
  }
643
873
  return { content: [{ type: "text", text: lines.join("\n") }] };
644
874
  });
@@ -673,6 +903,12 @@ READINESS IS ONE FIELD. \`ready\` is computed server-side and is the field to tr
673
903
 
674
904
  If an instance has been provisioning for more than about 15 minutes it is considered STUCK rather than slow - say so instead of advising the user to keep waiting.
675
905
 
906
+ NOT EVERY NODE IN A GROUP IS A DATABASE. Two fields say which is which, and reading them avoids pointing a user at a node that cannot do what they asked:
907
+ - \`clickhouse_node_role\` - \`keeper_only\` marks the Raft arbiter. It runs clickhouse-keeper, not clickhouse-server, so it holds no table data: no query stats, no data backup, no scaling policy. It is not a node to connect to, but it IS a node on the bill.
908
+ - \`opensearch_node_role\` - which role this node carries in the cluster. Only the node with \`opensearch_endpoint: true\` is the client-facing 9200/DNS target; manager-only nodes serve no traffic and carry no data.
909
+
910
+ A HOT STANDBY IS A READABLE REPLICA, NOT A WRITE TARGET, and it also cannot be scaled or reconfigured directly - those operations belong to its primary and the backend refuses them on a replica, naming the primary. When a user asks to change something on a node that turns out to be a replica, act on the primary instead of relaying a confusing refusal.
911
+
676
912
  \`deprecated_at\`, when set, marks the machine type as scheduled for retirement. Worth surfacing unprompted.`, {
677
913
  pid: z.string().optional().describe("Instance PID (e.g. awsinst_xxx)"),
678
914
  instance_id: z.string().optional().describe("AWS EC2 instance ID (alternative to pid)"),
@@ -708,7 +944,7 @@ Metric sets (same names alert rules use):
708
944
  - system: cpu_percent, memory_percent, disk_usage_percent, load_avg_1/5/15, swap_usage_percent, cpu_io_wait_percent
709
945
  - network: recv_bytes_per_sec, sent_bytes_per_sec
710
946
  - storage: disk_used_percent, disk_free_bytes, inodes_used_percent, read/write_io_per_second, ... reported PER MOUNT POINT and keyed \`storage[/mount]\` in the summary. Read the engine's data volume (e.g. \`storage[/selfhostdev/postgresql]\`), not \`storage[/]\` or \`storage[/boot]\` - a full /boot says nothing about database capacity.
711
- - <engine>.database: postgresql.database | mysql.database | redis.database | clickhouse.database - the engine's own counters. \`create_alert_rule\` carries the complete per-engine allow-list; these are the same names.
947
+ - <engine>.database: postgresql.database | mysql.database | redis.database | clickhouse.database | opensearch.database - the engine's own counters. \`create_alert_rule\` carries the complete per-engine allow-list; these are the same names.
712
948
  - pgbouncer.stats appears once pooling is enabled. It is READABLE here but NOT alertable - the alert-rule catalog has no such metric_type, so a rule on it is rejected.
713
949
 
714
950
  On a replicated instance \`active_replica_count\` is the one to watch for a quietly-degraded cluster: it drops without the instance's own status changing. Ratio metrics (\`cache_hit_ratio\`, \`innodb_buffer_pool_hit_ratio\`, \`mark_cache_hit_ratio\`) are already on a 0-100 scale - do not multiply them by 100.
@@ -720,7 +956,7 @@ An empty result usually means the agent has not reported in that window (a stopp
720
956
  .describe("Inclusive start date, YYYY-MM-DD (default: yesterday). The window covers whole days."),
721
957
  end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD").optional()
722
958
  .describe("Inclusive end date, YYYY-MM-DD (default: today)"),
723
- metric_type: z.enum(["system", "network", "storage", "postgresql.database", "mysql.database", "redis.database", "clickhouse.database"]).optional()
959
+ metric_type: z.enum(["system", "network", "storage", "postgresql.database", "mysql.database", "redis.database", "clickhouse.database", "opensearch.database"]).optional()
724
960
  .describe("Only return this metric set. Cheaper than filtering client-side."),
725
961
  raw: z.boolean().optional().describe("Return every heartbeat frame instead of the summary. Verbose - use a narrow window and a single instance."),
726
962
  }, async ({ organization_id, instance_pid, start_date, end_date, metric_type, raw }) => {
@@ -776,12 +1012,12 @@ An empty result usually means the agent has not reported in that window (a stopp
776
1012
  // history - activity counts, a `failover_completed` state, achievement badges - with no
777
1013
  // button to trigger one. The endpoint is an operator escape hatch, so it stays out of an
778
1014
  // LLM's reach: it drops every connection and the old master never returns as master.
779
- server.tool("update_instance", "Update an instance's configuration. Some changes are synchronous (db_port, backup, CIDR ranges), others are async (instance_type, multi_az, storage). Cannot update replicas - update the master instead. Not handled here: enabling TimescaleDB or pgvector on postgres (enable_timescaledb / enable_pgvector - both irreversible) and ClickHouse version upgrades (upgrade_clickhouse_version).", {
1015
+ server.tool("update_instance", "Update an instance's configuration. Some changes are synchronous (db_port, backup, CIDR ranges), others are async (instance_type, multi_az, storage). Cannot update replicas - update the master instead. Cannot change db_version on any engine: the platform withdrew the in-place ClickHouse upgrade in selfhost#2580, so a version change is now a new instance plus a restore or migration into it. Cannot toggle multi_az on OpenSearch - its HA is a cluster topology chosen at create. Not handled here either: enabling postgres extensions (see the extension tools) or the OpenSearch Dashboards UI (set_opensearch_dashboards).", {
780
1016
  instance_id: z.string().min(1, "Instance PID is required"),
781
1017
  identifier: z.string().optional().describe("New instance name"),
782
1018
  delete_protection: z.boolean().optional(),
783
1019
  instance_type: z.string().optional().describe("New EC2 instance type (triggers async resize)"),
784
- multi_az: z.boolean().optional().describe("Enable/disable multi-AZ (triggers async operation)"),
1020
+ multi_az: z.boolean().optional().describe("Enable/disable multi-AZ (triggers async operation). NOT AVAILABLE ON OPENSEARCH in either direction - its HA is a cluster topology fixed at create."),
785
1021
  backup_enabled: z.boolean().optional(),
786
1022
  db_port: z.number().int().min(0).max(65535).optional(),
787
1023
  security_group_ids: z.array(z.string()).optional(),
@@ -819,40 +1055,139 @@ An empty result usually means the agent has not reported in that window (a stopp
819
1055
  ],
820
1056
  };
821
1057
  });
822
- server.tool("upgrade_clickhouse_version", `Upgrade a ClickHouse instance to a newer version in place (ClickHouse is the only engine with an in-place version upgrade - the backend rejects db_version changes on every other engine).
823
-
824
- Rules the backend enforces:
825
- - One version at a time, along the supported ladder: ${CLICKHOUSE_VERSIONS.join(" → ")}. Skipping a
826
- step is rejected with the next allowed version named in the error.
827
- - The instance must have a registered agent.
828
- - Requesting the version it already runs is a no-op success.
829
-
830
- The upgrade restarts clickhouse-server on the node, so expect a brief interruption. Take a
831
- snapshot first (create_snapshot) - that is the rollback path if the upgrade goes wrong.`, {
832
- instance_id: z.string().min(1, "Instance PID is required (e.g. awsinst_xxx)"),
833
- db_version: z.enum(CLICKHOUSE_VERSIONS).describe("Target ClickHouse version - must be exactly one step above the current one"),
834
- confirm: z.boolean().describe("Must be true to proceed. Ask the user to confirm, and recommend a snapshot first."),
835
- }, async ({ instance_id, db_version, confirm }) => {
1058
+ server.tool("set_postgres_durability", `Change a PostgreSQL group's HA durability mode - how long a COMMIT waits for a standby to flush the WAL before the primary acks it. This is the RPO dial.
1059
+
1060
+ THE THREE MODES, and the only difference that matters is what happens when no standby is healthy:
1061
+ - \`async\` - the primary acks locally. Fastest, and RPO > 0: commits can be lost if the primary dies before a standby has them.
1062
+ - \`preferred\` - waits while standbys are healthy, and DEGRADES to no-wait when none are. It never blocks writes, which is exactly why it cannot be described as zero-data-loss.
1063
+ - \`required\` - RPO 0, honestly implemented: dead standby names stay in the wait set, so when the SLA cannot be held **THE PRIMARY STOPS ACCEPTING WRITES** rather than acking data no survivor holds. Availability is traded for durability, deliberately.
1064
+
1065
+ ONLY \`required\` WHILE \`sla_held\` IS TRUE MAY BE CALLED ZERO DATA LOSS. Read \`sla_held\` and \`degraded\` from the response before telling a user their data is safe: \`required\` with \`sla_held: false\` means writes are being refused right now, and \`preferred\` with \`degraded: true\` means it is silently acking locally.
1066
+
1067
+ RULES THE BACKEND ENFORCES:
1068
+ - PostgreSQL only, and on the MASTER only - replicas follow the group mode (a 422 names the primary).
1069
+ - THIS MUST BE SENT ALONE. Durability changes cannot ride along with any other instance update, so do not try to combine it with \`update_instance\`.
1070
+ - Moving TO \`preferred\` or \`required\` needs standbys actually streaming; without them the move is refused ("no streaming standbys to wait on"). Moving to \`async\` is always allowed - it is the honest escape hatch from a stalled \`required\` primary.
1071
+ - \`sync_number\` cannot exceed the group's HA standby count.
1072
+ - \`required\` with exactly one standby needs \`no_auto_failover_ack\` - see below.
1073
+
1074
+ IF A PRIMARY IS STALLED because \`required\` cannot be held, the unwedge is moving to \`preferred\` (which starts shrinking the wait set) or \`async\` (which removes the wait). That is the answer to "my writes are hanging" on a \`required\` cluster, and it is worth offering immediately rather than diagnosing further.`, {
1075
+ instance_id: z.string().min(1).describe("The MASTER instance's PID - not a replica"),
1076
+ data_durability: z.enum(PG_DURABILITY_MODES).optional().describe("Target mode. Omit to keep the current one and change only sync_number."),
1077
+ sync_number: z.number().int().min(1).optional().describe(`How many standbys must ack (default ${PG_DURABILITY_DEFAULT_SYNC_NUMBER}). Cannot exceed the group's HA standby count.`),
1078
+ no_auto_failover_ack: z.boolean().optional().describe("Required for `required` with exactly 1 standby: that topology has no automated failover."),
1079
+ confirm: z.boolean().describe("Must be true. Moving to `required` can block writes; moving to `async` gives up zero-data-loss."),
1080
+ }, async ({ instance_id, data_durability, sync_number, no_auto_failover_ack, confirm }) => {
1081
+ if (data_durability === undefined && sync_number === undefined && no_auto_failover_ack === undefined) {
1082
+ return { content: [{ type: "text", text: "Nothing to change - pass at least one of data_durability / sync_number / no_auto_failover_ack." }] };
1083
+ }
836
1084
  if (!confirm) {
1085
+ const consequence = data_durability === "required"
1086
+ ? "`required` means the primary STOPS ACCEPTING WRITES whenever the synchronous standby set cannot be held. That is the point of it - RPO 0 - but it converts a standby failure into a write outage. With exactly one standby it also disables automated failover."
1087
+ : data_durability === "async"
1088
+ ? "`async` removes the commit wait entirely: writes get faster and RPO becomes greater than zero, so commits can be lost if the primary dies. Any zero-data-loss claim stops being true."
1089
+ : data_durability === "preferred"
1090
+ ? "`preferred` waits while standbys are healthy and silently degrades to local acks when they are not, so it never blocks writes and never guarantees zero data loss."
1091
+ : "Changing sync_number changes how many standbys must ack each commit; raising it past the number of healthy standbys will stall writes in `required` mode.";
837
1092
  return {
838
1093
  content: [{
839
1094
  type: "text",
840
- text: `About to upgrade ClickHouse on ${instance_id} to ${db_version}. This restarts clickhouse-server (brief interruption) and cannot be rolled back except by restoring a snapshot.\n\nTake a snapshot first with create_snapshot, then call again with confirm=true.`,
1095
+ text: `About to change durability on ${instance_id}${data_durability ? ` to \`${data_durability}\`` : ""}${sync_number !== undefined ? ` with sync_number ${sync_number}` : ""}.\n\n${consequence}\n\nCall again with confirm=true.`,
1096
+ }],
1097
+ };
1098
+ }
1099
+ // The backend refuses a durability change that carries any other key, so this tool
1100
+ // sends nothing else - same contract as set_postgres_tls.
1101
+ const body = {};
1102
+ if (data_durability)
1103
+ body.data_durability = data_durability;
1104
+ if (sync_number !== undefined)
1105
+ body.sync_number = sync_number;
1106
+ if (no_auto_failover_ack !== undefined)
1107
+ body.no_auto_failover_ack = no_auto_failover_ack;
1108
+ const result = await apiRequest(`/aws/v1/instances/${instance_id}`, { method: "PATCH", body, toolName: "set_postgres_durability", skipOrgInjection: true });
1109
+ if (!result.success) {
1110
+ return { content: [{ type: "text", text: `Failed to change durability (${result.statusCode}): ${result.message}` }] };
1111
+ }
1112
+ const d = result.data ?? {};
1113
+ const notes = [`Durability is now \`${d.data_durability ?? data_durability}\`.`];
1114
+ if (d.data_durability === "required") {
1115
+ notes.push(d.sla_held
1116
+ ? "`sla_held` is true, so this cluster is genuinely holding RPO 0 right now - it is the one state in which zero data loss is an accurate claim."
1117
+ : "⚠️ `sla_held` is FALSE: the synchronous set is not being held, so the primary is refusing writes. Move to `preferred` or `async` to unwedge it, or bring a standby back.");
1118
+ if (d.auto_failover_allowed === false) {
1119
+ notes.push("Automated failover is OFF for this group - a primary failure needs manual intervention.");
1120
+ }
1121
+ }
1122
+ else if (d.data_durability === "preferred" && d.degraded) {
1123
+ notes.push("⚠️ `degraded` is true: no standby is healthy, so commits are being acked locally. Writes are flowing, but this is NOT zero data loss - do not describe it as such.");
1124
+ }
1125
+ else if (d.data_durability === "async") {
1126
+ notes.push("Commits now ack locally. RPO is greater than zero: any zero-data-loss claim for this cluster is no longer true.");
1127
+ }
1128
+ return {
1129
+ content: [
1130
+ { type: "text", text: notes.join("\n\n") },
1131
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
1132
+ ],
1133
+ };
1134
+ });
1135
+ server.tool("get_opensearch_dashboards", `Read the OpenSearch Dashboards state for an instance - the opt-in Kibana-style UI on port ${OPENSEARCH_DASHBOARDS_PORT}.
1136
+
1137
+ Returns \`dashboards_enabled\`, \`dashboards_status\` (\`disabled\` | \`installing\` | \`running\` | \`degraded\` | \`failed\`), \`dashboards_url\`, the instance's \`memory_gb\`, and two RAM verdicts: \`dashboards_supported\` (>= ${DASHBOARDS_MIN_RAM_GIB} GiB, the hard floor) and \`dashboards_recommended\` (>= ${DASHBOARDS_RECOMMENDED_RAM_GIB} GiB). Check \`dashboards_supported\` BEFORE offering to enable it - below the floor the enable is rejected outright.
1138
+
1139
+ DASHBOARDS FAILING DOES NOT MEAN THE CLUSTER IS UNHEALTHY. It is co-located on the endpoint node as a Node.js process next to the JVM, and it is deliberately fail-soft and outside provisioning and readiness: the instance is ready when port ${ENGINE_DEFAULT_PORT.opensearch} is, whatever Dashboards is doing. Report a \`failed\` or \`degraded\` state as a separate, non-blocking fact, never as a broken database.
1140
+
1141
+ 422 on any non-OpenSearch instance.`, {
1142
+ instance_id: z.string().min(1).describe("Instance PID (e.g. awsinst_xxx). Must be the cluster's endpoint node."),
1143
+ }, async ({ instance_id }) => {
1144
+ const result = await apiRequest(`/aws/v1/instances/${instance_id}/dashboards`, {
1145
+ toolName: "get_opensearch_dashboards",
1146
+ skipOrgInjection: true,
1147
+ });
1148
+ if (!result.success) {
1149
+ return { content: [{ type: "text", text: `Failed to get Dashboards state (${result.statusCode}): ${result.message}` }] };
1150
+ }
1151
+ return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
1152
+ });
1153
+ server.tool("set_opensearch_dashboards", `Turn the OpenSearch Dashboards UI on or off on an existing cluster (port ${OPENSEARCH_DASHBOARDS_PORT}). Unlike most of this engine's shape, this one IS a day-2 toggle in both directions.
1154
+
1155
+ Call \`get_opensearch_dashboards\` first and check \`dashboards_supported\`: the endpoint node needs ${DASHBOARDS_MIN_RAM_GIB} GiB RAM to enable at all (${DASHBOARDS_RECOMMENDED_RAM_GIB} GiB recommended, since Dashboards is a Node.js process sharing the box with the OpenSearch JVM). Below the floor this is a 422.
1156
+
1157
+ Other requirements the backend enforces: OpenSearch only, the cluster's ENDPOINT node only (not a manager or a non-endpoint data node), status \`running\` or \`provisioned\`, and a registered agent to enable.
1158
+
1159
+ Enabling also opens ${OPENSEARCH_DASHBOARDS_PORT} on the security group. Poll \`get_opensearch_dashboards\` for \`installing\` → \`running\`; a \`failed\` outcome leaves the cluster itself healthy and serving on ${ENGINE_DEFAULT_PORT.opensearch}.`, {
1160
+ instance_id: z.string().min(1).describe("Instance PID of the cluster's endpoint node"),
1161
+ enabled: z.boolean().describe("true installs and starts Dashboards; false stops it and closes the port."),
1162
+ confirm: z.boolean().describe("Must be true. Enabling adds a process competing with the JVM for memory on the endpoint node and opens another port; disabling takes the UI away from anyone using it."),
1163
+ }, async ({ instance_id, enabled, confirm }) => {
1164
+ if (!confirm) {
1165
+ return {
1166
+ content: [{
1167
+ type: "text",
1168
+ text: enabled
1169
+ ? `About to enable OpenSearch Dashboards on ${instance_id}. It runs on the endpoint node alongside the OpenSearch JVM (so it competes for memory) and opens port ${OPENSEARCH_DASHBOARDS_PORT}. Check \`dashboards_supported\` via get_opensearch_dashboards first.\n\nCall again with confirm=true.`
1170
+ : `About to disable OpenSearch Dashboards on ${instance_id}. The UI stops and port ${OPENSEARCH_DASHBOARDS_PORT} closes; the cluster keeps serving on ${ENGINE_DEFAULT_PORT.opensearch}.\n\nCall again with confirm=true.`,
841
1171
  }],
842
1172
  };
843
1173
  }
844
1174
  const result = await apiRequest(`/aws/v1/instances/${instance_id}`, {
845
1175
  method: "PATCH",
846
- body: { db_version },
847
- toolName: "upgrade_clickhouse_version",
1176
+ body: { dashboards_enabled: enabled },
1177
+ toolName: "set_opensearch_dashboards",
848
1178
  skipOrgInjection: true,
849
1179
  });
850
1180
  if (!result.success) {
851
- return { content: [{ type: "text", text: `Failed to upgrade ClickHouse version (${result.statusCode}): ${result.message}` }] };
1181
+ return { content: [{ type: "text", text: `Failed to ${enabled ? "enable" : "disable"} Dashboards (${result.statusCode}): ${result.message}` }] };
852
1182
  }
853
1183
  return {
854
1184
  content: [
855
- { type: "text", text: `ClickHouse upgrade to ${db_version} requested. Poll get_instance to watch the version and status, then verify with SELECT version().` },
1185
+ {
1186
+ type: "text",
1187
+ text: enabled
1188
+ ? `Dashboards enablement requested. Poll get_opensearch_dashboards until dashboards_status reaches \`running\`, then open dashboards_url. A \`failed\` state is a Dashboards problem only - the cluster stays healthy on ${ENGINE_DEFAULT_PORT.opensearch}.`
1189
+ : "Dashboards disabled. The port is closed and the cluster is unaffected.",
1190
+ },
856
1191
  { type: "text", text: JSON.stringify(result.data, null, 2) },
857
1192
  ],
858
1193
  };