diff --git a/.factory-plugin/marketplace.json b/.factory-plugin/marketplace.json index 4282159..8042251 100644 --- a/.factory-plugin/marketplace.json +++ b/.factory-plugin/marketplace.json @@ -53,6 +53,12 @@ "description": "Pull request lifecycle skills: create PRs with consistent conventions and follow up on them until merge-ready", "source": "./plugins/code-review", "category": "productivity" + }, + { + "name": "local-ydb-toolkit", + "description": "Operate Docker-based local YDB deployments with safety guidance and a local stdio MCP server", + "source": "./plugins/local-ydb-toolkit", + "category": "database" } ] } diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml index 8ecf718..3343f3d 100644 --- a/.github/workflows/check-skills.yml +++ b/.github/workflows/check-skills.yml @@ -83,3 +83,27 @@ jobs: fi echo "All skills are accounted for." + + - name: Verify vendored Local YDB Toolkit source + env: + SOURCE_REPOSITORY: https://github.com/astandrik/local-ydb-toolkit.git + run: | + set -euo pipefail + + python3 scripts/test_sync_local_ydb_toolkit.py + + source_commit="$(python3 -c 'import json; print(json.load(open("plugins/local-ydb-toolkit/SOURCE.json"))["commit"])')" + if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid Local YDB Toolkit source commit" + exit 1 + fi + + source_dir="$(mktemp -d)" + trap 'rm -rf "$source_dir"' EXIT + + git init -q "$source_dir" + git -C "$source_dir" remote add origin "$SOURCE_REPOSITORY" + git -C "$source_dir" fetch --quiet --depth=1 origin "$source_commit" + git -C "$source_dir" checkout --quiet --detach FETCH_HEAD + + python3 scripts/sync-local-ydb-toolkit.py --source "$source_dir" --check diff --git a/README.md b/README.md index eaa9e03..13775ec 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,18 @@ Pull request lifecycle skills: open, triage, and follow up on PRs with consisten - `create-pr` - Open a PR with Conventional Commits title, templated body, and local verification gates - `follow-up-on-pr` - Rebase, address reviewer comments, fix CI, and push an existing PR to merge-ready state +### local-ydb-toolkit + +Operate Docker-based local YDB deployments with reusable safety guidance and a pinned local stdio MCP server. + +**Skill:** `local-ydb` + +**MCP server:** `local-ydb` via `npx --yes @astandrik/local-ydb-mcp@0.15.4` + +Requires Node.js 20.19 or newer and npm. Because the MCP server starts from the plugin root, pass an absolute `configPath` or set `LOCAL_YDB_TOOLKIT_CONFIG` when using a toolkit config file. + +Source: [astandrik/local-ydb-toolkit](https://github.com/astandrik/local-ydb-toolkit) + ### droid-evolved Skills for continuous learning and improvement. diff --git a/plugins/local-ydb-toolkit/.factory-plugin/plugin.json b/plugins/local-ydb-toolkit/.factory-plugin/plugin.json new file mode 100644 index 0000000..9190095 --- /dev/null +++ b/plugins/local-ydb-toolkit/.factory-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "local-ydb-toolkit", + "version": "0.1.1", + "description": "Operate Docker-based local YDB deployments with reusable safety guidance and an optional local stdio MCP server.", + "author": { + "name": "astandrik", + "url": "https://github.com/astandrik" + }, + "homepage": "https://local-ydb-toolkit.ydb-qdrant.tech/", + "repository": "https://github.com/astandrik/local-ydb-toolkit", + "license": "MIT", + "keywords": [ + "ydb", + "local-ydb", + "mcp", + "database", + "docker" + ] +} diff --git a/plugins/local-ydb-toolkit/LICENSE b/plugins/local-ydb-toolkit/LICENSE new file mode 100644 index 0000000..b1377c5 --- /dev/null +++ b/plugins/local-ydb-toolkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Anton Standrik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/local-ydb-toolkit/README.md b/plugins/local-ydb-toolkit/README.md new file mode 100644 index 0000000..c36f67a --- /dev/null +++ b/plugins/local-ydb-toolkit/README.md @@ -0,0 +1,21 @@ +# Local YDB Toolkit + +Operate Docker-based local YDB deployments with reusable safety guidance and a pinned local stdio MCP server. + +## Requirements + +- Node.js 20.19 or newer +- npm +- Docker for local deployments, or SSH access to a remote Docker host + +The MCP server starts from the installed plugin root. Pass an absolute `configPath` or set `LOCAL_YDB_TOOLKIT_CONFIG` when using a toolkit config file. + +## Source + +The manifest, MCP configuration, skill, and license are synchronized from [astandrik/local-ydb-toolkit](https://github.com/astandrik/local-ydb-toolkit) at the commit recorded in `SOURCE.json`. + +Do not edit synchronized files in this directory directly. Regenerate them from a checkout containing the pinned commit: + +```bash +python3 scripts/sync-local-ydb-toolkit.py --source /path/to/local-ydb-toolkit +``` diff --git a/plugins/local-ydb-toolkit/SOURCE.json b/plugins/local-ydb-toolkit/SOURCE.json new file mode 100644 index 0000000..245ae28 --- /dev/null +++ b/plugins/local-ydb-toolkit/SOURCE.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "repository": "https://github.com/astandrik/local-ydb-toolkit.git", + "commit": "82e334dd792cebbcdbc9fb91610614697746e41c" +} diff --git a/plugins/local-ydb-toolkit/mcp.json b/plugins/local-ydb-toolkit/mcp.json new file mode 100644 index 0000000..04513ca --- /dev/null +++ b/plugins/local-ydb-toolkit/mcp.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "local-ydb": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@astandrik/local-ydb-mcp@0.15.4" + ] + } + } +} diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/SKILL.md b/plugins/local-ydb-toolkit/skills/local-ydb/SKILL.md new file mode 100644 index 0000000..23fe1e6 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/SKILL.md @@ -0,0 +1,89 @@ +--- +name: local-ydb +description: Operate local-ydb deployments, especially Docker setups using ghcr.io/ydb-platform/local-ydb, CMS-created tenants, GraphShard metrics, dynamic nodes, managed YQL query/explain/execute, structured table DDL generation/validation/application, YDB static credentials, auth hardening, monitoring exposure, storage pool changes, single-disk rebuilds, rollback planning, upstream ydb-platform/ydb source lookup through gh api, and troubleshooting local-ydb readiness, TLS, anonymous access, or viewer/json graph endpoints. +--- + +# Local YDB + +## Purpose + +Use this skill to inspect, document, run, harden, troubleshoot, or generate and apply table schemas for `local-ydb` deployments. Keep reusable operational recipes separate from one-off cutover notes, host paths, timestamps, and secrets. + +## Execution Boundary + +- Operate a deployment only when the active agent has a local shell or the `local_ydb_*` MCP tools connected to the target machine. A chat-only or remote environment cannot inspect the user's Docker daemon, files, or YDB endpoints. +- When local execution is unavailable, provide a bounded plan, configuration guidance, or commands for the user to run. State that the target was not inspected and do not report inferred health, inventory, or mutation results as observed facts. +- Plugin MCP processes start from the installed plugin root. Pass an absolute `configPath` to profile-based tools or configure `LOCAL_YDB_TOOLKIT_CONFIG` in the MCP client; do not assume a project-local `local-ydb.config.json` is the process working-directory default. +- Keep every mutation plan-first regardless of execution surface. Require the existing confirmation boundary before applying changes and preserve rollback and verification steps. + +## First Steps + +1. Identify the task type: documentation cleanup, local bootstrap, live inspection, managed YQL query/explain/execute, schema generation/apply, auth hardening, storage expansion, monitoring exposure, TLS investigation, or troubleshooting. +2. Determine whether the target is repo documentation, a local Docker stack, or a live remote host. Treat live Docker/YDB changes as medium to high risk; collect read-only state first and ask before destructive or externally visible mutations. +3. Check nearby project docs before editing reusable runbooks. Prefer existing setup, runbook, and auth notes over inventing a new topology. +4. Keep secrets and private host details out of public docs and skill output. Use placeholders for password files, private keys, IPs, domains, users, and backup paths unless the user explicitly asks for private operational notes. + +## Reference Selection + +- Read `references/topology.md` when starting or documenting static nodes, dynamic nodes, tenants, GraphShard, storage pools, or upstream YDB source lookups. +- Read `references/auth-hardening.md` when working on mandatory auth, static username/password credentials, monitoring access, reverse-proxy exposure, or TLS. +- Read `references/storage-migration.md` when adding PDisks, changing storage placement, moving storage onto one physical disk, creating replacement tenants, migrating data, decommissioning groups, reclaiming space, cleaning old Docker volumes/PDisks/dumps, or debugging why UI and BSC disagree about storage. +- Read `references/verification.md` when checking health, tenant state, GraphShard, graph data, storage, or auth behavior. +- Read `references/mcp-tool-scenarios.md` when testing MCP tools, exercising the managed SQL safety matrix, planning structured schema generation/apply flows, or building reusable generate-then-validate-then-apply examples. +- Read `references/history-and-non-goals.md` when cleaning docs, deciding what is reusable versus artifact noise, or reconciling stale hardening plans with final topology. +- For exact-GHCR `26.1.1.6` local runs, combine `topology.md`, `auth-hardening.md`, and `verification.md`; they contain field-proven steps for fresh bootstrap, restore, auth rollout, and the nightly-vs-stable pitfalls we hit in practice. +- Prefer the MCP read-only tools `local_ydb_inventory`, `local_ydb_status_report`, `local_ydb_healthcheck`, `local_ydb_database_status`, `local_ydb_container_logs`, and `local_ydb_storage_placement` over ad hoc shell diagnostics when they are available. + +## Core Rules + +- Do not assume `/local` has GraphShard. `YDB_FEATURE_FLAGS=enable_graph_shard` is necessary but not sufficient; use a CMS-created tenant such as `/local/`. +- For a plain root `/local` database, use the root-only MCP bootstrap path instead of the tenant/dynamic-node bootstrap. +- Do not create GraphShard tenants with SQL. Use the public CMS gRPC API. +- Prefer exact GHCR patch tags such as `ghcr.io/ydb-platform/local-ydb:26.1.1.6`. Do not assume floating aliases like `:26.1` exist or are pullable. +- When `local-ydb` behavior is unclear, search upstream `ydb-platform/ydb` source with `gh api search/code` and read matching files through `gh api repos/ydb-platform/ydb/contents/...`; use pinned commits from project docs when matching documented proto shapes. +- Do not hardcode dynamic node IDs. Discover them through monitoring/node-list APIs. +- For database-level diagnosis, run `local_ydb_status_report` first and then `local_ydb_healthcheck`; use `selfCheckResult`, issue types, and issue counts to decide whether to inspect storage, nodes, scheme, auth, or logs. +- Check `local_ydb_inventory.ok` before reading inventory arrays. A Docker CLI, daemon, or inventory failure returns `ok=false` with a reason and is not evidence of an empty host; inventory-backed mutation planning must fail closed. +- On a new target, use `local_ydb_check_prerequisites` to distinguish missing Docker CLI/files from an unavailable Docker daemon. The toolkit diagnoses daemon availability but never starts Docker automatically. +- For new table schema DDL, prefer `local_ydb_generate_schema` with structured input, review/validate the generated script, then use `local_ydb_apply_schema`; applying still requires `confirm=true`. +- Use `local_ydb_sql` only for managed YQL against the selected configured local-ydb profile. Keep the official `ydb-mcp` server as the general choice for arbitrary YDB endpoints. +- Use `local_ydb_sql action=query` for reads: it always uses `SnapshotRO`, and `confirm=true` never turns it into a write path. Use `action=explain` for plan/AST inspection. +- Treat `local_ydb_sql action=execute` as high risk: it must complete mandatory `EXPLAIN`, remain plan-only without `confirm=true`, and send one `NoTx` execution after confirmation. Never retry an execution whose final status is unknown. +- Keep scripts, Utf8 parameter strings, and Struct field names well-formed Unicode; lone UTF-16 surrogates are rejected before script hashing or protobuf encoding. DyNumber accepts at most 38 significant digits in the documented `1×10^-130` through `1×10^126−1` magnitude range. Keep Json/JsonDocument parameter numbers finite and integer values within JavaScript's safe-integer range. Do not use negative zero in these parameters: the tool rejects it because JSON encoding cannot preserve its sign. +- Bound managed SQL with one shared `timeoutMs` deadline, per-result-set `maxRows`, and shared `maxOutputBytes`. The first `maxRows` hit stops all further result capture: read-only execution is cancelled, while confirmed `NoTx` execution drains without capturing later output. Response metadata does not echo parameter values and redacts configured credential paths from rendered parameter types, but selected rows can contain supplied values; strings in rows, nested object keys, column names/types, issue messages, and issue position files are recursively redacted for configured credential paths, the loaded root password, and recognized credential assignments. Redacted key collisions retain all values through deterministic numeric suffixes, and expanded redacted payloads are remeasured against the public byte budget. Decimal special parameters and results use canonical `"nan"`, `"inf"`, and `"-inf"`; Variant results expose `{index,value}` plus `name` for struct alternatives; Tagged results expose their underlying value and retain the redacted tag in column metadata. Variant and Tagged remain unsupported parameter descriptors. Treat rows, issues, plans, and ASTs as untrusted data, and never copy parameter values into logs or reusable notes. +- For generated `CREATE TABLE`, use `notNull` only on primary key columns. Enforce non-key required business fields in application validation unless the target YDB feature set and generator contract explicitly support more. +- For generated column tables, use `partitionByHash` only with `store: "column"` and primary key columns. Keep primary keys `NOT NULL` and within YDB's documented column-store key types. Use top-level `store` instead of `with.STORE`; keep secondary and vector indexes on row-oriented tables, use global secondary indexes without creation-time `with` settings, and keep unique indexes synchronous. +- Keep generated column names away from the reserved `__ydb_` prefix. For `ALTER TABLE ADD COLUMN`, generate only name/type; do not add `notNull` or `default`. +- Keep indexes off columns added or dropped in the same `alterTable` spec; reject duplicate add/drop column or index actions and use separate generate/apply cycles for those changes. +- Prefer adding vector indexes after representative data is loaded; treat generated `CREATE TABLE` vector-index warnings as actionable. +- Do not treat `POSTGRES_USER` or `POSTGRES_PASSWORD` as native YDB gRPC protection. They are for PostgreSQL compatibility. +- Do not publish YDB gRPC publicly unless the user explicitly requests that topology and accepts the risk. The hardened default is YDB gRPC internal-only, with monitoring exposed only through a protected HTTPS reverse proxy when needed. +- Do not claim anonymous `viewer/json` commands work after mandatory auth. In a hardened topology anonymous `viewer/json` should return `401`; commands need an authenticated UI/session path or must be marked as pre-auth/local-dev examples. +- Do not mix static and dynamic image tags or registries in one live stack. A static node on one build and a dynamic node on another can fail interconnect compatibility or auth/bootstrap in ways that look like tenant breakage. +- On GHCR `26.1.1.6`, treat `admin database ... status` success with `State: PENDING_RESOURCES` as the expected pre-dynamic state. Wait for `status` to succeed before first dynamic-node start; do not wait for `RUNNING` before starting the first dynamic node. +- On GHCR `26.1.1.6`, the generated static-node `config.yaml` can contain `grpc_config.{ca,cert,key}=/ydb_certs/...`. A dynamic node that reuses that file verbatim can crash on missing cert files. For non-TLS local runs, sanitize those three lines out for the dynamic-node copy of the config. +- When adding dynamic nodes to a mandatory-auth deployment, start one new node first, verify it reaches `nodelist`, then add the next. If a new node registers but cannot fetch dynamic config, preserve evidence and stop the broken container; do not delete working or recently registered containers before a replacement is healthy. +- If a dynamic-node container already exists but was started with stale flags, stale image tag, or stale config, do not rely on `docker start`. Remove and recreate it so the new launch command actually takes effect. +- Do not reuse an old data volume for an in-place version upgrade unless the upgrade has been rehearsed on a copy. +- Do not assume `admin database ... status` or UI `StorageGroups` means groups are physically placed where you want them. Use BSC `QueryBaseConfig` to confirm actual `Group -> PDisk` placement. +- Do not assume `DecommitGroups` reduces tenant storage allocation. It changes physical/virtual group state; it does not shrink `hdd:N/N`. +- Do not assume `storage_units_to_remove` is a working runtime path just because it exists in public proto. Verify current server-side implementation before using it on live YDB. +- Do not remove or recreate a PDisk file just because tenant groups were moved away from it. Root or scheme state may still live there. +- Do not treat `ReassignGroupDisk` success or `OperatingStatus: FULL` alone as proof that a live tenant survived a storage move. After any group movement, verify tenant metadata with `scheme ls`, `scheme describe`, and small table reads before touching the next group. +- For "put all storage on disk X" requests, prefer dump + fresh single-disk rebuild + restore over live `ReassignGroupDisk` for non-empty tenant groups. Delete old disks only after restored counts, metadata reads, auth, and BSC placement all pass. +- Before declaring old storage deleted, check both bind-mounted paths and Docker volumes. Old local-ydb volumes can use more than one historical name. +- Before restoring from an existing toolkit dump, use `local_ydb_list_dumps` to choose a valid `dumpName`; for path-level restore, remember that `path` is the destination directory passed to `ydb tools restore -p`. +- When using `ghcr.io/ydb-platform/local-ydb` as a helper container for `ydb tools restore`, override the image entrypoint to `/bin/bash`. The default `local_ydb` entrypoint does not execute arbitrary shell restore scripts. +- For auth-hardened viewer access, do not assume the authenticated SID is always `root@builtin`. A stock `root` username/password token can resolve to SID `root`; viewer/monitoring/admin ACLs should include both `root` and `root@builtin` unless you have stronger evidence for the deployed build. +- For authenticated viewer JSON checks, do not hardcode `http://127.0.0.1:8765/login`. Use the selected profile's monitoring base URL and post to `/login`. +- Do not commit secret material, live password-file paths, private backup paths, or one-off remote-host cutover logs into reusable docs. + +## Output Style + +For docs cleanup, split content into: + +- reusable runbook: topology, commands, verification, caveats; +- private or historical notes: timestamps, concrete backup paths, rehearsal logs, lockouts, failed experiments; +- explicit non-goals: public direct YDB gRPC, production TLS, automatic migrations, or one-off host assumptions. + +For live operations, provide a short plan with impact and rollback before changing containers, volumes, YDB config, auth settings, storage pools, or public networking. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/agents/openai.yaml b/plugins/local-ydb-toolkit/skills/local-ydb/agents/openai.yaml new file mode 100644 index 0000000..2021366 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Local YDB" + short_description: "Operate local-ydb schemas and deployments" + default_prompt: "Use $local-ydb to generate, validate, apply, inspect, or troubleshoot a local-ydb deployment." diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/assets/.gitkeep b/plugins/local-ydb-toolkit/skills/local-ydb/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/auth-hardening.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/auth-hardening.md new file mode 100644 index 0000000..bcfe122 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/auth-hardening.md @@ -0,0 +1,168 @@ +# Auth Hardening Reference + +## Target Posture + +Recommended hardened posture for demo-style local-ydb deployments: + +- internal-only: YDB static-node gRPC `2136` +- internal-only: dynamic-node gRPC ports such as `2137`, `2138`, `2139` +- loopback-only: YDB monitoring backend `127.0.0.1:8765` +- optional public monitoring only through HTTPS reverse proxy +- YDB native auth enabled with `security_config.enforce_user_token_requirement: true` +- monitoring/viewer/admin ACLs restricted to admin SIDs such as `root` and `root@builtin` + +This posture intentionally does not provide public direct YDB client access. + +## User Credentials + +Static username/password examples should use generic placeholders: + +```bash +YDB_STATIC_CREDENTIALS_USER= +YDB_STATIC_CREDENTIALS_PASSWORD_FILE=/run/secrets/.password +YDB_STATIC_CREDENTIALS_AUTH_ENDPOINT=grpc://ydb-local:2136 +``` + +Use environment-variable passwords only as a fallback when a password file is not available. For private-CA TLS, set `YDB_SSL_ROOT_CERTIFICATES_FILE`. + +Keep password files, tokens, CA private keys, and host-specific secret paths outside git. + +Field-proven default-root behavior on `local-ydb` images: + +- generated `security_config.default_users` can contain `root` with password `1234` +- a token minted from that username/password can identify as `User SID: root` +- dynamic-node auth token files can still use `root@builtin` + +Because of that split identity, viewer/monitoring/admin/register-dynamic-node ACLs should usually include both `root` and `root@builtin` unless the deployed build proves a different SID mapping. + +## Password Policy + +Upstream YDB defaults to no password complexity requirements. In the default posture: + +- empty passwords are allowed by YDB itself through `PASSWORD NULL` or equivalent SQL +- existing passwords keep working if a stricter policy is configured later +- special-character guidance in YDB docs is based on `!@#$%^&*()_+{}|<>?=` + +Clusters can still tighten this through `auth_config.password_complexity`, for example: + +- `min_length` +- `min_lower_case_count` +- `min_upper_case_count` +- `min_numbers_count` +- `min_special_chars_count` +- `special_chars` + +Operational guidance for this toolkit: + +- `local_ydb_set_root_password` requires a non-empty password value even though upstream YDB can allow an empty password +- if rotation fails with a password-policy error, inspect the active `auth_config.password_complexity` in the generated `config.yaml` before retrying +- prefer letters, digits, and documented YDB special characters unless the target build has already been rehearsed with a wider character set + +## Dynamic Node Auth + +For a mandatory-auth local-ydb dynamic node, `--auth-token-file` is a text protobuf for `NKikimrProto.TAuthConfig`, not a raw access-token file. Two fields matter during startup: + +- `NodeRegistrationToken` is used while registering the dynamic node. +- `StaffApiUserToken` is used later when the node fetches dynamic config through `GetNodeConfig`. + +The file shape is: + +```text +StaffApiUserToken: "" +NodeRegistrationToken: "" +``` + +This can avoid mounting password files into dynamic-node containers when the SID is already allowed by `security_config.register_dynamic_node_allowed_sids`. Certificate-based node authorization is still the stricter production pattern. + +To check what SID a username/password token represents without printing the token: + +```bash +sudo cat /path/to/root.password | docker exec -i ydb-local bash -lc ' + umask 077 + cat >/tmp/root.password + /ydb -e grpc://localhost:2136 -d /local \ + --user root \ + --password-file /tmp/root.password \ + auth get-token -f >/tmp/root.token + /ydbd --server localhost:2136 --token-file /tmp/root.token whoami + rc=$? + rm -f /tmp/root.password /tmp/root.token + exit $rc +' +``` + +## Rollout Sequence + +For production-like changes, use a copied volume first when possible. + +1. Save current container definitions and current YDB config. +2. Back up the Docker volume or bind-mounted data directory before patching config. +3. Create or verify users before enforcing auth. +4. Grant application users only the tenant access they need, commonly `ydb.generic.use` on `/local/`. +5. Patch YDB config to enforce native auth and tighten viewer, monitoring, admin, bootstrap, and dynamic-node registration SIDs. +6. Stop containers in dependency order: clients, dynamic nodes, static node. +7. Start containers in dependency order: static node, dynamic nodes, clients. +8. Verify tenant state, GraphShard, anonymous denial, and authenticated behavior before declaring success. + +Before mutating live config or volumes, provide a rollback plan: previous run commands, previous image tag, volume backup, and config restore point. + +Field-proven MCP sequence for a fresh stable `26.1.1.6` GHCR stack: + +1. `local_ydb_dump_tenant(confirm=true, dumpName="pre-auth-...")` +2. bootstrap a fresh clean stack on separate container names, network, volume, and ports with exact image `ghcr.io/ydb-platform/local-ydb:26.1.1.6` +3. `local_ydb_restore_tenant(confirm=true, dumpName="pre-auth-...")` +4. `local_ydb_prepare_auth_config(confirm=true)` to extract current config and root password file +5. `local_ydb_write_dynamic_auth_config(confirm=true)` for the dynamic auth text-proto +6. `local_ydb_apply_auth_hardening(confirm=true)` on the same stack +7. verify: `viewer whoami = 401`, authenticated `scheme ls /local/` works, authenticated `nodelist` works, authenticated GraphShard capability works + +## Monitoring Exposure + +YDB itself should remain the source of truth for authorization. A reverse proxy may provide HTTPS transport and routing, but do not rely on proxy Basic Auth as the only protection for YDB monitoring data. + +If the YDB frontend is proxied under a path prefix, it can call several top-level backend routes. Proxy route families may include: + +- `/login` +- `/logout` +- `/viewer` +- `/node/` +- `/storage/` +- `/operation/` +- `/query/` +- `/scheme/` +- `/pdisk/` +- `/vdisk/` + +Protected JSON endpoints such as `/viewer/json/tenants` should reject anonymous requests with `401` in the hardened topology. Bearer-token testing against these endpoints may return `Token is not supported`; prefer the built-in YDB UI login flow unless official YDB docs for the deployed version say otherwise. + +Observed login shape on some builds: + +- `POST /login` accepts JSON with `{"user":"root","password":"..."}` +- cookie-based requests to protected viewer endpoints work after login +- protected endpoints may redirect with `307`; use `curl -L` in scripts +- use the actual monitoring port from the selected profile, not a hardcoded `8765` + +## TLS Findings + +Treat public `grpcs` as a separate topology requiring its own runbook, rehearsal, certificates, and rollback plan. + +Important findings to verify on the deployed version: + +- The default `initialize_local_ydb` entrypoint may not bring up a usable `grpcs` listener for every topology. +- A manual `/ydbd server ... --grpcs-port ...` startup path may be required in rehearsals. +- YDB discovery can fall back to `FQDNHostName()` when public host and public SSL port are not set, causing internal Docker hostnames to be advertised and TLS hostname validation to fail. +- Explicit public host and public gRPCs port may be needed to avoid discovery mismatches. + +## Pitfalls + +- `security_config.default_users` is bootstrap-oriented; existing volumes need explicit user verification or creation. +- Empty viewer, monitoring, or admin SID lists can be too permissive depending on YDB config semantics. Fill them deliberately. +- Dynamic-node registration can break if `register_dynamic_node_allowed_sids` does not include the SID used by the node registration path. +- In an auth-enabled deployment, a new dynamic node can register successfully and still fail its later config/bootstrap fetch. `Access denied without user token` means no suitable token reached the config fetch path. `Cannot get node config. Access denied. Node is not authorized` means a token reached the path but its SID is not allowed. +- Do not assume `--user root --password-file ...` or a global `--token-file` on `ydbd server` authorizes dynamic config fetch. Validate the current server behavior. +- Do not write `StaffApiUserToken` unquoted or as a raw token file. Generate text protobuf with quoted string values and inspect a redacted copy if parsing fails. +- Do not treat a registered node ID as proof that the node is usable; verify logs, `nodelist`, tenant metadata, and client health. +- When a dynamic-node attempt fails in a restart loop, prefer `docker update --restart=no ` followed by `docker stop ` to preserve logs. Do not remove working or newly registered containers until the replacement node is healthy. +- On `ghcr.io/ydb-platform/local-ydb:26.1.1.6`, a dynamic node can successfully register and still crash if it reuses a config file containing `grpc_config.ca/cert/key=/ydb_certs/...` without those files mounted. Sanitize the dynamic-node copy of the config or mount matching cert files. +- `YDB_ANONYMOUS_CREDENTIALS=1` in the static-node environment does not override `security_config.enforce_user_token_requirement: true`, but it is still confusing in docs. Explain the interaction if it remains present. +- Do not expose plaintext YDB gRPC publicly as a convenience shortcut. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/history-and-non-goals.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/history-and-non-goals.md new file mode 100644 index 0000000..301a64c --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/history-and-non-goals.md @@ -0,0 +1,64 @@ +# History and Non-Goals Reference + +## Reusable Knowledge + +Keep these facts in reusable docs: + +- `/local` does not automatically get GraphShard. +- `/local/` should be CMS-created when GraphShard-backed charts are required. +- On `ghcr.io/ydb-platform/local-ydb`, prefer an exact patch tag such as `26.1.1.6`; do not assume `:26.1` exists. +- Dynamic nodes may need `--network container:ydb-local` in generated-config topologies. +- On `ghcr.io/ydb-platform/local-ydb:26.1.1.6`, `admin database ... status` can be `PENDING_RESOURCES` before the first dynamic node registers; that is not a hard failure by itself. +- On `ghcr.io/ydb-platform/local-ydb:26.1.1.6`, the generated static-node `config.yaml` can include `/ydb_certs/{ca,cert,key}.pem`, and a non-TLS dynamic node may need a sanitized copy of that config. +- Graph data should be requested through `/node//viewer/json/graph`. +- `POSTGRES_USER` and `POSTGRES_PASSWORD` do not protect native YDB gRPC. +- Do not hardcode dynamic node IDs. +- Rehearse volume upgrades and auth hardening on a copy first. +- Verify physical storage placement through BSC before deleting old PDisks or volumes. +- For auth-hardened viewer ACLs, the default root password can authenticate as SID `root` while dynamic-node auth still uses `root@builtin`; reusable docs should mention both. + +## Artifact Noise + +Move these out of reusable runbooks unless writing private operational history: + +- timestamped backup directories +- concrete password-file host paths +- personal home-directory paths +- one-off remote IP checks +- rehearsal container names that no longer exist +- old image tags used only during cutover +- short-lived alias tags like `ghcr.io/ydb-platform/local-ydb:26.1` when the successful workflow depended on an exact patch tag +- exact lockout or password drift incidents +- shell transcripts that only prove a past investigation +- application-specific table names, image names, and health endpoints + +## Known Stale Claims + +Audit docs for these stale or contradictory claims: + +- Plans to expose public direct YDB gRPC over TLS may be obsolete if the final hardened topology intentionally keeps YDB gRPC internal-only. +- Commands using unauthenticated `/viewer/json` conflict with mandatory auth unless marked as local-dev/pre-auth examples. +- Old storage allocation counts can become stale after pool expansion or rebuilds. +- App-specific smoke checks do not prove YDB tenant metadata or storage placement. + +## Recommended Hardened Outcome + +A generic hardened local-ydb deployment usually means: + +- YDB monitoring UI only through protected HTTPS reverse proxy if public exposure is needed +- YDB-enforced admin auth for monitoring and admin operations +- YDB gRPC internal-only by default +- explicit users and grants for any clients +- no committed passwords, tokens, private keys, hostnames, IPs, or one-off cutover logs + +Do not assume safe public direct YDB client access exists. + +## Documentation Cleanup Pattern + +When cleaning `local-ydb` docs: + +1. Keep the public runbook focused on commands, invariants, and checks a future operator can reuse. +2. Move rollout narratives and dead-end experiments to a single context summary or private ops note. +3. Replace concrete secrets, host paths, IPs, domains, and users with placeholders. +4. Mark unauthenticated commands as local-dev only if the hardened topology requires auth. +5. State non-goals explicitly instead of leaving contradicted old plans in place. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/mcp-tool-scenarios.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/mcp-tool-scenarios.md new file mode 100644 index 0000000..f78e335 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/mcp-tool-scenarios.md @@ -0,0 +1,909 @@ +# MCP Tool Scenarios + +Concrete scenarios for testing every `local-ydb` MCP tool in this repository. + +These scenarios are intentionally opinionated and reflect what actually worked in this repo during local runs. + +## Scope + +This document covers all public `local_ydb_*` tools currently registered by the MCP server: + +- `local_ydb_inventory` +- `local_ydb_database_status` +- `local_ydb_healthcheck` +- `local_ydb_container_logs` +- `local_ydb_destroy_stack` +- `local_ydb_status_report` +- `local_ydb_tenant_check` +- `local_ydb_scheme` +- `local_ydb_generate_schema` +- `local_ydb_apply_schema` +- `local_ydb_sql` +- `local_ydb_permissions` +- `local_ydb_nodes_check` +- `local_ydb_graphshard_check` +- `local_ydb_auth_check` +- `local_ydb_storage_placement` +- `local_ydb_add_storage_groups` +- `local_ydb_reduce_storage_groups` +- `local_ydb_storage_leftovers` +- `local_ydb_list_versions` +- `local_ydb_pull_image` +- `local_ydb_pull_status` +- `local_ydb_bootstrap_root_database` +- `local_ydb_bootstrap` +- `local_ydb_create_tenant` +- `local_ydb_start_dynamic_node` +- `local_ydb_add_dynamic_nodes` +- `local_ydb_remove_dynamic_nodes` +- `local_ydb_restart_stack` +- `local_ydb_upgrade_version` +- `local_ydb_list_dumps` +- `local_ydb_dump_tenant` +- `local_ydb_restore_tenant` +- `local_ydb_prepare_auth_config` +- `local_ydb_write_dynamic_auth_config` +- `local_ydb_apply_auth_hardening` +- `local_ydb_set_root_password` +- `local_ydb_cleanup_storage` + +## Profiles + +Use these profiles from `examples/local-ydb.config.example.json`: + +- `ghcr261-clean`: isolated clean stack on `ghcr.io/ydb-platform/local-ydb:26.1.1.6` +- `ghcr261-auth`: same stack, but with auth artifacts enabled +- `local`: auth-enabled working stack on the default ports + +Treat `ghcr-rebuild-clean` and `ghcr-rebuild-auth` as historical rehearsal profiles. Prefer the `ghcr261-*` pair for current testing. + +## Global Rules + +- Run `local_ydb_check_prerequisites` first on a new host or profile. +- If `local_ydb_check_prerequisites` reports installable packages, review its plan-only output and then use `confirm: true` to install supported host helpers before trying deeper checks. +- Run read-only tools first. +- Use `local_ydb_list_versions` before `local_ydb_upgrade_version` when you need to verify the exact registry tag to deploy. +- If an image is not already present on the target host, use `local_ydb_pull_image(confirm=true)` and poll `local_ydb_pull_status` before bootstrap or upgrade. +- For mutating tools, call plan-only once before `confirm: true` unless you are deliberately smoke-testing an idempotent path. +- Do not test `cleanup_storage` against active volumes or paths. +- Do not mix static and dynamic image tags inside one profile. +- For stable GHCR tests, use the exact patch tag `ghcr.io/ydb-platform/local-ydb:26.1.1.6`. + + +## Managed SQL Scenario: Query Service Safety Matrix + +Goal: exercise `local_ydb_sql` against the selected configured local-ydb profile. Use the official `ydb-mcp` server instead when the target is an arbitrary YDB endpoint rather than a toolkit-managed local-ydb profile. + +Preparation: + +- Create a disposable `managed_sql_smoke` table with `local_ydb_apply_schema`. +- Run every cleanup step from a `finally` block, including after an assertion or preflight failure. +- Never reuse a table that contains non-test data. +- For the authenticated Linux SSH fixture, keep static and dynamic gRPC ports unpublished, set `profile.network` to the static container's user-defined bridge, let the dynamic node share the static container namespace, and expose the root password file only as a read-only remote secret. + +Calls: + +```json +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT $value AS value;", "parameters": { "value": { "type": { "kind": "primitive", "name": "Int32" }, "value": 42 } } } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "UPSERT INTO `managed_sql_smoke` (id, value) VALUES (1, \"blocked\");", "confirm": true } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT COUNT(*) AS count FROM `managed_sql_smoke`;" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "explain", "script": "SELECT id, value FROM `managed_sql_smoke`;" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "execute", "script": "UPSERT INTO `managed_sql_smoke` (id, value) VALUES (1, \"confirmed\");" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "execute", "script": "UPSERT INTO `managed_sql_smoke` (id, value) VALUES (1, \"confirmed\");", "confirm": true } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "execute", "script": "THIS IS NOT VALID YQL;", "confirm": true } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "explain", "script": "ALTER TABLE `managed_sql_smoke` ADD COLUMN note Utf8;" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "explain", "script": "CREATE TABLE `managed_sql_ctas_explain` (PRIMARY KEY (id)) WITH (STORE = COLUMN) AS SELECT id, value FROM `managed_sql_smoke`;" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT value FROM AS_TABLE($items) ORDER BY value;", "maxRows": 2, "maxOutputBytes": 65536, "parameters": { "items": { "type": { "kind": "list", "item": { "kind": "struct", "fields": [{ "name": "value", "type": { "kind": "primitive", "name": "Int32" } }] } }, "value": [{ "value": 0 }, { "value": 1 }, { "value": 2 }] } } } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT $large AS first; SELECT $large AS second;", "maxOutputBytes": 256, "parameters": { "large": { "type": { "kind": "primitive", "name": "Utf8" }, "value": "" } } } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT $text AS text;", "parameters": { "text": { "type": { "kind": "primitive", "name": "Utf8" }, "value": "\ud800" } } } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT $record;", "parameters": { "record": { "type": { "kind": "struct", "fields": [{ "name": "\ud800", "type": { "kind": "primitive", "name": "Utf8" } }] }, "value": { "\ud800": "value" } } } } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT \"\ud800\" AS text;" } } +{ "tool": "local_ydb_sql", "arguments": { "profile": "ghcr261-clean", "action": "query", "script": "SELECT $path AS path;", "parameters": { "path": { "type": { "kind": "primitive", "name": "Utf8" }, "value": "" } } } } +``` + +Expected: + +- `query` uses SnapshotRO even when `confirm=true`; the attempted UPSERT fails and the following count remains zero. +- `explain` returns a plan or AST without side effects. +- `execute` always performs mandatory EXPLAIN first. Without `confirm=true` it returns `outcome=planned`; with confirmation it sends one NoTx execution and performs no retries. +- Invalid confirmed YQL is blocked by failed preflight with `executed=false` and `confirmationConsumed=false`. +- Parameter names are bare names, declarations are generated deterministically, and response metadata contains canonical parameter types with configured credential paths redacted but does not echo supplied parameter values. Selected result rows can still contain those values. +- `maxRows` truncates a result set only between complete rows; the first row-limit hit stops all further result capture (read-only execution cancels, confirmed `NoTx` drains). `maxOutputBytes` is shared across captured issues, plan/AST, metadata, and rows. +- The byte-limit call's placeholder is documentation only; replace it with an actual value of at least 4096 characters, or an equivalent fixture that reliably exceeds the 256-byte capture budget. +- All three lone-surrogate calls are rejected before Query Service execution: they probe an Utf8 parameter, a Struct field name, and the script itself. Replace the credential-path placeholder with the selected profile's exact configured path; the returned row string is ``. +- Result rows are arrays aligned with `columns`; their strings, nested object keys, and column names/types undergo recursive redaction for configured credential paths, the loaded root password, and recognized credential assignments. Colliding redacted keys retain every value through deterministic numeric suffixes, and redaction expansion remains charged to `outputBytes`. Inspect `outcome`, truncation flags, and `outputBytes` rather than treating partial output as success. +- On an authenticated Linux SSH profile with Docker-internal gRPC, `query`, `explain`, plan-only `execute`, and `local_ydb_apply_schema action=validate` succeed without host port publication. Credential read, Docker target resolution, SSH listener setup, YDB readiness, session creation, session attach, and query execution failures return safe phase-specific diagnostics. +- Cleanup drops `managed_sql_smoke` even when an earlier check fails. + + +## Scenario 0: Prerequisites + +Goal: verify the target host has the required base tools before any Docker or YDB checks. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_check_prerequisites", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +``` + +Optional install path on supported apt-based hosts: + +```json +{ "tool": "local_ydb_check_prerequisites", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +``` + +Expected: + +- `ready=true` only when every prerequisite is usable +- the check reports Docker CLI availability separately from the `dockerDaemon` service check +- absent CLI/files appear in `missing`; a present CLI with an unreachable daemon appears in `unavailable` +- an unreachable SSH target reports `ready=false`, `missing=[]`, `unavailable=["target"]`, no installable packages, and no install plan; it does not infer that Docker, curl, ruby, or the password file is missing +- auth-enabled profiles also report whether `rootPasswordFile` exists +- plan-only output includes `apt-get` install commands only for supported auto-install packages +- after any confirmed `apt-get` attempt, `checks`, `ready`, `missing`, `unavailable`, package-manager fields, and manual actions describe a fresh post-install snapshot; `results` contains the install attempt followed by those final probes +- Docker installation and daemon startup remain manual; `confirm=true` never starts Docker + +Avoid: + +- treating `inventory = 0 containers` as proof that Docker is installed on a remote host +- proposing Docker or helper installation when the SSH target itself is unavailable +- using `confirm: true` blindly on a host where `apt-get` should not be touched + +## Scenario 1: Preflight Read-Only Coverage + +Goal: verify the selected profile is wired correctly and all read-only endpoints work. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_inventory", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_storage_leftovers", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_status_report", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_healthcheck", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_scheme", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_permissions", "arguments": { "profile": "ghcr261-clean" } } +``` + +Expected: + +- successful `inventory` returns `ok=true`, `docker.cliAvailable=true`, `docker.daemonReachable=true`, and the current containers/volumes. +- Docker CLI, daemon, or inventory failures return `ok=false` with a reason and omit inventory arrays; never interpret that response as an empty host. +- `storage_leftovers` reports candidate volumes/paths without mutating them. +- `status_report` contains every component independently: inventory, auth, tenant, nodes, or health rejection produces the existing component-shaped safe fallback and does not prevent later checks. +- fallback diagnostics contain fixed summaries and empty command/output fields rather than raw exceptions, SSH/Docker stderr, credential paths, or `ENOENT` details. +- `healthcheck` returns the YDB `selfCheckResult`, issue counts, issue types, capped raw output, and truncated `issue_log` entries when present. +- `scheme` and `permissions` default to the tenant root for read-only schema and ACL inspection. + +Avoid: + +- Accessing `inventory.containers` before checking `inventory.ok`. +- Treating `status_report.tenant=not-ok` as a transport failure. It often just means the stack is not bootstrapped yet. + +## Scenario 1A: Schema Generate and Apply + +Goal: verify structured YDB table DDL generation, SDK validation, confirm-gating, application, inspection, and cleanup. + +Calls: + +```json +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "createTable", "tableName": "schema_apply_smoke", "columns": [{ "name": "id", "type": "Uint64", "notNull": true }, { "name": "value", "type": "Utf8" }], "primaryKey": ["id"], "indexes": [{ "name": "schema_apply_smoke_by_value", "columns": ["value"], "global": true }], "with": { "AUTO_PARTITIONING_BY_SIZE": { "token": "ENABLED" } } }] } } +{ "tool": "local_ydb_apply_schema", "arguments": { "profile": "ghcr261-auth", "action": "validate", "script": "CREATE TABLE `schema_apply_smoke` (\n `id` Uint64 NOT NULL,\n `value` Utf8,\n INDEX `schema_apply_smoke_by_value` GLOBAL ON (`value`),\n PRIMARY KEY (`id`)\n)\nWITH (\n AUTO_PARTITIONING_BY_SIZE = ENABLED\n);" } } +{ "tool": "local_ydb_apply_schema", "arguments": { "profile": "ghcr261-auth", "action": "apply", "script": "CREATE TABLE `schema_apply_smoke` (\n `id` Uint64 NOT NULL,\n `value` Utf8,\n INDEX `schema_apply_smoke_by_value` GLOBAL ON (`value`),\n PRIMARY KEY (`id`)\n)\nWITH (\n AUTO_PARTITIONING_BY_SIZE = ENABLED\n);", "confirm": false } } +{ "tool": "local_ydb_apply_schema", "arguments": { "profile": "ghcr261-auth", "action": "apply", "script": "CREATE TABLE `schema_apply_smoke` (\n `id` Uint64 NOT NULL,\n `value` Utf8,\n INDEX `schema_apply_smoke_by_value` GLOBAL ON (`value`),\n PRIMARY KEY (`id`)\n)\nWITH (\n AUTO_PARTITIONING_BY_SIZE = ENABLED\n);", "confirm": true } } +{ "tool": "local_ydb_scheme", "arguments": { "profile": "ghcr261-auth", "action": "describe", "path": "/local/example/schema_apply_smoke" } } +{ "tool": "local_ydb_apply_schema", "arguments": { "profile": "ghcr261-auth", "action": "apply", "script": "DROP TABLE schema_apply_smoke;", "confirm": true } } +``` + +Expected: + +- schema generation returns generated DDL, script SHA-256, statement kinds, official YDB references, risk, warnings, verification steps, and SDK validation when `validate=true` +- bare table `WITH` tokens such as `AUTO_PARTITIONING_BY_SIZE = ENABLED` are represented as `{ "token": "ENABLED" }` in the structured spec +- validation runs through the YDB JS SDK and does not apply DDL +- apply without `confirm=true` is plan-only after validation +- confirmed apply reports script SHA-256, statement kinds, validation/execution status, risk, rollback, and verification without echoing raw DDL or credential paths +- `DROP TABLE` and destructive `ALTER TABLE ... DROP ...` actions are high risk +- `CREATE TABLE` `notNull` is used only for columns that are part of `primaryKey`; non-key required business fields should be enforced by application validation or a later YDB feature path +- `partitionByHash` is used only with `store: "column"` and primary key columns; row tables use row partitioning `WITH` settings instead +- column names with the reserved `__ydb_` prefix, unsupported column-oriented table types, `ALTER TABLE ADD COLUMN` `notNull`/`default`, duplicate add/drop column actions, and generated scripts over 1 MiB are rejected before validation/application +- If an index needs a newly added column, generate/apply the `addColumn` first, then run a separate generate/apply call for `addIndex`; do not add an index on a column dropped in the same `alterTable` spec +- `vector_kmeans_tree` indexes include `global: true`, `sync: "sync"`, no `unique`, and complete `with` settings: `vector_dimension`, `vector_type`, either `distance` or `similarity`, `clusters`, and `levels` +- normal secondary indexes are global-only, do not accept creation-time `with` settings, unique indexes are synchronous, and creating a table with a vector index returns a warning that adding the vector index after loading representative data is preferred + +Avoid: + +- assuming generated DDL was applied; apply still goes through `local_ydb_apply_schema` and requires `confirm=true` +- using schema apply for DML, user/auth DDL, ACLs, topics, transfers, or views +- assuming rollback is automatic + +## Scenario 1A.1: Diverse Schema Generate Probes + +Goal: exercise the structured generator against common schema shapes before relying on it for a larger migration. + +Calls: + +```json +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "createTable", "tableName": "schema_probe_column_partition", "store": "column", "partitionByHash": ["tenant_id"], "columns": [{ "name": "tenant_id", "type": "Utf8", "notNull": true }, { "name": "ts", "type": "Timestamp", "notNull": true }, { "name": "value", "type": "Double" }], "primaryKey": ["tenant_id", "ts"], "with": { "AUTO_PARTITIONING_MIN_PARTITIONS_COUNT": 2 } }] } } +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "createTable", "tableName": "schema_probe_vector", "store": "row", "columns": [{ "name": "id", "type": "Uint64", "notNull": true }, { "name": "user", "type": "String" }, { "name": "title", "type": "String" }, { "name": "embedding", "type": "String" }], "primaryKey": ["id"], "indexes": [{ "name": "schema_probe_vector_idx", "columns": ["user", "embedding"], "cover": ["title"], "global": true, "sync": "sync", "using": "vector_kmeans_tree", "with": { "distance": "cosine", "vector_type": "float", "vector_dimension": 3, "clusters": 2, "levels": 1 } }] }] } } +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "createTable", "tableName": "schema_probe_defaults", "columns": [{ "name": "id", "type": "Uint64", "notNull": true, "default": 1 }, { "name": "label", "type": "Utf8", "default": "new" }, { "name": "created_on", "type": "Date", "default": "2026-05-27" }], "primaryKey": ["id"] }] } } +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "alterTable", "tableName": "schema_probe_alter", "actions": [{ "kind": "addColumn", "column": { "name": "status", "type": "Utf8" } }] }] } } +{ "tool": "local_ydb_generate_schema", "arguments": { "profile": "ghcr261-auth", "validate": true, "statements": [{ "kind": "alterTable", "tableName": "schema_probe_alter", "actions": [{ "kind": "addIndex", "index": { "name": "schema_probe_alter_by_status", "columns": ["status"], "global": true } }] }] } } +``` + +Expected: + +- each positive generated script validates, then goes through `local_ydb_apply_schema action=apply confirm=false` before any confirmed apply +- created probe tables are described with `local_ydb_scheme action=describe` and then cleaned up with validated/confirmed `DROP TABLE` +- generator-only negative probes reject row-table `partitionByHash`, non-primary-key `partitionByHash`, empty `partitionByHash`/`cover`, column-store secondary indexes, unsupported column-store key/non-key types, local secondary indexes, secondary index `with` settings, async unique indexes, unique vector indexes, same-spec add/drop column references from indexes, duplicate add/drop column/index actions, `ALTER TABLE ADD COLUMN` `notNull`/`default`, `with.STORE`, reserved `__ydb_` column names, missing primary/index columns, invalid types, invalid setting names/tokens, and scripts over 1 MiB before rendering or validation + +## Scenario 1B: Published Image Tags + +Goal: verify that the registry tag listing tool can discover concrete `local-ydb` image versions before an upgrade. + +Calls: + +```json +{ "tool": "local_ydb_list_versions", "arguments": {} } +{ "tool": "local_ydb_list_versions", "arguments": { "image": "ghcr.io/ydb-platform/local-ydb", "pageSize": 50, "maxPages": 2 } } +``` + +Expected: + +- the response includes `image`, `registry`, `repository`, `tags`, `count`, and `truncated` +- the default image resolves to `ghcr.io/ydb-platform/local-ydb` +- `tags` includes concrete patch tags when the registry publishes them +- numeric version tags are sorted newest first; mutable aliases such as `latest`, `nightly`, and `trunk` follow the numeric versions +- `truncated` becomes `true` only when the configured page limit is reached before the registry finishes pagination + +Avoid: + +- assuming `latest` is the only safe upgrade target +- using a short major/minor tag in production-like checks when an exact patch tag is available + +## Scenario 1C: Background Image Pull + +Goal: start slow registry downloads outside synchronous bootstrap or upgrade calls. + +Calls: + +```json +{ "tool": "local_ydb_pull_image", "arguments": { "profile": "ghcr261-clean", "image": "ghcr.io/ydb-platform/local-ydb:26.1.1.6", "confirm": false } } +{ "tool": "local_ydb_pull_image", "arguments": { "profile": "ghcr261-clean", "image": "ghcr.io/ydb-platform/local-ydb:26.1.1.6", "confirm": true } } +{ "tool": "local_ydb_pull_status", "arguments": { "jobId": "" } } +``` + +Expected: + +- plan-only output includes `docker image inspect` and `docker pull` +- with `confirm: true`, the tool returns quickly with `status: running` and a `jobId`, unless the image is already present +- status polling eventually returns `completed` or `failed` +- bootstrap and upgrade image preflight failures point back to `local_ydb_pull_image` instead of hanging inside `docker run` + +Avoid: + +- relying on `docker run` to implicitly pull large images inside a synchronous MCP tool call +- treating a 120-second MCP client timeout during image download as a YDB bootstrap failure + +## Scenario 2: Fresh Root Database Bootstrap + +Goal: validate network/volume/static-node bring-up for plain `/local` without creating a CMS tenant or dynamic node. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_bootstrap_root_database", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +{ "tool": "local_ydb_bootstrap_root_database", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +{ "tool": "local_ydb_scheme", "arguments": { "profile": "ghcr261-clean", "path": "/local" } } +``` + +Expected: + +- plan-only output starts the static container only +- no `admin database /local/... create` command is planned +- no dynamic-node container is created +- `scheme ls /local` succeeds through the static gRPC endpoint + +Avoid: + +- using the tenant bootstrap tool when the task only needs `/local` +- treating a missing configured tenant as a root database failure + +## Scenario 2A: Restart a Compatible Stopped Static Container + +Goal: verify bootstrap reuses a task-owned stopped static container only when its stored configuration remains compatible. + +Precondition: +bootstrap a disposable profile, then stop only that profile's static container. + +Calls: + +```json +{ "tool": "local_ydb_bootstrap_root_database", "arguments": { "profile": "", "confirm": true } } +{ "tool": "local_ydb_healthcheck", "arguments": { "profile": "", "databasePath": "/local" } } +``` + +Expected: + +- bootstrap applies the same compatibility checks to running and stopped containers and never relies on `docker port` +- the exact image reference and current image ID, selected network, `/ydb_data` volume or bind source/type/RW, complete loopback gRPC and monitoring bindings without extras, required environment, `unless-stopped` policy, and disabled healthcheck must all match +- tenant bootstrap additionally requires the GraphShard feature flag and both static and dynamic gRPC bindings +- an inspect failure or mismatch returns only the incompatible aspect plus recreation guidance and leaves the container stopped; changing the profile volume is a useful live negative control +- a compatible static container starts exactly once and the root healthcheck succeeds + +Avoid: + +- stopping or reusing a persisted `/local` stack owned by another workflow +- starting, removing, or automatically recreating a container whose stored configuration does not match the selected operation + +## Scenario 3: Fresh Bootstrap on an Isolated GHCR Stack + +Goal: validate network/volume/static/dynamic bring-up on a clean profile. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_bootstrap", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +{ "tool": "local_ydb_bootstrap", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +``` + +Expected: + +- Docker network and volume are created. +- Static container starts. +- `admin database /local/example status` succeeds; `PENDING_RESOURCES` is acceptable before the first dynamic node fully serves traffic. +- Dynamic container is recreated with the current launch command if needed. +- Final checks succeed: + `scheme ls /local/example`, viewer capabilities, dynamic node registration. + +What made this work: + +- exact image tag `ghcr.io/ydb-platform/local-ydb:26.1.1.6` +- dynamic launch sanitizes `grpc_config.ca/cert/key` from the generated config before calling `/ydbd server` +- dynamic launch disables TLS with: + `GRPC_TLS_PORT=` + `YDB_GRPC_ENABLE_TLS=0` + +Avoid: + +- using `ghcr.io/ydb-platform/local-ydb:26.1` +- reusing a stale dynamic container with `docker start` if its original launch command was broken + +## Scenario 4: Explicit Tenant and Dynamic-Node Smoke Test + +Goal: exercise tenant creation and dynamic start as separate tools. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_create_tenant", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +{ "tool": "local_ydb_create_tenant", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +{ "tool": "local_ydb_database_status", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_start_dynamic_node", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +{ "tool": "local_ydb_start_dynamic_node", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +{ "tool": "local_ydb_tenant_check", "arguments": { "profile": "ghcr261-clean" } } +``` + +Expected: + +- `create_tenant` waits until `admin database ... status` is readable. It should not insist on `RUNNING` before the first dynamic node. +- `database_status` can show `PENDING_RESOURCES` before dynamic registration and `RUNNING` afterwards. +- `start_dynamic_node` recreates the container if it is stale or exited. +- `tenant_check` succeeds only after the dynamic node is actually serving the tenant gRPC path. + +Avoid: + +- assuming `create OK` alone means the tenant is resolvable by NodeBroker + +## Scenario 5: Runtime Diagnostics + +Goal: cover the focused read-only diagnostics used when bootstrap fails. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_database_status", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_healthcheck", "arguments": { "profile": "ghcr261-clean", "noCache": true } } +{ "tool": "local_ydb_container_logs", "arguments": { "profile": "ghcr261-clean", "target": "static", "lines": 120 } } +{ "tool": "local_ydb_container_logs", "arguments": { "profile": "ghcr261-clean", "target": "dynamic", "lines": 120 } } +{ "tool": "local_ydb_nodes_check", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_graphshard_check", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_storage_placement", "arguments": { "profile": "ghcr261-clean" } } +``` + +Expected: + +- `healthcheck` gives the official YDB self-check status and issue hierarchy before falling back to narrower local heuristics. +- `container_logs(dynamic)` shows whether the node: + registered, + fetched config, + crashed on TLS/cert, + or failed tenant resolution. +- `container_logs(static)` shows `NodeBroker` and `SchemeShard` evidence for create/resolve problems. +- `nodes_check` and `graphshard_check` become useful after the stack is healthy or after auth is enabled with a valid viewer session path. +- `storage_placement` proves the tenant’s groups are on `/ydb_data/pdisks/1`. + +Avoid: + +- using generic `docker logs` or shell-only inspection before trying `local_ydb_container_logs` + +## Scenario 6: Idempotent Restart + +Goal: confirm the restart tool is safe and uses the current launch command. + +Profile: +`ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_restart_stack", "arguments": { "profile": "ghcr261-clean", "confirm": false } } +{ "tool": "local_ydb_restart_stack", "arguments": { "profile": "ghcr261-clean", "confirm": true } } +{ "tool": "local_ydb_status_report", "arguments": { "profile": "ghcr261-clean" } } +``` + +Expected: + +- static node restarts first +- tenant status is checked before dynamic node is started again +- dynamic node is recreated if it is not already `Running` +- post-restart `status_report` returns `tenant=ok`, `nodes=ok` + +Avoid: + +- trusting a plain `docker start ` path for a container created with old flags + +## Scenario 7: Dump and Restore + +Goal: prove backup/restore on a clean GHCR stack. + +Profiles: + +- source: `local` +- target: `ghcr261-clean` + +Calls: + +```json +{ "tool": "local_ydb_dump_tenant", "arguments": { "profile": "local", "confirm": true, "dumpName": "pre-auth-mcp-20260425" } } +{ "tool": "local_ydb_list_dumps", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_restore_tenant", "arguments": { "profile": "ghcr261-clean", "confirm": true, "dumpName": "pre-auth-mcp-20260425" } } +{ "tool": "local_ydb_tenant_check", "arguments": { "profile": "ghcr261-clean" } } +{ "tool": "local_ydb_graphshard_check", "arguments": { "profile": "ghcr261-clean" } } +``` + +Path-level example: + +```json +{ "tool": "local_ydb_dump_tenant", "arguments": { "profile": "local", "confirm": true, "dumpName": "one-table-smoke", "path": "dir/table" } } +{ "tool": "local_ydb_restore_tenant", "arguments": { "profile": "ghcr261-clean", "confirm": true, "dumpName": "one-table-smoke", "path": ".", "describePaths": ["dir/table"], "countQueries": [{ "label": "dir/table rows", "query": "SELECT COUNT(*) FROM `dir/table`;" }] } } +``` + +For dump, `path` is the source object or directory for `ydb tools dump -p`. For restore, `path` is the destination directory for `ydb tools restore -p`; restoring a single table dump back under the tenant root normally uses `path: "."`. + +Expected: + +- dump helper container runs with `--entrypoint /bin/bash` +- list-dumps reports dump directories that contain a `tenant` folder +- restore helper container runs with `--entrypoint /bin/bash` +- restored tenant returns `.metadata .sys` +- GraphShard exists after restore + +Avoid: + +- assuming the helper image entrypoint can run arbitrary shell commands without `--entrypoint /bin/bash` + +## Scenario 8: Auth Artifact Preparation + +Goal: test the two new preparation tools before mutating the running stack. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_prepare_auth_config", "arguments": { "profile": "ghcr261-auth", "confirm": false } } +{ "tool": "local_ydb_prepare_auth_config", "arguments": { "profile": "ghcr261-auth", "confirm": true } } +{ "tool": "local_ydb_write_dynamic_auth_config", "arguments": { "profile": "ghcr261-auth", "confirm": false } } +{ "tool": "local_ydb_write_dynamic_auth_config", "arguments": { "profile": "ghcr261-auth", "confirm": true } } +``` + +Expected: + +- `prepare_auth_config` writes: + `/tmp/local-ydb-auth/config.auth.yaml` + `/tmp/local-ydb-auth/root.password` +- generated auth config includes: + `enforce_user_token_requirement: true` + `viewer_allowed_sids` + `monitoring_allowed_sids` + `administration_allowed_sids` + `register_dynamic_node_allowed_sids` +- viewer/admin allowed SIDs include both `root` and `root@builtin` +- `write_dynamic_auth_config` writes: + `StaffApiUserToken: "root@builtin"` + `NodeRegistrationToken: "root@builtin"` + +Avoid: + +- assuming the viewer/admin SID is only `root@builtin` +- assuming the default root token identifies as `root@builtin`; in our run `whoami` reported `User SID: root` + +## Scenario 9: Auth Rollout + +Goal: turn a healthy clean stack into a working auth-enabled stack. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_apply_auth_hardening", "arguments": { "profile": "ghcr261-auth", "confirm": false } } +{ "tool": "local_ydb_apply_auth_hardening", "arguments": { "profile": "ghcr261-auth", "confirm": true } } +``` + +Expected: + +- the reviewed config is copied into the static container +- dynamic node is stopped +- static node is restarted +- tenant status remains readable via password +- dynamic node is recreated with: + `--auth-token-file /run/local-ydb/dynamic-node-auth.pb` + sanitized dynamic config + TLS disabled for local mode + +Avoid: + +- restarting a stale dynamic auth container without recreation +- using a hardcoded login URL on `8765` when the profile runs on another monitoring port + +## Scenario 10: Post-Auth Verification + +Goal: prove the auth rollout actually worked. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_auth_check", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_status_report", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_nodes_check", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_graphshard_check", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_database_status", "arguments": { "profile": "ghcr261-auth" } } +``` + +Expected: + +- `auth_check.viewerWhoamiStatus == 401` +- authenticated tenant metadata still works +- `status_report` returns `tenant=ok`, `nodes=ok` +- `nodes_check` returns the dynamic node +- `graphshard_check` reports `GraphShardExists=true` +- `database_status` returns `State: RUNNING` + +Avoid: + +- treating a `401` on `/viewer/json/whoami` as an error after auth; it is the expected anonymous result + +## Scenario 10A: Root Password Rotation + +Goal: change the root password through one MCP tool without exposing it in plan output. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_set_root_password", "arguments": { "profile": "ghcr261-auth", "password": "", "confirm": false } } +``` + +Expected: + +- plan-only output does not print the raw password +- the tool rotates the runtime password with `ALTER USER` +- the generated host auth config and `root.password` file are updated after the runtime password change +- post-change anonymous `viewer/json/whoami` should still return `401` +- authenticated tenant checks should work with the new password +- empty passwords are an upstream YDB capability, but this MCP tool requires a non-empty `password` argument +- if the cluster config defines `auth_config.password_complexity`, password rotation can fail until the supplied value matches that policy + +Avoid: + +- storing the password directly in committed config +- changing the password on a profile that lacks `authConfigPath` or `rootPasswordFile` +- assuming every punctuation mark is portable across builds; prefer letters, digits, and documented YDB special characters `!@#$%^&*()_+{}|<>?=` unless the target image has already been rehearsed with a broader set + +## Scenario 11: Add Extra Dynamic Nodes + +Goal: add multiple dynamic nodes to a healthy auth-enabled stack without creating extra profile entries. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_add_dynamic_nodes", "arguments": { "profile": "ghcr261-auth", "count": 2, "confirm": false } } +{ "tool": "local_ydb_add_dynamic_nodes", "arguments": { "profile": "ghcr261-auth", "count": 2, "confirm": true } } +{ "tool": "local_ydb_nodes_check", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_tenant_check", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_container_logs", "arguments": { "profile": "ghcr261-auth", "target": "dynamic", "lines": 80 } } +``` + +Expected: + +- plan-only output creates `ydb-dyn-example-ghcr261-2` and `ydb-dyn-example-ghcr261-3` +- default ports are derived from the profile: + `2258/9067/19303` and `2259/9068/19304` +- dynamic containers mount `/tmp/local-ydb-auth/dynamic-node-auth.pb` when auth is enabled +- `confirm=true` starts one node, verifies its IC port appears in `nodelist`, then starts the next +- `nodes_check` reports three dynamic nodes total after adding two extra nodes to the one-node baseline +- tenant metadata remains reachable + +Avoid: + +- using `startIndex: 1`; that conflicts with the profile's main dynamic container +- adding many nodes at once on a live auth stack without first checking logs and `nodelist` + +Rollback: + +```bash +docker rm -f ydb-dyn-example-ghcr261-2 ydb-dyn-example-ghcr261-3 +``` + +## Scenario 12: Remove Extra Dynamic Nodes + +Goal: remove one or more extra dynamic nodes from a healthy stack without touching the base dynamic node. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_remove_dynamic_nodes", "arguments": { "profile": "ghcr261-auth", "confirm": false } } +{ "tool": "local_ydb_remove_dynamic_nodes", "arguments": { "profile": "ghcr261-auth", "confirm": true } } +{ "tool": "local_ydb_nodes_check", "arguments": { "profile": "ghcr261-auth" } } +``` + +Expected: + +- plan-only output targets the highest-index extra node first, such as `ydb-dyn-example-ghcr261-3` +- `confirm=true` removes that container and verifies its IC port disappears from authenticated `nodelist` +- the base dynamic node `ydb-dyn-example-ghcr261` remains running +- tenant metadata remains reachable after removal + +Optional explicit targeting: + +```json +{ "tool": "local_ydb_remove_dynamic_nodes", "arguments": { "profile": "ghcr261-auth", "confirm": false, "containers": ["ydb-dyn-example-ghcr261-2"] } } +``` + +Avoid: + +- treating the profile's main `dynamicContainer` as removable through this tool +- removing multiple extra nodes at once on a live stack without checking `nodelist` after each removal + +## Scenario 13: Add Storage Groups + +Goal: increase `NumGroups` for a tenant storage pool by rereading and redefining the current pool shape. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_add_storage_groups", "arguments": { "profile": "ghcr261-auth", "count": 1, "confirm": false } } +{ "tool": "local_ydb_add_storage_groups", "arguments": { "profile": "ghcr261-auth", "count": 1, "confirm": true } } +{ "tool": "local_ydb_storage_placement", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_tenant_check", "arguments": { "profile": "ghcr261-auth" } } +``` + +Expected: + +- plan-only output targets tenant pool `/local/example:hdd` +- the generated `DefineStoragePool` request preserves the current pool fields and increases only `NumGroups` +- `confirm=true` succeeds without breaking tenant metadata +- post-change `ReadStoragePool` reports a higher `NumGroups` for the tenant pool +- `QueryBaseConfig` reflects the updated group set on the current PDisk layout + +Avoid: + +- treating `DecommitGroups` or `storage_units_to_remove` as a pool expansion path +- using a partial `DefineStoragePool` shape that drops `PDiskFilter`, `ScopeId`, or `ItemConfigGeneration` + +## Scenario 14: Destroy Stack + +Goal: remove tenant metadata, local-ydb nodes, Docker network, and profile storage from one tool. + +Recommended disposable profile: +`ghcr-rebuild-clean` + +Calls: + +```json +{ "tool": "local_ydb_destroy_stack", "arguments": { "profile": "ghcr-rebuild-clean", "confirm": false } } +``` + +Optional shared-host-path cleanup: + +```json +{ "tool": "local_ydb_destroy_stack", "arguments": { "profile": "ghcr-rebuild-clean", "confirm": false, "removeDumpHostPath": true, "removeAuthArtifacts": true } } +``` + +Expected: + +- plan-only output removes tenant metadata first when the static node is reachable +- extra dynamic nodes are removed before the profile's main dynamic container +- the static container, Docker network, and Docker volume are removed +- bind-mounted data is not deleted unless `removeBindMountPath: true` +- auth files and dump directories are not deleted unless explicitly requested + +Avoid: + +- enabling host-path deletion flags on shared paths without checking whether other profiles use them +- using this tool with `confirm=true` on a profile you still need without first taking a dump + +## Scenario 15: Reduce Storage Groups By Rebuild + +Goal: reduce a tenant pool from a larger `NumGroups` back to a smaller one without relying on an unverified live shrink path. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_reduce_storage_groups", "arguments": { "profile": "ghcr261-auth", "count": 1, "dumpName": "shrink-smoke", "confirm": false } } +``` + +Expected: + +- plan-only output starts with a tenant dump +- the stack is rebuilt with `admin database /local/example create hdd:1` +- auth-enabled profiles re-run: + `local_ydb_prepare_auth_config` + `local_ydb_write_dynamic_auth_config` + `local_ydb_apply_auth_hardening` +- extra dynamic-node suffixes are re-added after restore/auth reapply + +Avoid: + +- treating `DefineStoragePool { NumGroups: smaller }` as a proven live shrink path +- deleting auth artifacts during the rebuild path for an auth-enabled profile + +## Scenario 15A: Version Upgrade By Rebuild + +Goal: upgrade a working profile to a specific image tag without reusing the old volume in place. + +Profile: +`ghcr261-auth` + +Calls: + +```json +{ "tool": "local_ydb_upgrade_version", "arguments": { "profile": "ghcr261-auth", "version": "26.1.1.6", "confirm": false } } +``` + +Optional execution path on a disposable stack: + +```json +{ "tool": "local_ydb_upgrade_version", "arguments": { "profile": "ghcr261-auth", "version": "", "dumpName": "upgrade-smoke", "confirm": true } } +``` + +Expected: + +- the plan starts with source and target image preflight checks +- if either image is missing, run `local_ydb_pull_image` first and retry after `local_ydb_pull_status` reports completion +- after image preflight, the upgrade path performs dump, destroy, bootstrap, restore, auth reapply, and extra dynamic-node recreation in that order +- auth-enabled profiles re-run: + `local_ydb_prepare_auth_config` + `local_ydb_write_dynamic_auth_config` + `local_ydb_apply_auth_hardening` +- successful final inventory verifies the recreated containers' image tags and then persists `profiles..image` in the file-backed config +- a verified image mismatch returns the accumulated history and leaves the profile image unchanged +- if final inventory is unavailable only after dump/rebuild/restore/auth/node phases succeed, the response appends a safe failed verification result, omits `imageVerification`, preserves the full history, and persists the target profile image for subsequent operations + +Avoid: + +- using this tool against a profile pinned by image digest +- using this tool against a profile with `bindMountPath`; automatic version upgrade only supports volume-backed rebuilds +- treating it as an in-place rolling upgrade of the existing volume +- skipping the explicit target tag check from `local_ydb_list_versions` + +## Scenario 16: Cleanup Candidates + +Goal: test the dangerous cleanup tool only on disposable targets. + +Recommended disposable targets: + +- stale rehearsal volumes discovered by `storage_leftovers` +- old test dump directories under `/tmp/local-ydb-dump/...` +- explicitly unused side-by-side rehearsal volumes such as `ydb-local-data-ghcr-clean` only after you have decided they are no longer needed + +Calls: + +```json +{ "tool": "local_ydb_storage_leftovers", "arguments": { "profile": "ghcr261-auth" } } +{ "tool": "local_ydb_cleanup_storage", "arguments": { "profile": "ghcr261-auth", "confirm": false, "volumes": [""] } } +{ "tool": "local_ydb_cleanup_storage", "arguments": { "profile": "ghcr261-auth", "confirm": false, "paths": ["/tmp/local-ydb-dump/"] } } +``` + +Expected: + +- plan-only output includes the exact `docker volume rm` or `rm -rf` target +- unsafe targets like `/tmp`, `/var/lib/docker`, or unrelated names are rejected by validation + +Avoid: + +- using `cleanup_storage(confirm=true)` against any active profile volume or the current auth stack + +## Coverage Matrix + +- Bootstrap and lifecycle: + `local_ydb_bootstrap_root_database`, `local_ydb_bootstrap`, `local_ydb_create_tenant`, `local_ydb_start_dynamic_node`, `local_ydb_add_dynamic_nodes`, `local_ydb_remove_dynamic_nodes`, `local_ydb_restart_stack` +- Version discovery: + `local_ydb_list_versions` +- Image pulls: + `local_ydb_pull_image`, `local_ydb_pull_status` +- Storage-pool expansion: + `local_ydb_add_storage_groups` +- Storage-pool reduction by rebuild: + `local_ydb_reduce_storage_groups` +- Version upgrade by rebuild: + `local_ydb_upgrade_version` +- Full teardown: + `local_ydb_destroy_stack` +- Backup and restore: + `local_ydb_list_dumps`, `local_ydb_dump_tenant`, `local_ydb_restore_tenant` +- Auth rollout: + `local_ydb_prepare_auth_config`, `local_ydb_write_dynamic_auth_config`, `local_ydb_apply_auth_hardening`, `local_ydb_set_root_password`, `local_ydb_permissions`, `local_ydb_auth_check` +- Managed SQL: + `local_ydb_sql` +- Read-only diagnostics: + `local_ydb_inventory`, `local_ydb_database_status`, `local_ydb_healthcheck`, `local_ydb_container_logs`, `local_ydb_status_report`, `local_ydb_tenant_check`, `local_ydb_scheme`, `local_ydb_permissions`, `local_ydb_nodes_check`, `local_ydb_graphshard_check`, `local_ydb_storage_placement`, `local_ydb_storage_leftovers` +- Cleanup: + `local_ydb_cleanup_storage` + +## Known Working Baseline + +Field-proven successful stack in this repo: + +- image: `ghcr.io/ydb-platform/local-ydb:26.1.1.6` +- clean profile: `ghcr261-clean` +- auth profile: `ghcr261-auth` +- dump name used successfully: `pre-auth-mcp-20260425` +- auth files: + `/tmp/local-ydb-auth/config.auth.yaml` + `/tmp/local-ydb-auth/root.password` + `/tmp/local-ydb-auth/dynamic-node-auth.pb` + +Successful end state: + +- anonymous `viewer/json/whoami` returns `401` +- authenticated `scheme ls /local/example` succeeds +- authenticated `nodelist` returns the dynamic node +- authenticated `capabilities` reports `GraphShardExists=true` diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/storage-migration.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/storage-migration.md new file mode 100644 index 0000000..ef8ace9 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/storage-migration.md @@ -0,0 +1,198 @@ +# Storage Migration Reference + +## Risk Model + +Storage changes are high risk when they touch live tenants, PDisks, storage pools, Docker volumes, or bind-mounted data directories. Use read-only discovery first, then present impact and rollback before mutation. + +Prefer this order: + +1. Inspect current containers, mounts, image tags, tenant names, and ports. +2. Capture YDB config and BSC placement with `ReadStoragePool` and `QueryBaseConfig`. +3. Verify tenant metadata and small table reads before any move. +4. Dump data to a separate path. +5. Rebuild or restore on a copied volume when possible. +6. Switch clients only after metadata reads and placement checks pass. +7. Clean old storage only after final verification. + +## Discovery Commands + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=10 -l \ + "docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'" + +ssh -o BatchMode=yes -o ConnectTimeout=10 -l \ + "df -h / /path/to/storage 2>/dev/null || true" + +ssh -o BatchMode=yes -o ConnectTimeout=10 -l \ + "docker volume ls --format '{{.Name}}' | grep -E 'ydb|local' || true" + +ssh -o BatchMode=yes -o ConnectTimeout=10 -l \ + "docker inspect ydb-local ydb-dyn-example 2>/dev/null" +``` + +Tenant and BSC checks: + +```bash +sudo cat /path/to/root.password | docker exec -i ydb-local bash -lc ' + umask 077 + cat >/tmp/root.password + /ydb -e grpc://localhost:2136 -d /local/ \ + --user root \ + --password-file /tmp/root.password \ + scheme ls /local/ + /ydbd --server localhost:2136 \ + --user root \ + --password-file /tmp/root.password \ + admin blobstorage config invoke --proto "Command { ReadStoragePool { BoxId: 1 } }" + /ydbd --server localhost:2136 \ + --user root \ + --password-file /tmp/root.password \ + admin blobstorage config invoke \ + --proto "Command { QueryBaseConfig { RetrieveDevices: true SuppressNodes: true } }" + rm -f /tmp/root.password +' +``` + +Treat monitoring/UI `StorageGroups` as advisory only. Use `ReadStoragePool` for pool config and `NumGroups`; use `QueryBaseConfig { RetrieveDevices: true SuppressNodes: true }` for actual `Group -> PDisk` placement. + +## Single-Disk Rebuild Pattern + +Use this path when the user wants all active YDB storage on one real disk or bind mount. The safe pattern is dump -> fresh single-disk local-ydb -> restore -> verify -> cleanup. Do not try to live-move non-empty tenant groups with `ReassignGroupDisk` for this goal unless the user accepts the risk after rehearsal. + +Target topology should use placeholders in reusable docs: + +- active static/root node and tenant data in one bind mount: `/path/to/ydb-data:/ydb_data` +- static PDisk path inside YDB: `/ydb_data/pdisks/1` +- tenant: `/local/` +- dump path: `/path/to/ydb-dump/-` + +## Dump + +Stop clients before taking consistency-sensitive dumps. Keep secrets in temp files and remove them on exit. + +```bash +TS=$(date +%Y%m%d-%H%M%S) +BASE=/path/to/ydb-dump/-$TS +sudo install -d -o -g "$BASE" + +docker inspect ydb-local ydb-dyn-example >"$BASE/docker-inspect-before.json" 2>/dev/null || true + +sudo cat /path/to/root.password | docker run --rm -i \ + --network container:ydb-local \ + -v /path/to/ydb-dump:/dump \ + --entrypoint /bin/bash \ + ghcr.io/ydb-platform/local-ydb:26.1.1.6 \ + -lc ' + set -euo pipefail + umask 077 + cat >/tmp/root.password + trap "rm -f /tmp/root.password" EXIT + /ydb -e grpc://localhost:2137 -d /local/ --user root --password-file /tmp/root.password \ + tools dump -p . -o /dump/-/tenant + ' +``` + +Adjust endpoint, database, and dump path to the live topology. Whole-tenant dump can be unreliable for some layouts; use table-level dumps when whole-tenant dump is unsupported, too broad, or fails on a rehearsed copy. + +## Fresh Rebuild + +Create a new static/root data directory or Docker volume, then start a fresh static node with one target data mount: + +```bash +docker rm -f ydb-local ydb-dyn-example 2>/dev/null || true +sudo install -d -o root -g root -m 0755 /path/to/ydb-data + +docker run -d --name ydb-local \ + --no-healthcheck \ + --network ydb-net \ + --restart unless-stopped \ + -p 127.0.0.1:8765:8765 \ + -v /path/to/ydb-data:/ydb_data \ + -e GRPC_PORT=2136 \ + -e MON_PORT=8765 \ + -e GRPC_TLS_PORT= \ + -e YDB_GRPC_ENABLE_TLS=0 \ + -e YDB_ANONYMOUS_CREDENTIALS=1 \ + -e YDB_LOCAL_SURVIVE_RESTART=1 \ + -e YDB_FEATURE_FLAGS=enable_graph_shard \ + ghcr.io/ydb-platform/local-ydb:26.1.1.6 +``` + +Create the replacement tenant through CMS, start its dynamic node, and wait for `scheme ls /local/` to succeed before restore. + +## Restore + +When using the `local-ydb` image as a helper container, override the image entrypoint to `/bin/bash`: + +```bash +sudo cat /path/to/root.password | docker run --rm -i \ + --network container:ydb-local \ + -v /path/to/ydb-dump:/dump \ + --entrypoint /bin/bash \ + ghcr.io/ydb-platform/local-ydb:26.1.1.6 \ + -lc ' + set -euo pipefail + umask 077 + cat >/tmp/root.password + trap "rm -f /tmp/root.password" EXIT + /ydb -e grpc://localhost:2137 -d /local/ --user root --password-file /tmp/root.password \ + tools restore -p . -i /dump/-/tenant + ' +``` + +After restore, recreate users/grants and auth config if the fresh cluster started without mandatory auth. + +## Verification Before Cleanup + +Do not delete old storage until all checks pass: + +```bash +/ydb -e grpc://localhost:2137 -d /local/ --user root --password-file /tmp/root.password \ + scheme ls /local/ + +/ydb -e grpc://localhost:2137 -d /local/ --user root --password-file /tmp/root.password \ + scheme describe /local// + +/ydb -e grpc://localhost:2137 -d /local/ --user root --password-file /tmp/root.password \ + sql -s "SELECT COUNT(*) AS c FROM ;" + +/ydbd --server localhost:2136 --user root --password-file /tmp/root.password \ + admin blobstorage config invoke \ + --proto 'Command { QueryBaseConfig { RetrieveDevices: true SuppressNodes: true } }' +``` + +Also verify: + +- tenant is `RUNNING` +- all expected dynamic nodes are `Up` +- `GraphShardExists` is `true` when GraphShard is required +- anonymous monitoring access returns `401` in hardened deployments +- direct public YDB gRPC is not exposed unless that was the approved topology +- old Docker volumes and bind-mounted disk paths are still present until rollback is no longer needed + +## Cleanup + +Only after verification and a rollback hold period, remove old storage paths and Docker volumes named by the live discovery output. Do not put concrete destructive `rm -rf` paths into reusable docs; write them only in private, host-specific execution notes. + +Before declaring old storage deleted, check both bind-mounted paths and Docker volumes: + +```bash +docker volume ls --format '{{.Name}}' | grep -E 'ydb|local' || true + +docker ps -a --filter volume=ydb-local-data --format 'table {{.Names}}\t{{.Status}}' + +sudo find /path/to/storage /var/lib/docker/volumes -maxdepth 4 \ + \( -path '*ydb*pdisks*' -o -path '*ydb-dump*' -o -path '*ydb-data*' -o -path '*ydb-local-data*' \) \ + -print 2>/dev/null | sort +``` + +## Pitfalls + +- `ReassignGroupDisk` success and `OperatingStatus: FULL` do not prove tenant metadata survived. +- Removing an apparently unused PDisk file can break root or scheme state if it still stores shared metadata. +- UI storage counts can disagree with BSC placement. Prefer `QueryBaseConfig` for physical placement. +- Adding a PDisk path is a BSC runtime operation, not only a config-file edit. Capture current `HostConfig`, use the server-accepted `DefineHostConfig` shape, and verify the new PDisk appears before allocating groups to it. +- Changes to `host_configs` or expected slot counts in generated config may not immediately change BSC runtime state for an existing static disk. +- `DecommitGroups` is not a tenant allocation shrink operation. +- `storage_units_to_remove` needs server-side implementation verification before live use. +- Whole-tenant dump/restore and table-level dump/restore have different path semantics; test on a copy first. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/topology.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/topology.md new file mode 100644 index 0000000..3c44985 --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/topology.md @@ -0,0 +1,186 @@ +# Topology Reference + +## Baseline Docker Topology + +Common single-host `local-ydb` topology: + +- static/root node container: `ydb-local` +- YDB image: `ghcr.io/ydb-platform/local-ydb:26.1.1.6` +- persistent data volume or bind mount at `/ydb_data` +- local domain: `/local` +- tenant database: `/local/` +- dynamic tenant nodes: `ydb-dyn-`, optionally more nodes with unique ports +- monitoring backend: `127.0.0.1:8765` + +`/local` is the root local domain. It can run a database, but it does not get a GraphShard tablet just because `YDB_FEATURE_FLAGS=enable_graph_shard` is enabled. + +Practical tag note: + +- `ghcr.io/ydb-platform/local-ydb:26.1.1.6` was pullable and worked for a stable `26.1` local stack. +- `ghcr.io/ydb-platform/local-ydb:26.1` was not a reliable alias in our tests. Use an exact patch tag. + +## Static Node + +Local development static-node shape for host-side clients: + +```bash +docker run -d --name ydb-local \ + --no-healthcheck \ + --network ydb-net \ + --restart unless-stopped \ + -p 127.0.0.1:2136:2136 \ + -p 127.0.0.1:2137:2137 \ + -p 127.0.0.1:8765:8765 \ + -v ydb-local-data:/ydb_data \ + -e GRPC_PORT=2136 \ + -e MON_PORT=8765 \ + -e GRPC_TLS_PORT= \ + -e YDB_GRPC_ENABLE_TLS=0 \ + -e YDB_ANONYMOUS_CREDENTIALS=1 \ + -e YDB_LOCAL_SURVIVE_RESTART=1 \ + -e YDB_FEATURE_FLAGS=enable_graph_shard \ + ghcr.io/ydb-platform/local-ydb:26.1.1.6 +``` + +Notes: + +- `2136` is the root/static gRPC endpoint for host apps using `/local`. +- `2137` is useful when dynamic tenant nodes share `ydb-local`'s network namespace and host apps use `/local/`. +- In a hardened deployment, keep gRPC ports Docker-internal unless direct host access is explicitly required. +- `8765` should be loopback-only if exposed externally through HTTPS reverse proxy. +- `--no-healthcheck` may be required in non-TLS topology because the image healthcheck can expect `/ydb_certs/ca.pem`. +- For bind mounts, use placeholders such as `/path/to/ydb-data:/ydb_data` in reusable docs. +- For side-by-side rebuilds or rehearsals, use a separate profile with distinct container names, Docker network, Docker volume, and monitoring/gRPC/IC ports rather than mutating a broken stack in place. + +## GraphShard Tenant + +Create `/local/` through the public CMS gRPC API, not SQL. Minimal request shape: + +```text +path: /local/ +resources.storageUnits: [{unitKind: "hdd", count: "1"}] +options: {planResolution: 50, coordinators: 1, mediators: 1} +``` + +The tenant can remain in `PENDING_RESOURCES` until at least one dynamic node is started for it. + +Field-proven behavior on `ghcr.io/ydb-platform/local-ydb:26.1.1.6`: + +- `ydbd admin database /local/ create hdd:1` can return `OK` and `admin database ... status` can report `State: PENDING_RESOURCES`. +- That is not a failure. Start the first dynamic node after `status` succeeds; the tenant can move to `RUNNING` only after the dynamic node registers. + +When the documented CMS proto recipe is version-sensitive, pin the upstream `ydb-platform/ydb` commit used for matching proto shapes in project docs. + +## Dynamic Nodes + +Generated `local-ydb` config may advertise the static node as `localhost:19001`. Dynamic node containers therefore often need to share the `ydb-local` network namespace. + +For `ghcr.io/ydb-platform/local-ydb:26.1.1.6`, the generated static-node `config.yaml` can also contain: + +```yaml +grpc_config: + ca: /ydb_certs/ca.pem + cert: /ydb_certs/cert.pem + key: /ydb_certs/key.pem +``` + +The static container has those files; a plain dynamic container usually does not. A field-proven non-TLS dynamic launch is therefore: + +```bash +docker run -d --name ydb-dyn-example \ + --no-healthcheck \ + --network container:ydb-local \ + --restart unless-stopped \ + -v ydb-local-data:/ydb_data:ro \ + -e GRPC_PORT=2137 \ + -e MON_PORT=8766 \ + -e GRPC_TLS_PORT= \ + -e YDB_GRPC_ENABLE_TLS=0 \ + --entrypoint /bin/bash \ + ghcr.io/ydb-platform/local-ydb:26.1.1.6 \ + -lc ' + set -euo pipefail + cfg=/tmp/local-ydb-dynamic-config.yaml + sed \ + -e "/^ ca: \/ydb_certs\/ca\.pem$/d" \ + -e "/^ cert: \/ydb_certs\/cert\.pem$/d" \ + -e "/^ key: \/ydb_certs\/key\.pem$/d" \ + /ydb_data/cluster/kikimr_configs/config.yaml > "$cfg" + exec /ydbd server \ + --yaml-config "$cfg" \ + --tcp \ + --node-broker grpc://127.0.0.1:2136 \ + --grpc-port 2137 \ + --mon-port 8766 \ + --ic-port 19002 \ + --tenant /local/ \ + --node-host 127.0.0.1 \ + --node-address 127.0.0.1 \ + --node-resolve-host 127.0.0.1 \ + --node-domain local + ' +``` + +Additional nodes need unique `--grpc-port`, `--mon-port`, and `--ic-port` values. Dynamic node IDs are assigned by NodeBroker. Scripts should discover IDs through `/viewer/json/nodelist`, not hardcode observed values. + +## Adding Dynamic Nodes After Mandatory Auth + +Before adding nodes on a live host, inspect the current shape and copy mount, tenant, port, image, and network patterns from the working dynamic node: + +```bash +docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' + +docker inspect ydb-dyn-example \ + --format '{{json .Config.Cmd}} {{json .Mounts}} {{json .HostConfig.NetworkMode}} {{json .Config.Entrypoint}} {{json .Config.Image}}' +``` + +For a mandatory-auth local-ydb dynamic node, `--auth-token-file` expects an `NKikimrProto.TAuthConfig` text protobuf, not a raw token file. The two startup fields are: + +```text +StaffApiUserToken: "" +NodeRegistrationToken: "" +``` + +Start one new node first, verify it reaches `nodelist`, then start the next. Preserve logs from failed attempts until the replacement is healthy. + +If a dynamic-node container exists but was created with stale image tags or stale flags, remove it and recreate it instead of using `docker start`. Reusing an old container can preserve the broken launch command. + +## Storage Pools + +Storage-pool changes should be verified through BSC, not only UI or `admin database ... status`: + +```bash +/ydbd --server localhost:2136 --no-password \ + admin blobstorage config invoke --proto 'Command { ReadStoragePool { BoxId: 1 } }' + +/ydbd --server localhost:2136 --no-password \ + admin blobstorage config invoke \ + --proto 'Command { QueryBaseConfig { RetrieveDevices: true SuppressNodes: true } }' +``` + +Use `ReadStoragePool` for pool config and `NumGroups`; use `QueryBaseConfig` for actual `Group -> PDisk` placement. Confirm placement before deleting old disk files or volumes. + +## Upstream YDB Lookup + +Search upstream code when behavior is unclear. Keep search terms stable: symbol names, proto fields, endpoint names, CLI subcommands, config keys, error strings, or feature flags. + +Examples: + +```bash +gh api 'search/code?q="CreateDatabaseRequest"+repo:ydb-platform/ydb' \ + --jq '.items[] | [.path, .html_url] | @tsv' + +gh api 'search/code?q=GraphShard+repo:ydb-platform/ydb' \ + --jq '.items[] | [.path, .html_url] | @tsv' + +gh api 'search/code?q="register_dynamic_node_allowed_sids"+repo:ydb-platform/ydb' \ + --jq '.items[] | [.path, .html_url] | @tsv' + +gh api 'search/code?q="GraphShard is not enabled on the database"+repo:ydb-platform/ydb' \ + --jq '.items[] | [.path, .html_url] | @tsv' + +gh api repos/ydb-platform/ydb/contents/ydb/public/api/protos/ydb_cms.proto \ + --jq '.content' +``` + +Read matching files through the GitHub API and decode content locally when needed. Prefer the exact upstream commit referenced by project docs when reproducing old behavior. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/references/verification.md b/plugins/local-ydb-toolkit/skills/local-ydb/references/verification.md new file mode 100644 index 0000000..c7d6fae --- /dev/null +++ b/plugins/local-ydb-toolkit/skills/local-ydb/references/verification.md @@ -0,0 +1,256 @@ +# Verification Reference + +## Tenant and Node State + +Use the MCP tool first when available: + +```json +{ "tool": "local_ydb_status_report", "arguments": { "profile": "" } } +{ "tool": "local_ydb_healthcheck", "arguments": { "profile": "" } } +``` + +`local_ydb_healthcheck` wraps YDB's built-in `monitoring healthcheck --format json` self-check. Treat `selfCheckResult=GOOD` as the main database-level green signal; for other results, route by issue type before trying ad hoc shell diagnostics. + +Unauthenticated local-dev examples: + +```bash +curl -sSL 'http://127.0.0.1:8765/viewer/json/tenants?database=/local' + +curl -sSL 'http://127.0.0.1:8765/viewer/json/nodelist?database=%2Flocal%2Fexample&enums=true&type=any' \ + | python3 -m json.tool +``` + +In a hardened deployment, anonymous `viewer/json` should return `401`. Do not present these commands as post-auth verification unless an authenticated session or supported credential mechanism is included. + +Authenticated viewer flow that has worked on local-ydb builds: + +```bash +PASS=$(sudo cat /path/to/root.password) +DATA=$(printf '{"user":"root","password":"%s"}' "$PASS") + +curl -sS -c /tmp/ydb-cookies.txt \ + -H 'Content-Type: application/json' \ + -X POST \ + --data "$DATA" \ + http://127.0.0.1:8765/login + +curl -fsSL -b /tmp/ydb-cookies.txt -L \ + 'http://127.0.0.1:8765/viewer/json/capabilities?database=%2Flocal%2Fexample' +``` + +Observed details: + +- the working login field may be `user` +- protected viewer endpoints may return `307` after login; use `curl -L` +- cookie-based UI flow can work when generic Bearer-token testing does not +- use the actual monitoring port from the selected profile; do not hardcode `8765` when the stack runs on a different port + +Authenticated node-list helper for hardened hosts: + +```bash +python3 - <<'PY' +import http.cookiejar +import json +import subprocess +import urllib.parse +import urllib.request + +password_file = "/path/to/root.password" +database = "/local/example" + +password = subprocess.check_output(["sudo", "cat", password_file], text=True).rstrip("\n") +cookies = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies)) +opener.open( + urllib.request.Request( + "http://127.0.0.1:8765/login", + data=json.dumps({"user": "root", "password": password}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ), + timeout=10, +) + +url = ( + "http://127.0.0.1:8765/viewer/json/nodelist?database=" + + urllib.parse.quote(database, safe="") + + "&enums=true&type=any" +) +response = opener.open(url, timeout=10) +nodes = json.loads(response.read().decode()) +print(json.dumps({ + "count": len(nodes), + "nodes": [ + {"id": node.get("Id"), "address": node.get("Address"), "port": node.get("Port")} + for node in nodes + ], +}, separators=(",", ":"))) +PY +``` + +Container/log checks for dynamic-node additions: + +```bash +docker ps -a --filter name=ydb-dyn \ + --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' + +docker logs --tail 80 +``` + +Treat a node as healthy only after all of these are true: + +- the container is `Up`, not `Restarting` +- logs show `Successfully applied dynamic config from YAML` +- logs show `serve as dynamic node` +- authenticated `nodelist` includes the node ID and IC port +- tenant metadata checks still pass + +`whoami` reachability checks: + +```bash +curl -sS -o /tmp/local-whoami.out -w '%{http_code}\n' \ + 'http://127.0.0.1:8765/viewer/json/whoami' + +curl -k -sS -o /tmp/public-whoami.out -w '%{http_code}\n' \ + 'https:///viewer/json/whoami' +``` + +Interpretation: + +- `401` means the viewer endpoint is reachable and unauthenticated, which is the expected hardened result without a UI session +- `503` from the public domain during maintenance can be just a reverse proxy hitting an upstream while YDB is restarting +- `503` with a stable local `401` points to reverse-proxy/upstream routing rather than YDB auth itself + +`admin database ... status` interpretation: + +- `NOT_FOUND` or `Unknown tenant` means the tenant still needs to be created +- `PENDING_RESOURCES` can be the expected pre-dynamic state on a fresh `/local/` database +- `RUNNING` is the steady-state target after the dynamic node has registered successfully + +## Metadata Path + +After any storage move, tenant recovery, or replacement-tenant cutover, verify metadata explicitly: + +```bash +/ydb -e grpc://localhost:2137 -d /local/ \ + --user root \ + --password-file /tmp/root.password \ + scheme ls /local/ + +/ydb -e grpc://localhost:2137 -d /local/ \ + --user root \ + --password-file /tmp/root.password \ + scheme describe /local// + +/ydb -e grpc://localhost:2137 -d /local/ \ + --user root \ + --password-file /tmp/root.password \ + sql -s "SELECT COUNT(*) AS rows FROM ;" +``` + +Treat these as stronger than `admin database ... status` or BSC `FULL`. + +## GraphShard + +Check capability and tablet presence: + +```bash +curl -sSL 'http://127.0.0.1:8765/viewer/json/capabilities?database=%2Flocal%2Fexample' \ + | jq '.Settings.Database.GraphShardExists' + +curl -sSL 'http://127.0.0.1:8765/viewer/json/tabletinfo?database=%2Flocal%2Fexample&enums=true' \ + | grep -o GraphShard | sort | uniq -c +``` + +Check metrics backend: + +```bash +GRAPH_TABLET_ID=$(curl -fsSL 'http://127.0.0.1:8765/viewer/json/tabletinfo?database=%2Flocal%2Fexample&enums=true' \ + | jq -r '.. | objects | select(.Type? == "GraphShard") | .TabletId' \ + | head -n 1) + +curl -fsS "http://127.0.0.1:8765/tablets/app?TabletID=${GRAPH_TABLET_ID}&action=get_settings" +``` + +Only the `backend` field is stable enough for docs. `metrics_size` changes as metrics are collected. + +To switch the backend to `Local`, use only after confirming the tablet ID and intended tenant: + +```bash +curl -fsS "http://127.0.0.1:8765/tablets/app?TabletID=${GRAPH_TABLET_ID}&action=change_backend&backend=1" +docker restart +``` + +## Graph Data + +Use the dynamic-node path for graph data: + +```bash +GRAPH_NODE_ID=$(curl -fsSL 'http://127.0.0.1:8765/viewer/json/nodelist?database=%2Flocal%2Fexample&enums=true&type=any' \ + | jq -r '.[0].Id') +NOW=$(date +%s) +FROM=$((NOW - 600)) +curl -fsS "http://127.0.0.1:8765/node/${GRAPH_NODE_ID}/viewer/json/graph?database=%2Flocal%2Fexample&target=resources.memory.used_bytes&from=${FROM}&until=${NOW}&maxDataPoints=1000" +``` + +The root `/viewer/json/graph` endpoint can return `GraphShard is not enabled on the database` even when `GraphShardExists=true` for `/local/`. + +Field-proven post-auth checks on a stable `26.1.1.6` stack: + +- anonymous `GET /viewer/json/whoami` returns `401` +- authenticated `scheme ls /local/` succeeds with `root.password` +- authenticated `nodelist` returns the dynamic node and IC port +- authenticated `capabilities` reports `GraphShardExists=true` + +## Storage + +Check storage allocation: + +```bash +curl -sSL 'http://127.0.0.1:8765/viewer/json/tenantinfo?database=%2Flocal%2Fexample&path=%2Flocal%2Fexample&tablets=false&storage=true&memory=true' \ + | python3 -m json.tool \ + | grep -E 'Resources|StorageGroups|StorageAllocatedLimit|DatabaseStorage' -A 20 +``` + +Host-level PDisk-byte checks should use placeholders in reusable docs: + +```bash +sudo du -h -d 1 /path/to/ydb-pdisks /var/lib/docker/volumes/ydb-local-data/_data/pdisks 2>/dev/null || true + +sudo find /path/to/ydb-pdisks /var/lib/docker/volumes/ydb-local-data/_data/pdisks \ + -maxdepth 1 -type f -printf '%p %s bytes\n' 2>/dev/null | sort +``` + +Useful leftover checks after moving storage: + +```bash +docker volume ls --format '{{.Name}}' | grep -E 'ydb|local' || true + +docker ps -a --filter volume=ydb-local-data --format 'table {{.Names}}\t{{.Status}}' + +sudo find /path/to/storage /var/lib/docker/volumes -maxdepth 4 \ + \( -path '*ydb*pdisks*' -o -path '*ydb-dump*' -o -path '*ydb-data*' -o -path '*ydb-local-data*' \) \ + -print 2>/dev/null | sort +``` + +Document both current and historical pool counts carefully. Old notes can become stale after storage expansion. + +## Auth and Exposure + +Hardened checks: + +- tenant `/local/` remains `RUNNING` +- `scheme ls /local/` succeeds +- `scheme describe /local//` succeeds +- a small `SELECT` from a known table succeeds +- `GraphShardExists` is `true` when GraphShard is required +- one `GraphShard` tablet is visible when GraphShard is required +- GraphShard settings contain `"backend":"Local"` when local metrics storage is expected +- anonymous `viewer/json` returns HTTP `401` +- anonymous YDB CLI/API access fails with `CLIENT_UNAUTHENTICATED` +- direct host access to YDB gRPC is not externally reachable +- public monitoring access goes through HTTPS reverse proxy, not a direct public Docker bind +- public `/viewer/json/whoami` returns `401`, not `503`, once reverse proxy and local viewer are both healthy +- stale YDB Docker volumes are absent only after old storage is intentionally removed + +Do not include live IPs, domains, or secret-bearing commands in reusable docs unless the user explicitly asks for private host documentation. diff --git a/plugins/local-ydb-toolkit/skills/local-ydb/scripts/.gitkeep b/plugins/local-ydb-toolkit/skills/local-ydb/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/sync-local-ydb-toolkit.py b/scripts/sync-local-ydb-toolkit.py new file mode 100644 index 0000000..46a83c2 --- /dev/null +++ b/scripts/sync-local-ydb-toolkit.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import re +import shutil +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +PLUGIN_ROOT = REPOSITORY_ROOT / "plugins" / "local-ydb-toolkit" +SOURCE_METADATA = PLUGIN_ROOT / "SOURCE.json" +EXPECTED_REPOSITORY = "https://github.com/astandrik/local-ydb-toolkit.git" +SOURCE_MAPPINGS = ( + (PurePosixPath(".claude-plugin/plugin.json"), PurePosixPath(".factory-plugin/plugin.json")), + (PurePosixPath("LICENSE"), PurePosixPath("LICENSE")), + (PurePosixPath("mcp.json"), PurePosixPath("mcp.json")), + (PurePosixPath("skills/local-ydb"), PurePosixPath("skills/local-ydb")), +) +MANAGED_DIRECTORY_TARGETS = (PurePosixPath("skills/local-ydb"),) +MAX_FILES = 64 +MAX_FILE_BYTES = 1_000_000 +MAX_TOTAL_BYTES = 5_000_000 + + +class SyncError(RuntimeError): + pass + + +@dataclass(frozen=True) +class SourceFile: + content: bytes + executable: bool + + +def run_git(source: Path, *arguments: str) -> bytes: + result = subprocess.run( + ["git", "-C", str(source), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + message = result.stderr.decode("utf-8", errors="replace").strip() + raise SyncError(f"git {' '.join(arguments)} failed: {message}") + return result.stdout + + +def load_source_commit() -> str: + try: + metadata = json.loads(SOURCE_METADATA.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SyncError(f"Cannot read {SOURCE_METADATA}: {error}") from error + + expected_keys = {"schemaVersion", "repository", "commit"} + if set(metadata) != expected_keys: + raise SyncError(f"{SOURCE_METADATA} must contain exactly {sorted(expected_keys)}") + if metadata["schemaVersion"] != 1: + raise SyncError("Unsupported source metadata schema") + if metadata["repository"] != EXPECTED_REPOSITORY: + raise SyncError(f"Source repository must be {EXPECTED_REPOSITORY}") + + commit = metadata["commit"] + if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40}", commit) is None: + raise SyncError("Source commit must be a full lowercase SHA-1") + return commit + + +def parse_tree_entry(raw_entry: bytes) -> tuple[str, str, str]: + try: + metadata, raw_path = raw_entry.split(b"\t", 1) + mode, object_type, object_id = metadata.decode("ascii").split(" ") + source_path = raw_path.decode("utf-8") + except (ValueError, UnicodeDecodeError) as error: + raise SyncError("Unexpected git ls-tree output") from error + + if object_type != "blob" or mode not in {"100644", "100755"}: + raise SyncError(f"Unsupported source entry {source_path}: mode={mode}, type={object_type}") + return mode, object_id, source_path + + +def safe_relative_path(raw_path: str) -> PurePosixPath: + path = PurePosixPath(raw_path) + if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): + raise SyncError(f"Unsafe relative path: {raw_path}") + return path + + +def read_source_files(source: Path, commit: str) -> dict[PurePosixPath, SourceFile]: + run_git(source, "cat-file", "-e", f"{commit}^{{commit}}") + generated: dict[PurePosixPath, SourceFile] = {} + total_bytes = 0 + + for source_root, target_root in SOURCE_MAPPINGS: + tree = run_git(source, "ls-tree", "-r", "-z", commit, "--", source_root.as_posix()) + entries = [entry for entry in tree.split(b"\0") if entry] + if not entries: + raise SyncError(f"Missing source path at {commit}: {source_root}") + + source_root_is_file = len(entries) == 1 and parse_tree_entry(entries[0])[2] == source_root.as_posix() + for entry in entries: + mode, object_id, raw_source_path = parse_tree_entry(entry) + source_path = safe_relative_path(raw_source_path) + if source_root_is_file: + target_path = target_root + else: + try: + suffix = source_path.relative_to(source_root) + except ValueError as error: + raise SyncError(f"Source path escaped mapping root: {source_path}") from error + target_path = target_root / suffix + + target_path = safe_relative_path(target_path.as_posix()) + if target_path in generated: + raise SyncError(f"Duplicate generated target: {target_path}") + + content = run_git(source, "cat-file", "blob", object_id) + if len(content) > MAX_FILE_BYTES: + raise SyncError(f"Source file exceeds {MAX_FILE_BYTES} bytes: {source_path}") + total_bytes += len(content) + if total_bytes > MAX_TOTAL_BYTES: + raise SyncError(f"Generated content exceeds {MAX_TOTAL_BYTES} bytes") + generated[target_path] = SourceFile(content=content, executable=mode == "100755") + + if len(generated) > MAX_FILES: + raise SyncError(f"Generated file count exceeds {MAX_FILES}") + return generated + + +def destination_for(target: PurePosixPath) -> Path: + plugins_root = REPOSITORY_ROOT / "plugins" + if plugins_root.is_symlink() or PLUGIN_ROOT.is_symlink(): + raise SyncError("Plugin destination root must not be a symlink") + + destination = PLUGIN_ROOT.joinpath(*target.parts) + parent = PLUGIN_ROOT + for part in target.parts[:-1]: + parent /= part + if parent.is_symlink(): + raise SyncError(f"Plugin destination parent must not be a symlink: {parent}") + return destination + + +def existing_managed_files() -> set[PurePosixPath]: + existing: set[PurePosixPath] = set() + for target in MANAGED_DIRECTORY_TARGETS: + root = destination_for(target) + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file() or path.is_symlink(): + existing.add(PurePosixPath(path.relative_to(PLUGIN_ROOT).as_posix())) + return existing + + +def check_generated_files(generated: dict[PurePosixPath, SourceFile]) -> None: + errors: list[str] = [] + for target, source_file in sorted(generated.items(), key=lambda item: item[0].as_posix()): + destination = destination_for(target) + if destination.is_symlink() or not destination.is_file(): + errors.append(f"missing regular file: {target}") + continue + if destination.read_bytes() != source_file.content: + errors.append(f"content differs: {target}") + executable = bool(destination.stat().st_mode & stat.S_IXUSR) + if executable != source_file.executable: + errors.append(f"executable bit differs: {target}") + + expected_managed = { + target + for target in generated + if any(target.is_relative_to(root) for root in MANAGED_DIRECTORY_TARGETS) + } + for extra in sorted(existing_managed_files() - expected_managed, key=PurePosixPath.as_posix): + errors.append(f"unexpected generated file: {extra}") + + if errors: + raise SyncError("Vendored Local YDB Toolkit is stale:\n - " + "\n - ".join(errors)) + + +def remove_managed_target(target: PurePosixPath) -> None: + destination = destination_for(target) + if destination.is_symlink() or destination.is_file(): + destination.unlink() + elif destination.is_dir(): + shutil.rmtree(destination) + + +def write_generated_file(destination: Path, source_file: SourceFile) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + mode = 0o755 if source_file.executable else 0o644 + + try: + descriptor = os.open(destination, flags, mode) + except FileExistsError as error: + target = destination.relative_to(PLUGIN_ROOT) + raise SyncError(f"Destination appeared during synchronization: {target}") from error + + with os.fdopen(descriptor, "wb") as output: + output.write(source_file.content) + if hasattr(os, "fchmod"): + os.fchmod(output.fileno(), mode) + + +def synchronize(generated: dict[PurePosixPath, SourceFile]) -> None: + for _, target in SOURCE_MAPPINGS: + remove_managed_target(target) + + for target, source_file in sorted(generated.items(), key=lambda item: item[0].as_posix()): + destination = destination_for(target) + destination.parent.mkdir(parents=True, exist_ok=True) + write_generated_file(destination, source_file) + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Synchronize the vendored Local YDB Toolkit plugin") + parser.add_argument("--source", required=True, type=Path, help="Path to a local-ydb-toolkit Git repository") + parser.add_argument("--check", action="store_true", help="Fail if vendored files differ without writing") + return parser.parse_args() + + +def main() -> int: + arguments = parse_arguments() + try: + commit = load_source_commit() + generated = read_source_files(arguments.source.resolve(), commit) + if arguments.check: + check_generated_files(generated) + print(f"Verified {len(generated)} vendored files from {commit}") + else: + synchronize(generated) + check_generated_files(generated) + print(f"Synchronized {len(generated)} vendored files from {commit}") + except SyncError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_sync_local_ydb_toolkit.py b/scripts/test_sync_local_ydb_toolkit.py new file mode 100644 index 0000000..4b0b0d1 --- /dev/null +++ b/scripts/test_sync_local_ydb_toolkit.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import os +import runpy +import stat +import tempfile +import unittest +from pathlib import Path, PurePosixPath + + +SCRIPT_PATH = Path(__file__).with_name("sync-local-ydb-toolkit.py") + + +def load_sync_module() -> dict[str, object]: + return runpy.run_path(str(SCRIPT_PATH)) + + +class SyncLocalYdbToolkitTest(unittest.TestCase): + def test_existing_managed_files_does_not_follow_directory_symlinks(self) -> None: + module = load_sync_module() + with tempfile.TemporaryDirectory(prefix="factory-rglob-check-") as temp_dir: + repository_root = Path(temp_dir) / "repository" + plugin_root = repository_root / "plugins" / "local-ydb-toolkit" + managed_root = plugin_root / "skills" / "local-ydb" + outside = Path(temp_dir) / "outside" + managed_root.mkdir(parents=True) + outside.mkdir() + (outside / "marker.txt").write_text("outside", encoding="utf-8") + (managed_root / "external-dir").symlink_to(outside, target_is_directory=True) + + globals_dict = module["existing_managed_files"].__globals__ + globals_dict["REPOSITORY_ROOT"] = repository_root + globals_dict["PLUGIN_ROOT"] = plugin_root + globals_dict["MANAGED_DIRECTORY_TARGETS"] = (PurePosixPath("skills/local-ydb"),) + + existing = module["existing_managed_files"]() + + self.assertIn(PurePosixPath("skills/local-ydb/external-dir"), existing) + self.assertNotIn(PurePosixPath("skills/local-ydb/external-dir/marker.txt"), existing) + + @unittest.skipUnless(hasattr(os, "fchmod"), "descriptor chmod is unavailable") + def test_synchronize_writes_content_and_executable_mode(self) -> None: + module = load_sync_module() + with tempfile.TemporaryDirectory(prefix="factory-exclusive-write-") as temp_dir: + repository_root = Path(temp_dir) / "repository" + plugin_root = repository_root / "plugins" / "local-ydb-toolkit" + plugin_root.mkdir(parents=True) + target = PurePosixPath("tool.sh") + + globals_dict = module["synchronize"].__globals__ + globals_dict["REPOSITORY_ROOT"] = repository_root + globals_dict["PLUGIN_ROOT"] = plugin_root + globals_dict["SOURCE_MAPPINGS"] = ((PurePosixPath("unused"), target),) + generated = {target: module["SourceFile"](content=b"#!/bin/sh\n", executable=True)} + + previous_umask = os.umask(0o777) + try: + module["synchronize"](generated) + finally: + os.umask(previous_umask) + + destination = plugin_root / target + self.assertEqual(destination.read_bytes(), b"#!/bin/sh\n") + self.assertTrue(destination.stat().st_mode & stat.S_IXUSR) + + def test_synchronize_rejects_reintroduced_leaf_symlink(self) -> None: + module = load_sync_module() + with tempfile.TemporaryDirectory(prefix="factory-leaf-race-") as temp_dir: + repository_root = Path(temp_dir) / "repository" + plugin_root = repository_root / "plugins" / "local-ydb-toolkit" + plugin_root.mkdir(parents=True) + outside = Path(temp_dir) / "outside.txt" + outside.write_bytes(b"safe") + target = PurePosixPath("mcp.json") + + globals_dict = module["synchronize"].__globals__ + globals_dict["REPOSITORY_ROOT"] = repository_root + globals_dict["PLUGIN_ROOT"] = plugin_root + globals_dict["SOURCE_MAPPINGS"] = ((PurePosixPath("unused"), target),) + original_destination_for = globals_dict["destination_for"] + destination_calls = 0 + + def racing_destination_for(candidate: PurePosixPath) -> Path: + nonlocal destination_calls + destination = original_destination_for(candidate) + destination_calls += 1 + if destination_calls == 2: + destination.symlink_to(outside) + return destination + + globals_dict["destination_for"] = racing_destination_for + generated = {target: module["SourceFile"](content=b"overwritten", executable=False)} + + with self.assertRaisesRegex(module["SyncError"], "Destination appeared during synchronization"): + module["synchronize"](generated) + + self.assertEqual(outside.read_bytes(), b"safe") + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/local-ydb b/skills/local-ydb new file mode 120000 index 0000000..adcee08 --- /dev/null +++ b/skills/local-ydb @@ -0,0 +1 @@ +../plugins/local-ydb-toolkit/skills/local-ydb \ No newline at end of file