From 2f93a57f149aba7c156ad736f1646501ce619540 Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Sun, 20 Sep 2026 21:40:02 +0300 Subject: [PATCH 1/5] feat: publish a rendered tree per definition schema A definition here reaches every install within a day whatever version of lerd is running, and the path it arrives on is one the binary computes for itself. A binary already in the field cannot be taught a new one, so the unprefixed path has to keep meaning what those binaries expect, and anything a newer lerd understands has to reach it some other way. Definitions are now authored once under sources/ in the newest schema. A schema file states only what changed since the one before it and how to render a document back down, and CI publishes a tree for each: the unprefixed path is schema 1, read by everything up to 1.35.0, and schema/2/ is read by 1.36.0 and later. Most of the delta is keys an older binary ignores anyway, so rendering them away only makes the published tree mean what it says. One is not cosmetic. Schema 2 serves a dashboard through a proxy that strips the upstream's framing headers, and schema 1 opens the dashboard URL directly in an iframe, so a UI that refuses framing renders a blocked panel. A dashboard that only works behind that proxy is therefore dropped when rendering to schema 1, and the service installs and serves its port with no dashboard card rather than a dead one. The guard learned to tell a rendered removal from a real one, and a second job fails a pull request whose published trees no longer match the sources. --- .github/scripts/render_schema.py | 154 ++++++++ .github/scripts/schema_guard.py | 23 ++ .github/workflows/schema-guard.yml | 30 +- schema/2.yaml | 41 ++ schema/2/services/adminer.svg | 3 + schema/2/services/adminer.yaml | 147 +++++++ schema/2/services/beanstalkd.yaml | 14 + schema/2/services/clickhouse.svg | 1 + schema/2/services/clickhouse.yaml | 86 +++++ schema/2/services/elasticsearch.svg | 1 + schema/2/services/elasticsearch.yaml | 27 ++ schema/2/services/elasticvue.yaml | 14 + schema/2/services/gotenberg.yaml | 12 + schema/2/services/index.json | 363 ++++++++++++++++++ schema/2/services/kafka-ui.yaml | 19 + schema/2/services/kafka.svg | 1 + schema/2/services/kafka.yaml | 30 ++ schema/2/services/localstack.svg | 6 + schema/2/services/localstack.yaml | 19 + schema/2/services/mariadb.svg | 1 + schema/2/services/mariadb.yaml | 117 ++++++ schema/2/services/memcached.yaml | 13 + schema/2/services/mongo-express.yaml | 17 + schema/2/services/mongo.svg | 1 + schema/2/services/mongo.yaml | 75 ++++ schema/2/services/opensearch-dashboards.yaml | 15 + schema/2/services/opensearch.svg | 1 + schema/2/services/opensearch.yaml | 28 ++ schema/2/services/pgadmin.yaml | 54 +++ schema/2/services/phpmyadmin.svg | 1 + schema/2/services/phpmyadmin.yaml | 50 +++ schema/2/services/postgres-pgvector.svg | 1 + schema/2/services/postgres-pgvector.yaml | 82 ++++ schema/2/services/postgres-timescaledb.svg | 1 + schema/2/services/postgres-timescaledb.yaml | 77 ++++ schema/2/services/rabbitmq.svg | 1 + schema/2/services/rabbitmq.yaml | 29 ++ schema/2/services/redisinsight.yaml | 25 ++ schema/2/services/selenium.svg | 1 + schema/2/services/selenium.yaml | 19 + schema/2/services/soketi.yaml | 28 ++ schema/2/services/stripe-mock.svg | 1 + schema/2/services/stripe-mock.yaml | 13 + schema/2/services/typesense-dashboard.yaml | 14 + schema/2/services/typesense.yaml | 20 + schema/2/services/valkey.svg | 1 + schema/2/services/valkey.yaml | 21 + services/adminer.yaml | 379 +++++++------------ services/beanstalkd.yaml | 10 +- services/clickhouse.yaml | 151 +++----- services/elasticsearch.yaml | 22 +- services/elasticvue.yaml | 6 +- services/gotenberg.yaml | 6 +- services/index.json | 85 ++--- services/kafka-ui.yaml | 18 +- services/kafka.yaml | 36 +- services/localstack.yaml | 45 +-- services/mariadb.yaml | 185 ++++----- services/memcached.yaml | 6 +- services/mongo-express.yaml | 14 +- services/mongo.yaml | 100 ++--- services/opensearch-dashboards.yaml | 12 +- services/opensearch.yaml | 26 +- services/pgadmin.yaml | 73 ++-- services/phpmyadmin.yaml | 81 ++-- services/postgres-pgvector.yaml | 130 +++---- services/postgres-timescaledb.yaml | 121 +++--- services/rabbitmq.yaml | 25 +- services/redisinsight.yaml | 24 +- services/selenium.yaml | 15 +- services/soketi.yaml | 28 +- services/stripe-mock.yaml | 8 +- services/typesense-dashboard.yaml | 9 +- services/typesense.yaml | 16 +- services/valkey.yaml | 20 +- sources/services/adminer.svg | 3 + sources/services/adminer.yaml | 259 +++++++++++++ sources/services/beanstalkd.yaml | 16 + sources/services/clickhouse.svg | 1 + sources/services/clickhouse.yaml | 115 ++++++ sources/services/elasticsearch.svg | 1 + sources/services/elasticsearch.yaml | 33 ++ sources/services/elasticvue.yaml | 14 + sources/services/gotenberg.yaml | 12 + sources/services/index.json | 363 ++++++++++++++++++ sources/services/kafka-ui.yaml | 29 ++ sources/services/kafka.svg | 1 + sources/services/kafka.yaml | 36 ++ sources/services/localstack.svg | 6 + sources/services/localstack.yaml | 44 +++ sources/services/mariadb.svg | 1 + sources/services/mariadb.yaml | 108 ++++++ sources/services/memcached.yaml | 13 + sources/services/mongo-express.yaml | 21 + sources/services/mongo.svg | 1 + sources/services/mongo.yaml | 67 ++++ sources/services/opensearch-dashboards.yaml | 19 + sources/services/opensearch.svg | 1 + sources/services/opensearch.yaml | 36 ++ sources/services/pgadmin.yaml | 52 +++ sources/services/phpmyadmin.svg | 1 + sources/services/phpmyadmin.yaml | 67 ++++ sources/services/postgres-pgvector.svg | 1 + sources/services/postgres-pgvector.yaml | 86 +++++ sources/services/postgres-timescaledb.svg | 1 + sources/services/postgres-timescaledb.yaml | 84 ++++ sources/services/rabbitmq.svg | 1 + sources/services/rabbitmq.yaml | 30 ++ sources/services/redisinsight.yaml | 35 ++ sources/services/selenium.svg | 1 + sources/services/selenium.yaml | 18 + sources/services/soketi.yaml | 28 ++ sources/services/stripe-mock.svg | 1 + sources/services/stripe-mock.yaml | 13 + sources/services/typesense-dashboard.yaml | 17 + sources/services/typesense.yaml | 24 ++ sources/services/valkey.svg | 1 + sources/services/valkey.yaml | 23 ++ 118 files changed, 4081 insertions(+), 952 deletions(-) create mode 100644 .github/scripts/render_schema.py create mode 100644 schema/2.yaml create mode 100644 schema/2/services/adminer.svg create mode 100644 schema/2/services/adminer.yaml create mode 100644 schema/2/services/beanstalkd.yaml create mode 100644 schema/2/services/clickhouse.svg create mode 100644 schema/2/services/clickhouse.yaml create mode 100644 schema/2/services/elasticsearch.svg create mode 100644 schema/2/services/elasticsearch.yaml create mode 100644 schema/2/services/elasticvue.yaml create mode 100644 schema/2/services/gotenberg.yaml create mode 100644 schema/2/services/index.json create mode 100644 schema/2/services/kafka-ui.yaml create mode 100644 schema/2/services/kafka.svg create mode 100644 schema/2/services/kafka.yaml create mode 100644 schema/2/services/localstack.svg create mode 100644 schema/2/services/localstack.yaml create mode 100644 schema/2/services/mariadb.svg create mode 100644 schema/2/services/mariadb.yaml create mode 100644 schema/2/services/memcached.yaml create mode 100644 schema/2/services/mongo-express.yaml create mode 100644 schema/2/services/mongo.svg create mode 100644 schema/2/services/mongo.yaml create mode 100644 schema/2/services/opensearch-dashboards.yaml create mode 100644 schema/2/services/opensearch.svg create mode 100644 schema/2/services/opensearch.yaml create mode 100644 schema/2/services/pgadmin.yaml create mode 100644 schema/2/services/phpmyadmin.svg create mode 100644 schema/2/services/phpmyadmin.yaml create mode 100644 schema/2/services/postgres-pgvector.svg create mode 100644 schema/2/services/postgres-pgvector.yaml create mode 100644 schema/2/services/postgres-timescaledb.svg create mode 100644 schema/2/services/postgres-timescaledb.yaml create mode 100644 schema/2/services/rabbitmq.svg create mode 100644 schema/2/services/rabbitmq.yaml create mode 100644 schema/2/services/redisinsight.yaml create mode 100644 schema/2/services/selenium.svg create mode 100644 schema/2/services/selenium.yaml create mode 100644 schema/2/services/soketi.yaml create mode 100644 schema/2/services/stripe-mock.svg create mode 100644 schema/2/services/stripe-mock.yaml create mode 100644 schema/2/services/typesense-dashboard.yaml create mode 100644 schema/2/services/typesense.yaml create mode 100644 schema/2/services/valkey.svg create mode 100644 schema/2/services/valkey.yaml create mode 100644 sources/services/adminer.svg create mode 100644 sources/services/adminer.yaml create mode 100644 sources/services/beanstalkd.yaml create mode 100644 sources/services/clickhouse.svg create mode 100644 sources/services/clickhouse.yaml create mode 100644 sources/services/elasticsearch.svg create mode 100644 sources/services/elasticsearch.yaml create mode 100644 sources/services/elasticvue.yaml create mode 100644 sources/services/gotenberg.yaml create mode 100644 sources/services/index.json create mode 100644 sources/services/kafka-ui.yaml create mode 100644 sources/services/kafka.svg create mode 100644 sources/services/kafka.yaml create mode 100644 sources/services/localstack.svg create mode 100644 sources/services/localstack.yaml create mode 100644 sources/services/mariadb.svg create mode 100644 sources/services/mariadb.yaml create mode 100644 sources/services/memcached.yaml create mode 100644 sources/services/mongo-express.yaml create mode 100644 sources/services/mongo.svg create mode 100644 sources/services/mongo.yaml create mode 100644 sources/services/opensearch-dashboards.yaml create mode 100644 sources/services/opensearch.svg create mode 100644 sources/services/opensearch.yaml create mode 100644 sources/services/pgadmin.yaml create mode 100644 sources/services/phpmyadmin.svg create mode 100644 sources/services/phpmyadmin.yaml create mode 100644 sources/services/postgres-pgvector.svg create mode 100644 sources/services/postgres-pgvector.yaml create mode 100644 sources/services/postgres-timescaledb.svg create mode 100644 sources/services/postgres-timescaledb.yaml create mode 100644 sources/services/rabbitmq.svg create mode 100644 sources/services/rabbitmq.yaml create mode 100644 sources/services/redisinsight.yaml create mode 100644 sources/services/selenium.svg create mode 100644 sources/services/selenium.yaml create mode 100644 sources/services/soketi.yaml create mode 100644 sources/services/stripe-mock.svg create mode 100644 sources/services/stripe-mock.yaml create mode 100644 sources/services/typesense-dashboard.yaml create mode 100644 sources/services/typesense.yaml create mode 100644 sources/services/valkey.svg create mode 100644 sources/services/valkey.yaml diff --git a/.github/scripts/render_schema.py b/.github/scripts/render_schema.py new file mode 100644 index 0000000..5baa0e9 --- /dev/null +++ b/.github/scripts/render_schema.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Render the authored definitions into one published tree per schema. + +Definitions are authored under sources/ in the newest schema. Every binary in +the field computes its own store URL and cannot be taught a new one, so the +legacy unprefixed path has to carry the oldest schema still supported, and each +later schema gets a prefixed tree that only a binary knowing about it asks for. + +A schema file states the delta from the one before it and how to render back +down: `drop` removes a key, `join` collapses a list into a delimited string, +`rename` moves one. A change may carry `when: `, applying only to a +document where that path is truthy, which is how a key valid in both schemas can +still be wrong to publish to the older one. + +Usage: render_schema.py [--check] +""" + +import copy +import json +import pathlib +import shutil +import sys +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SOURCES = ROOT / "sources" +SCHEMA_DIR = ROOT / "schema" + +# Fields an index entry carries, in the order the published index uses them. +INDEX_FIELDS = ["name", "description", "family", "dashboard", "image", "category", + "icon", "color", "admin_for", "admin_rank", "depends_on", + "versions", "default_version", "env_role"] + + +def load_yaml(path): + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def schemas(): + """Every schema file, oldest first. Schema 1 is implicit and has no file.""" + out = [] + for path in sorted(SCHEMA_DIR.glob("*.yaml"), key=lambda p: int(p.stem)): + spec = load_yaml(path) + out.append((int(spec["schema"]), spec)) + return out + + +def resolve(doc, path): + """Read a dotted path out of a document, or None.""" + node = doc + for part in path.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + +def drop(doc, path): + parts = path.split(".") + node = doc + for part in parts[:-1]: + if not isinstance(node, dict) or part not in node: + return + node = node[part] + if isinstance(node, dict): + node.pop(parts[-1], None) + + +def apply_change(doc, change, guard): + """Render one change backwards, from the newer schema to the older one. + + A `when` guard reads the document as it entered this schema's step, not the + half-rendered one: a rule commonly depends on a key an earlier rule in the + same step has already dropped. + """ + if "when" in change and not resolve(guard, change["when"]): + return + path, how = change["path"], change["downgrade"] + if how == "drop": + drop(doc, path) + elif isinstance(how, dict) and "join" in how: + value = resolve(doc, path) + if isinstance(value, list): + parts = path.split(".") + node = doc + for part in parts[:-1]: + node = node[part] + node[parts[-1]] = how["join"].join(str(v) for v in value) + elif isinstance(how, dict) and "rename" in how: + value = resolve(doc, path) + if value is not None: + drop(doc, path) + doc[how["rename"]] = value + else: + raise SystemExit(f"unknown downgrade {how!r} for {path}") + + +def render_to(doc, target, specs): + """Render a newest-schema document down to the target schema.""" + out = copy.deepcopy(doc) + for version, spec in sorted(specs, reverse=True): + if version <= target: + continue + guard = copy.deepcopy(out) + for change in spec.get("changes", []): + apply_change(out, change, guard) + return out + + +def index_for(docs): + entries = [] + for doc in sorted(docs, key=lambda d: d.get("name", "")): + entry = {f: doc[f] for f in INDEX_FIELDS if f in doc and doc[f] not in (None, "", [], {})} + entries.append(entry) + return {"services": entries} + + +def write_tree(out_dir, docs, assets): + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + for name, doc in sorted(docs.items()): + with open(out_dir / f"{name}.yaml", "w", encoding="utf-8") as handle: + yaml.safe_dump(doc, handle, sort_keys=False, default_flow_style=False, allow_unicode=True) + for asset in assets: + shutil.copy2(asset, out_dir / asset.name) + with open(out_dir / "index.json", "w", encoding="utf-8") as handle: + json.dump(index_for(list(docs.values())), handle, indent=2) + handle.write("\n") + + +def main(): + specs = schemas() + newest = max(v for v, _ in specs) if specs else 1 + src = SOURCES / "services" + sources = {p.stem: load_yaml(p) for p in sorted(src.glob("*.yaml")) if p.name != "index.json"} + assets = sorted(src.glob("*.svg")) + + targets = {1: ROOT / "services"} + for version, _ in specs: + if version > 1: + targets[version] = ROOT / "schema" / str(version) / "services" + + for version, out_dir in sorted(targets.items()): + rendered = {n: render_to(d, version, specs) for n, d in sources.items()} + write_tree(out_dir, rendered, assets) + print(f"schema {version}: {len(rendered)} definition(s) -> {out_dir.relative_to(ROOT)}") + print(f"authored schema is {newest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/schema_guard.py b/.github/scripts/schema_guard.py index 4051259..14ac466 100755 --- a/.github/scripts/schema_guard.py +++ b/.github/scripts/schema_guard.py @@ -75,6 +75,26 @@ def profile(root, pattern): return out +def schema_dropped_paths(root): + """Paths a schema delta renders away, which are removals by design. + + The published legacy tree is the oldest schema, so every key a later schema + added is absent from it on purpose. Without this the guard would read each + of those as a key that disappeared. + """ + dropped = set() + for path in sorted(pathlib.Path(root, "schema").glob("*.yaml")): + try: + with open(path, "r", encoding="utf-8") as handle: + spec = yaml.safe_load(handle) or {} + except (OSError, yaml.YAMLError): + continue + for change in spec.get("changes", []): + if change.get("downgrade") == "drop" and "path" in change: + dropped.add(change["path"]) + return dropped + + def closed_sets(profiles): """Union every file's observed values per path, keeping the small ones.""" merged = {} @@ -96,6 +116,7 @@ def main(): published = profile(published_dir, pattern) candidate = profile(candidate_dir, pattern) enums = closed_sets(published) + by_design = schema_dropped_paths(candidate_dir) failures, warnings = [], [] @@ -110,6 +131,8 @@ def main(): for path, kinds in sorted(old_types.items()): if path not in new_types: + if path.split("[]")[0] in by_design: + continue failures.append(f"{rel}: key `{path}` was removed, an older lerd still reads it") continue if kinds != new_types[path] and not kinds & new_types[path]: diff --git a/.github/workflows/schema-guard.yml b/.github/workflows/schema-guard.yml index c8bcd6d..5efc9c3 100644 --- a/.github/workflows/schema-guard.yml +++ b/.github/workflows/schema-guard.yml @@ -3,22 +3,40 @@ name: Schema guard on: pull_request: -# A store definition reaches every install within a day, whatever version of -# lerd it runs, so a pull request may only grow the schema. This refuses a key -# that disappeared or changed type, and reports a value no published definition -# has used before. +# Definitions are authored under sources/ in the newest schema and published as +# one rendered tree per schema. Two things have to hold on every pull request: +# the published trees are what the sources render to, and the oldest of them +# stays readable by the binaries that fetch it. jobs: + render: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pyyaml + + - name: Render every schema + run: python3 .github/scripts/render_schema.py + + - name: The published trees must match the sources + run: | + if ! git diff --quiet; then + echo "The published trees are stale. Run .github/scripts/render_schema.py and commit the result." + git diff --stat + exit 1 + fi + schema-guard: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pyyaml - name: Extract the published tree diff --git a/schema/2.yaml b/schema/2.yaml new file mode 100644 index 0000000..a0eea97 --- /dev/null +++ b/schema/2.yaml @@ -0,0 +1,41 @@ +# Schema 2, as understood by lerd 1.36.0 and later. +# +# A schema states only what changed since the one before it, and how to render a +# document back down to that one. Definitions are authored in the newest schema +# under sources/, and CI publishes a rendered tree per schema: the legacy +# unprefixed path is schema 1, which is what every binary up to 1.35.0 computes +# for itself and cannot be taught otherwise. +schema: 2 +since_lerd: "1.36.0" + +changes: + # Keys schema 1 has no field for. An old binary ignores them, so dropping is + # about publishing a tree that means what it says rather than about safety. + - path: admin_rank + downgrade: drop + - path: dashboard_follows_color_scheme + downgrade: drop + - path: dashboard_login + downgrade: drop + - path: dashboard_proxy_at_path + downgrade: drop + - path: dashboard_proxy_keep_host + downgrade: drop + - path: dashboard_proxy_rebase + downgrade: drop + - path: dashboard_proxy_reroute + downgrade: drop + - path: dashboard_proxy_strip + downgrade: drop + - path: dashboard_scheme_key + downgrade: drop + + # This one is not cosmetic. Schema 2 proxies a dashboard at /_svc// and + # strips the upstream's X-Frame-Options and frame-ancestors on the way through. + # Schema 1 has no such proxy and opens the dashboard URL directly in an iframe, + # so a UI that refuses framing, which is every upstream needing the strip flag, + # renders a blocked panel. Better to publish no dashboard to schema 1 than a + # card that cannot work: the service still installs, runs and serves its port. + - path: dashboard + downgrade: drop + when: dashboard_proxy_strip diff --git a/schema/2/services/adminer.svg b/schema/2/services/adminer.svg new file mode 100644 index 0000000..0cc1a80 --- /dev/null +++ b/schema/2/services/adminer.svg @@ -0,0 +1,3 @@ + + + diff --git a/schema/2/services/adminer.yaml b/schema/2/services/adminer.yaml new file mode 100644 index 0000000..c7ef790 --- /dev/null +++ b/schema/2/services/adminer.yaml @@ -0,0 +1,147 @@ +name: adminer +image: docker.io/library/adminer:6 +update_strategy: rolling +description: Adminer web UI for every database lerd runs +ports: +- 8081:8080 +dynamic_env: + ADMINER_MYSQL_HOSTS: discover_family:mysql,mariadb + ADMINER_PGSQL_HOSTS: discover_family:postgres +files: +- target: /var/www/html/index.php + content: "servers[\\Adminer\\SERVER];\n\ + \t\t\t\treturn array($s['server'], $s['username'], $s['password']);\n\t\t\t}\n\ + \t\t\tfunction login($login, $password) { return true; }\n\t\t}\n\t\treturn new\ + \ \\Adminer\\Plugins(array(new LerdServers(\\lerd_servers())));\n\t}\n}\nnamespace\ + \ {\n\tfunction lerd_servers() {\n\t\tstatic $out = null;\n\t\tif ($out !== null)\ + \ { return $out; }\n\t\t$families = array(\n\t\t\t'ADMINER_MYSQL_HOSTS' =>\ + \ array('server', 'root', 'lerd'),\n\t\t\t'ADMINER_PGSQL_HOSTS' =>\ + \ array('pgsql', 'postgres', 'lerd'),\n\t\t);\n\t\t$out = array();\n\t\t\ + foreach ($families as $env => $spec) {\n\t\t\tforeach (array_filter(explode(',',\ + \ (string) getenv($env))) as $host) {\n\t\t\t\t$out[$host] = array('server' =>\ + \ $host, 'driver' => $spec[0], 'username' => $spec[1], 'password' => $spec[2]);\n\ + \t\t\t}\n\t\t}\n\t\tif (!$out) {\n\t\t\t$out['lerd-mysql'] = array('server' =>\ + \ 'lerd-mysql', 'driver' => 'server', 'username' => 'root', 'password' => 'lerd');\n\ + \t\t}\n\t\treturn $out;\n\t}\n\n\tfunction lerd_query_key(array $s) {\n\t\treturn\ + \ $s['driver'] === 'server' ? 'server' : $s['driver'];\n\t}\n\n\t// Which server\ + \ actually holds this database. The dashboard deep-links a\n\t// database by name\ + \ alone, so without this a Postgres database opens against\n\t// MySQL and Adminer\ + \ answers \"Invalid database\".\n\tfunction lerd_server_with_db($db) {\n\t\tforeach\ + \ (lerd_servers() as $host => $s) {\n\t\t\ttry {\n\t\t\t\tif ($s['driver'] ===\ + \ 'server' && extension_loaded('mysqli')) {\n\t\t\t\t\t$c = @new mysqli($host,\ + \ $s['username'], $s['password']);\n\t\t\t\t\tif ($c->connect_errno) { continue;\ + \ }\n\t\t\t\t\t$q = $c->query(\"SHOW DATABASES LIKE '\" . $c->real_escape_string($db)\ + \ . \"'\");\n\t\t\t\t\t$hit = $q && $q->num_rows > 0;\n\t\t\t\t\t$c->close();\n\ + \t\t\t\t\tif ($hit) { return $host; }\n\t\t\t\t} elseif ($s['driver'] === 'pgsql'\ + \ && extension_loaded('pdo_pgsql')) {\n\t\t\t\t\t$p = new PDO(\"pgsql:host=$host;dbname=postgres\"\ + , $s['username'], $s['password'], array(PDO::ATTR_TIMEOUT => 2));\n\t\t\t\t\t\ + $st = $p->prepare('SELECT 1 FROM pg_database WHERE datname = ?');\n\t\t\t\t\t\ + $st->execute(array($db));\n\t\t\t\t\tif ($st->fetchColumn()) { return $host; }\n\ + \t\t\t\t}\n\t\t\t} catch (\\Throwable $e) {\n\t\t\t\t// A server that is down\ + \ must not stop the search.\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\t// The\ + \ dashboard proxy forwards its own /_svc// prefix, and Adminer's own\n\t\ + // assets are query strings rather than paths, so they survive it. A design\n\t\ + // stylesheet is the exception: Adminer links it as a plain file name, which\n\ + \t// arrives here under the prefix, misses on disk and falls through to this\n\ + \t// script. Serve it rather than treat it as a page to redirect.\n\t$asset =\ + \ basename(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '');\n\t\ + if (preg_match('~^[\\\\w.-]+\\\\.(css|js|png|gif|ico|svg|woff2?)$~', $asset) &&\ + \ is_file(__DIR__ . '/' . $asset)) {\n\t\t$types = array('css' => 'text/css',\ + \ 'js' => 'application/javascript', 'png' => 'image/png',\n\t\t\t'gif' => 'image/gif',\ + \ 'ico' => 'image/x-icon', 'svg' => 'image/svg+xml',\n\t\t\t'woff' => 'font/woff',\ + \ 'woff2' => 'font/woff2');\n\t\t$ext = strtolower(pathinfo($asset, PATHINFO_EXTENSION));\n\ + \t\theader('Content-Type: ' . ($types[$ext] ?? 'application/octet-stream'));\n\ + \t\treadfile(__DIR__ . '/' . $asset);\n\t\texit;\n\t}\n\n\t$servers = lerd_servers();\n\ + \t// Seeded and closed before adminer.php is included, with the cache limiter\n\ + \t// off, so its own session_start sends no headers after ours have gone out.\n\ + \tini_set('session.cache_limiter', '');\n\tob_start();\n\tsession_name('adminer_sid');\n\ + \tsession_start();\n\tforeach ($servers as $host => $s) {\n\t\tif (!isset($_SESSION['pwds'][$s['driver']][$host][$s['username']]))\ + \ {\n\t\t\t$_SESSION['pwds'][$s['driver']][$host][$s['username']] = $s['password'];\n\ + \t\t\t$_SESSION['db'][$s['driver']][$host][$s['username']] = array();\n\t\t}\n\ + \t}\n\tsession_write_close();\n\tob_end_clean();\n\n\t// Which server this request\ + \ is for. A request that already names one is left\n\t// alone; anything else\ + \ is answered with a redirect to the canonical URL rather\n\t// than by rewriting\ + \ the request in place, because the versions disagree about\n\t// whether they\ + \ build their own links from $_GET or from the raw query string,\n\t// and a rewrite\ + \ that one of them ignores turns into a redirect loop.\n\t$named = false;\n\t\ + foreach ($servers as $host => $s) {\n\t\tif (isset($_GET[lerd_query_key($s)]))\ + \ { $named = true; break; }\n\t}\n\t// An asset request carries file=; it needs\ + \ no server and redirecting it only\n\t// costs a round trip before Adminer serves\ + \ the same bytes.\n\tif (!$named && !isset($_GET['file'])) {\n\t\t$pick = null;\n\ + \t\t// lerd_server is how the dashboard says which engine it opened this for,\n\ + \t\t// without having to know the driver or the credentials that go with it.\n\ + \t\tif (isset($_GET['lerd_server']) && isset($servers[$_GET['lerd_server']]))\ + \ {\n\t\t\t$pick = $_GET['lerd_server'];\n\t\t} elseif (isset($_GET['db']) &&\ + \ $_GET['db'] !== '') {\n\t\t\t$pick = lerd_server_with_db($_GET['db']);\n\t\t\ + }\n\t\tif ($pick === null) { $pick = key($servers); }\n\t\t$s = $servers[$pick];\n\ + \t\t$q = $_GET;\n\t\tunset($q['lerd_server']);\n\t\t$q[lerd_query_key($s)] = $pick;\n\ + \t\t$q['username'] = $s['username'];\n\t\t// A Postgres database is only half\ + \ an address; without a schema the hub\n\t\t// of the URL is incomplete and 6\ + \ bounces it back rather than opening it.\n\t\tif ($s['driver'] === 'pgsql' &&\ + \ !empty($q['db']) && !isset($q['ns'])) {\n\t\t\t$q['ns'] = 'public';\n\t\t}\n\ + \t\theader('Location: ?' . http_build_query($q), true, 302);\n\t\texit;\n\t}\n\ + \n\t// Adminer 5 registers a plugin hook only for methods that exist on its core\n\ + \t// object, and navigation() is a plain function there, so a plugin cannot add\n\ + \t// to the sidebar. The switcher is spliced into the rendered page instead, from\n\ + \t// an output-buffer callback: Adminer exits on most paths, and a callback still\n\ + \t// runs at shutdown where code after the include would not.\n\tfunction lerd_switcher($html)\ + \ {\n\t\t$servers = lerd_servers();\n\t\tif (count($servers) < 2 || strpos($html,\ + \ \"