- bin/db/psql-yq + bin/web/search + tools/{yamlout,websearch} vendored as real files
- bin/db/ssh-tunnel added (OnlyOffice VM pg on 5433)
- skills reference local bin/ paths; no agent-skills/abs links in git
- pyproject.toml + uv.lock; CI installs via uv sync --frozen
- release-please auto-tags semver from conventional commits when green
- LICENSE MIT, badges/mermaid README
117 lines
4.1 KiB
Bash
Executable File
117 lines
4.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# db/psql-yq - read any Postgres as YAML, cheaply and read-only.
|
|
#
|
|
# db/psql-yq --profile onlyoffice -t document_asset -l 20 # table sample
|
|
# db/psql-yq --profile onlyoffice -s document_asset # column list
|
|
# db/psql-yq --profile onlyoffice -c 'SELECT ...' # query -> YAML
|
|
# db/psql-yq --container my-pg --db app -c '...' # ad-hoc container
|
|
# db/psql-yq --dsn 'postgres://u@h:5432/db' -c '...'
|
|
# db/psql-yq -r 'SELECT ...' # raw rows, no YAML
|
|
#
|
|
# Profiles live in ~/.config/brain/db-profiles.yml so that credentials and
|
|
# hostnames stay out of every project repo. A profile is:
|
|
#
|
|
# onlyoffice:
|
|
# host: 127.0.0.1
|
|
# port: 5433
|
|
# user: onlyoffice
|
|
# db: onlyoffice
|
|
# password_env_file: /home/ano/.config/ops/onlyoffice.env
|
|
#
|
|
# The onlyoffice Postgres listens inside the QEMU VM (host port 32, SSH only).
|
|
# Credentials come from /etc/onlyoffice/documentserver/local.json inside the
|
|
# VM (dbUser/dbPass), read via db/ssh-tunnel, never committed.
|
|
# db/ssh-tunnel opens 127.0.0.1:5433 -> vm:5432 before querying.
|
|
#
|
|
# Read-only guard: any DML or DDL keyword is rejected, whatever the profile.
|
|
set -euo pipefail
|
|
|
|
PROFILES="${BRAIN_DB_PROFILES:-$HOME/.config/brain/db-profiles.yml}"
|
|
IMAGE="${BRAIN_PSQL_IMAGE:-postgres:13}"
|
|
LIMIT="${PSQLYQ_LIMIT:-50}"
|
|
MODE=c
|
|
ARG=""
|
|
PROFILE=""
|
|
CONTAINER=""
|
|
DSN=""
|
|
DB=""
|
|
USER_NAME=""
|
|
|
|
usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; }
|
|
|
|
[[ $# -eq 0 ]] && { usage; exit 1; }
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--profile) PROFILE="$2"; shift 2 ;;
|
|
--container) CONTAINER="$2"; shift 2 ;;
|
|
--dsn) DSN="$2"; shift 2 ;;
|
|
--db) DB="$2"; shift 2 ;;
|
|
--user) USER_NAME="$2"; shift 2 ;;
|
|
-c) MODE=c; ARG="$2"; shift 2 ;;
|
|
-t) MODE=t; ARG="$2"; shift 2 ;;
|
|
-s) MODE=s; ARG="$2"; shift 2 ;;
|
|
-r) MODE=r; ARG="$2"; shift 2 ;;
|
|
-l) LIMIT="$2"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "unknown arg: $1" >&2; usage; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
assert_readonly() {
|
|
local s="${1,,}"
|
|
if [[ "$s" =~ (^|[^a-z])(insert|update|delete|drop|truncate|alter|create|grant|revoke|vacuum|copy)[[:space:]] ]]; then
|
|
echo "db/psql-yq: read-only, query rejected" >&2
|
|
exit 3
|
|
fi
|
|
}
|
|
|
|
prof() { yq -r ".\"$PROFILE\".$1 // \"\"" "$PROFILES" 2>/dev/null; }
|
|
|
|
build_client() {
|
|
if [[ -n "$DSN" ]]; then
|
|
PGCLI=(docker run --rm "$IMAGE" psql "$DSN" -X -A -t)
|
|
return
|
|
fi
|
|
|
|
if [[ -n "$CONTAINER" ]]; then
|
|
PGCLI=(docker exec "$CONTAINER" psql -U "${USER_NAME:-postgres}" -d "${DB:?--db required with --container}" -X -A -t)
|
|
return
|
|
fi
|
|
|
|
[[ -n "$PROFILE" ]] || { echo "need --profile, --container or --dsn" >&2; exit 2; }
|
|
[[ -f "$PROFILES" ]] || { echo "no profiles file: $PROFILES" >&2; exit 2; }
|
|
[[ "$(yq -r "has(\"$PROFILE\")" "$PROFILES")" == "true" ]] \
|
|
|| { echo "unknown profile '$PROFILE' in $PROFILES" >&2; exit 2; }
|
|
|
|
local container host port user db network envfile
|
|
container="$(prof container)"; host="$(prof host)"; port="$(prof port)"
|
|
user="$(prof user)"; db="$(prof db)"; network="$(prof network)"
|
|
envfile="$(prof password_env_file)"
|
|
|
|
if [[ -n "$container" ]]; then
|
|
PGCLI=(docker exec "$container" psql -U "${user:-postgres}" -d "$db" -X -A -t)
|
|
else
|
|
if [[ -n "$envfile" ]]; then
|
|
[[ -f "$envfile" ]] || { echo "password_env_file missing: $envfile" >&2; exit 2; }
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$envfile"; set +a
|
|
fi
|
|
local net=()
|
|
[[ -n "$network" ]] && net=(--network "$network")
|
|
PGCLI=(docker run --rm "${net[@]}" -e PGPASSWORD="${PGPASSWORD:?password unset for profile $PROFILE}" \
|
|
"$IMAGE" psql -h "$host" -p "${port:-5432}" -U "$user" -d "$db" -X -A -t)
|
|
fi
|
|
}
|
|
|
|
build_client
|
|
|
|
case "$MODE" in
|
|
r) assert_readonly "$ARG"; "${PGCLI[@]}" -c "$ARG" ;;
|
|
t) assert_readonly "SELECT * FROM $ARG"
|
|
"${PGCLI[@]}" -c "SELECT json_agg(row_to_json(t)) FROM (SELECT * FROM $ARG LIMIT $LIMIT) t" | yq -P . ;;
|
|
s) "${PGCLI[@]}" -c "SELECT column_name || ':' || data_type FROM information_schema.columns WHERE table_name='$ARG' ORDER BY ordinal_position" ;;
|
|
c) assert_readonly "$ARG"
|
|
"${PGCLI[@]}" -c "SELECT json_agg(row_to_json(t)) FROM ($ARG) t" | yq -P . ;;
|
|
esac
|