diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..fe6b824
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+# Every text file is stored and checked out with LF, on every platform. Without
+# this, Git for Windows checks out CRLF, gofmt rewrites every file back to LF,
+# and the format gate reports the whole module as unformatted. It also keeps the
+# frames under tests/e2e/screens comparable, which are diffed byte for byte.
+* text=auto eol=lf
+
+# Never converted: the inference library this program carries, and the images.
+*.so binary
+*.dylib binary
+*.dll binary
+*.gguf binary
+*.png binary
diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
new file mode 100644
index 0000000..d348577
--- /dev/null
+++ b/.github/codeql/codeql-config.yml
@@ -0,0 +1,7 @@
+name: opendba
+
+paths-ignore:
+ - src/cli/internal/parser/generated
+
+queries:
+ - uses: security-and-quality
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fed0de..8005972 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,11 +22,11 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -39,7 +39,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -50,7 +50,7 @@ jobs:
go-${{ runner.os }}-
- name: Cache the pinned tools
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: .local/bin
key: tools-ubuntu-latest-${{ hashFiles('src/tools/go.sum', 'src/tools/internal/toolbin/toolbin.go') }}
@@ -60,7 +60,7 @@ jobs:
- name: Upload the coverage report
if: always()
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: coverage
path: coverage.html
@@ -76,11 +76,11 @@ jobs:
matrix:
os: [macos-latest, windows-latest]
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -93,7 +93,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -113,11 +113,11 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -130,7 +130,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -145,7 +145,7 @@ jobs:
- name: Upload what the screens looked like
if: always()
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: screens
path: .e2e
@@ -167,11 +167,11 @@ jobs:
- { goos: windows, goarch: amd64 }
- { goos: windows, goarch: arm64 }
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -184,7 +184,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -207,39 +207,11 @@ jobs:
contents: read
security-events: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: zizmorcore/zizmor-action@v0.6.2
+ - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054
with:
version: v1.29.0
config: .github/zizmor.yml
-
- pages:
- name: publish coverage
- needs: gates
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pages: write
- id-token: write
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - uses: actions/download-artifact@v8
- with:
- name: coverage
- path: site
-
- - name: Use the report as the site index
- run: cp site/coverage.html site/index.html
-
- - uses: actions/configure-pages@v6
- - uses: actions/upload-pages-artifact@v5
- with:
- path: site
- - id: deployment
- uses: actions/deploy-pages@v5
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 3bc8876..ce9b528 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -23,24 +23,24 @@ jobs:
actions: read
contents: read
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
- - uses: github/codeql-action/init@v4
+ - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938
with:
languages: go
- queries: security-and-quality
+ config-file: .github/codeql/codeql-config.yml
- name: Build both modules
run: |
go build ./src/cli/...
go build ./src/tools/...
- - uses: github/codeql-action/analyze@v4
+ - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938
with:
category: /language:go
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 17b5d8a..37afdd5 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -55,11 +55,11 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -72,7 +72,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -83,7 +83,7 @@ jobs:
go-${{ runner.os }}-
- name: Cache the pinned tools
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: .local/bin
key: tools-${{ matrix.os }}-${{ hashFiles('src/tools/go.sum', 'src/tools/internal/toolbin/toolbin.go') }}
@@ -104,11 +104,11 @@ jobs:
matrix:
os: [ubuntu-24.04-arm, macos-15-intel, windows-11-arm]
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -121,7 +121,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -132,7 +132,7 @@ jobs:
go-${{ runner.os }}-
- name: Cache the pinned tools
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: .local/bin
key: tools-${{ matrix.os }}-${{ hashFiles('src/tools/go.sum', 'src/tools/internal/toolbin/toolbin.go') }}
@@ -155,11 +155,11 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -172,7 +172,7 @@ jobs:
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Reuse what was compiled before
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
${{ steps.caches.outputs.build }}
@@ -196,18 +196,27 @@ jobs:
id-token: write
attestations: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
+ - name: Make room for six binaries that each carry an inference library
+ run: |
+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
+ sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
+ df -h /
+
+ - name: Install cosign, which goreleaser signs with
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
+
- name: Install the SBOM generator goreleaser expects
- uses: anchore/sbom-action/download-syft@v0
+ uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610
with:
syft-version: v1.51.0
@@ -218,16 +227,16 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "nightly $version" >> "$GITHUB_STEP_SUMMARY"
- - uses: goreleaser/goreleaser-action@v7
+ - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94
with:
distribution: goreleaser
version: v2.18.0
- args: release --snapshot --clean
+ args: release --snapshot --clean --parallelism 2
env:
OPENDBA_SNAPSHOT_VERSION: ${{ steps.version.outputs.version }}
- name: Attest everything the nightly publishes
- uses: actions/attest-build-provenance@v4
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8
with:
subject-path: |
dist/*.tar.gz
@@ -272,4 +281,6 @@ jobs:
dist/*.tar.gz \
dist/*.zip \
dist/*.sbom.json \
- dist/checksums.txt
+ dist/checksums.txt \
+ dist/checksums.txt.sig \
+ dist/checksums.txt.pem
diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml
index c9afb34..bd3e303 100644
--- a/.github/workflows/release-prep.yml
+++ b/.github/workflows/release-prep.yml
@@ -22,11 +22,11 @@ jobs:
contents: write
pull-requests: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache-dependency-path: src/tools/go.sum
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f0ebca4..311b0f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,12 +23,12 @@ jobs:
id-token: write
attestations: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache: false
@@ -39,22 +39,31 @@ jobs:
- name: Run every gate
run: go run ./src/tools/cmd/dev check --ci --summary "$GITHUB_STEP_SUMMARY"
+ - name: Make room for six binaries that each carry an inference library
+ run: |
+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
+ sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
+ df -h /
+
+ - name: Install cosign, which goreleaser signs with
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
+
- name: Install the SBOM generator goreleaser expects
- uses: anchore/sbom-action/download-syft@v0
+ uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610
with:
syft-version: v1.51.0
- - uses: goreleaser/goreleaser-action@v7
+ - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94
with:
distribution: goreleaser
version: v2.18.0
- args: release --clean
+ args: release --clean --parallelism 2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
- name: Attest everything the release publishes
- uses: actions/attest-build-provenance@v4
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8
with:
subject-path: |
dist/*.tar.gz
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 25ab57b..845d905 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -22,16 +22,16 @@ jobs:
id-token: write
contents: read
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: ossf/scorecard-action@v2.4.4
+ - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- - uses: github/codeql-action/upload-sarif@v4
+ - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938
with:
sarif_file: results.sarif
diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml
index e03ebeb..58ff46f 100644
--- a/.github/workflows/tag.yml
+++ b/.github/workflows/tag.yml
@@ -19,11 +19,11 @@ jobs:
contents: write
actions: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
cache-dependency-path: src/tools/go.sum
diff --git a/.github/workflows/vulnerabilities.yml b/.github/workflows/vulnerabilities.yml
index 8076f06..406a0ba 100644
--- a/.github/workflows/vulnerabilities.yml
+++ b/.github/workflows/vulnerabilities.yml
@@ -20,11 +20,11 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/setup-go@v7
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.work
@@ -39,10 +39,10 @@ jobs:
contents: read
pull-requests: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- - uses: actions/dependency-review-action@v5.0.0
+ - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294
with:
fail-on-severity: moderate
comment-summary-in-pr: on-failure
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
index e7c24bc..57d959e 100644
--- a/.github/zizmor.yml
+++ b/.github/zizmor.yml
@@ -2,7 +2,7 @@ rules:
unpinned-uses:
config:
policies:
- "*": ref-pin
+ "*": hash-pin
artipacked:
ignore:
- release-prep.yml
diff --git a/.goreleaser.yml b/.goreleaser.yml
index e4a9879..5a5ebf8 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -44,6 +44,21 @@ sboms:
documents:
- "{{ .ArtifactName }}.sbom.json"
+# Keyless, against the workflow's own OIDC identity: there is no private key to
+# keep and the signature says which workflow at which commit produced the file.
+signs:
+ - cmd: cosign
+ signature: "${artifact}.sig"
+ certificate: "${artifact}.pem"
+ args:
+ - sign-blob
+ - "--output-signature=${signature}"
+ - "--output-certificate=${certificate}"
+ - "${artifact}"
+ - "--yes"
+ artifacts: checksum
+ output: true
+
snapshot:
version_template: "{{ .Env.OPENDBA_SNAPSHOT_VERSION }}"
diff --git a/README.md b/README.md
index f08fdb3..766f84c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,8 @@
-
OPENDBA
+
+
+
+
+OpenDBA
A terminal workbench for PostgreSQL, SQL Server and SQLite.
@@ -6,48 +10,30 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
-```text
- ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
- → ~ screens › sqlite 3.53.3 READ ONLY
- ─────────────────────────────────────────────────────────────────────────────
-
- OK nothing needs attention 6 checks, all of them fine
-
- MEMORY
-
- free pages [||||||||||||||||||||] 0 ok
-
- LOAD
-
- access read only ok
-
- STORAGE
-
- ▌ integrity ok ok
- journal delete note
- size 20.0 KiB note
-```
+
+
+
-OPENDBA connects to a database and shows you what it is doing: the health of the
+OpenDBA connects to a database and shows you what it is doing: the health of the
server, what is running on it right now, what it holds, and an editor to ask it
-questions. Every statement is parsed before it is sent, and on a read-only
-profile anything that would change data is refused rather than warned about.
+questions. Every statement is parsed against the real grammar of the target
+database before it is sent, and on a read-only profile anything that would change
+data is refused rather than warned about.
It is in development.
## Install
-Every release publishes a signed archive for Linux, macOS and Windows, on both
-amd64 and arm64. Pick one from the
+Every release publishes an archive for Linux, macOS and Windows, on both amd64
+and arm64, with build provenance and a signed checksum file. Pick one from the
[latest release](https://github.com/sonquer/opendba/releases/latest), unpack it,
and put `opendba` on your `PATH`.
@@ -59,7 +45,9 @@ go install github.com/sonquer/opendba/src/cli/cmd/opendba@latest
alongside the plain one, because the product is a nested module.
Nothing else is needed at run time. There is no cgo, no libpq, and no SQLite to
-install: everything the program talks to a database with is compiled into it.
+install: everything the program talks to a database with is compiled into it,
+including the inference library that runs a local model. That is what makes the
+binary large.
### Nightly builds
@@ -71,8 +59,9 @@ compatibility promise.
### Verifying what you downloaded
-Every archive, every SBOM and `checksums.txt` carry build provenance, for
-releases and nightlies alike. Check one before you run it:
+Every archive, every SBOM and `checksums.txt` carry build provenance, and
+`checksums.txt` is signed with a keyless cosign signature tied to the workflow
+that built it. Check one before you run it:
```bash
gh attestation verify opendba_0.1.0_linux_amd64.tar.gz --repo sonquer/opendba
@@ -89,26 +78,155 @@ details, an access mode, and a colour for the environment. The connection is
tested before it is saved, and the password goes to your keychain rather than
into the profile.
-After that: `e` for the editor, `ctrl+r` to run, `?` for the keys, `ctrl+k` for
-everything else.
-
-## Features
-
-- **A dashboard** of server health, sessions, and what the database holds.
-- **An editor with tabs**, autocompletion, and the schema beside it. Each tab is
- on a connection of its own, and a statement keeps running when you leave it.
-- **SQL files** kept per connection, opened in a tab and saved with `ctrl+s`.
-- **Results** that scroll, zoom to the window, and open a row on its own page.
-- **`EXPLAIN`** and `EXPLAIN ANALYZE`, drawn as a tree.
-- **Export** to CSV, JSON, XLSX, Markdown or XML.
-- **History** of what you have run, searchable and forgettable.
-- **An assistant** that can read the schema and draft a statement — Anthropic,
- OpenAI, Gemini, Ollama, or a local model with no network at all.
-- **Connections** set up, switched and removed inside the interface, several of
- them open at once — including several on one configuration, which is two
- windows onto one server.
-- **`--json`** output for scripting.
-- **Mouse** support, if you want it.
+After that: `e` for the editor, `a` to ask, `s` for the tables, `?` for the keys,
+`/` for everything else.
+
+## The dashboard
+
+The bar across the top is the connection: where you are, what version answered,
+and whether this profile may write. Its colour is yours to set per connection, so
+production does not look like staging.
+
+Under it are the readings, grouped by what they are about, each with a bar, a
+value and a verdict of `ok`, `watch` or `act`. The headline names the subsystems
+that need attention and counts the rest. A driver that cannot measure something
+says so rather than reporting a zero.
+
+What is measured depends on the engine: PostgreSQL reports cache and index hit
+ratios, spilled temp files, connections, waiting locks, deadlocks, long-running
+and idle-in-transaction sessions, sequential scans, dead rows, unused indexes,
+vacuum age, transaction wraparound, forced checkpoints, WAL and replication
+slots. SQL Server reports page life expectancy, pending memory grants, the top
+wait, log space and reuse waits, missing indexes, statistics age and the last
+backup. SQLite reports an integrity check, the journal mode, free pages and the
+foreign key check.
+
+Below that is what is running on the server right now, refreshed every three
+seconds, with `c` to cancel a statement and `x` to close a session. The
+connections OpenDBA itself made are hidden unless you ask for them.
+
+### What a reading means
+
+
+
+
+
+`enter` on a reading opens the number with the reasoning behind it: what it is,
+what it means when it is high, and what to do about it. There is one of these
+written for every reading of every engine.
+
+`a` on that page carries the reading into a conversation, so the next question
+already knows which number you are looking at.
+
+## The editor
+
+
+
+
+
+Tabs, each with its own statement, its own result and its own split. The schema
+of the current database is beside the editor, and under it the `.sql` files kept
+for this connection. `enter` on a table opens its rows in a tab of its own with
+the statement that read them written into it; `enter` on a file opens the file.
+
+The statement is coloured as you type it, and what could finish the word is drawn
+after the cursor in grey — tables, the columns of the tables the statement
+already names, and keywords. `tab` takes it.
+
+The line under the editor is the classifier's verdict on what the cursor is in,
+before anything is sent: in the screenshot, `✓ allowed SELECT · reads only`. A
+buffer holding several statements separated by semicolons is a script, and what
+runs is the one the cursor is in.
+
+A statement keeps running when you leave the editor. The tab is marked, the
+header says how much is still out from wherever you are, and `f7` gives up on it.
+
+`f6` asks the server what it would do with the statement instead of doing it, and
+draws the plan as a tree; `enter` there escalates to `EXPLAIN ANALYZE` after
+asking. `ctrl+e` writes the whole result — not the rows on screen — to CSV, JSON,
+XLSX, Markdown or XML. `ctrl+g` opens what you have run, searchable, with `space`
+to keep one.
+
+### A row on its own page
+
+
+
+
+
+`enter` on a row, or a click, opens it as a page of names and values, because a
+row wider than the screen is a row you want to read first. `y` copies the value
+under the cursor and `Y` the whole row, over OSC 52, which tmux ignores unless
+`set -g set-clipboard on`.
+
+## Tables and indexes
+
+
+
+
+
+`s` lists the tables of the schemas you have selected — rows, size, index size,
+and how much of the table is read from memory rather than disk. `i` does the same
+for indexes: which table, how big, how often read. `f` filters, `o` sorts, `O`
+reverses, and `enter` opens one.
+
+`ctrl+d` chooses which database and which schemas those screens cover. What you
+choose is written back to the profile, so the next run starts where you left off.
+
+## The assistant
+
+
+
+
+
+`a` opens a conversation about the database in front of you. It is not a
+statement generator: it reads the schema, the relations, the indexes and the
+health readings with tools, and answers from what it read.
+
+Every statement it wants to run meets the same classifier yours does, in the same
+access mode, and is shown to you before it runs. It cannot write in any mode.
+
+What answers is up to you. `ctrl+o` opens the list: models that run on this
+machine, and providers that need a key — Anthropic, OpenAI, Gemini, Ollama, or
+any OpenAI-compatible endpoint you name. If `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`
+or `GEMINI_API_KEY` is already set, that provider is offered with a reference to
+the variable; the value is never copied into the configuration.
+
+Choosing a local model downloads it and runs it in this process through
+llama.cpp, with no network and no cgo. The catalogue holds Gemma 4 E2B, E4B and
+12B, and GPT-OSS 20B and 120B, each Apache-2.0 or MIT, each pinned to a commit,
+each listed with what it will actually cost you in memory. A model is read into
+memory when you ask your first question, not when you open the screen.
+
+Before anything goes to a machine that is not this one, the screen says what
+would be sent and waits for you. A model running here sends nothing anywhere.
+
+## From the shell
+
+The interface is the default, not the whole program. Every screen has a command
+behind it, and `--json` on any of them prints a machine-readable report against a
+versioned schema.
+
+```bash
+opendba # the interface
+opendba inspect # the health of the connected database
+opendba inspect --json # the same, for something else to read
+opendba schema --schema public # the tables of one schema
+opendba indexes --connection staging # the indexes, on another connection
+opendba query 'select count(*) from orders'
+opendba connections # what is configured
+opendba version
+```
+
+| flag | does |
+|---|---|
+| `--json` | print a machine-readable report |
+| `--connection ` | which connection to use, the first configured one by default |
+| `--schema ` | limit the report to one schema |
+| `--limit ` | how many rows to read |
+| `--yes` | send a statement that changes data without being asked |
+
+`inspect` exits non-zero when the database is not healthy, and a statement the
+classifier refuses exits `3`, so both are usable from a script or a check.
## Safety
@@ -122,15 +240,24 @@ Four layers, and a statement has to pass all of them:
A READ ONLY profile can send nothing that changes data. A READ / WRITE profile
asks before it sends one, and keeps what it ran: the transaction is committed
-unless the statement failed or you gave up on it. A statement PostgreSQL will
-not run inside a transaction block at all, such as `DROP DATABASE` or
-`CREATE INDEX CONCURRENTLY`, is refused by the classifier rather than sent and
-rejected by the server.
+unless the statement failed or you gave up on it.
+
+The classifier is not a search for keywords. It catches a `DELETE` hidden in a
+CTE, a `SELECT ... INTO`, a `SELECT ... FOR UPDATE`, an `EXPLAIN ANALYZE` of a
+write, and a call to a function that changes the server while parsing as a read —
+`pg_terminate_backend`, `setval`, `lo_import`, `dblink_exec` and their like — but
+not a column that merely happens to be named after one. Statements OpenDBA never
+runs at all, such as `COPY`, `SET`, `BEGIN`, `VACUUM` or `REINDEX`, are refused
+in both modes, and so is one PostgreSQL will not run inside a transaction block,
+such as `DROP DATABASE` or `CREATE INDEX CONCURRENTLY` — refused by the
+classifier rather than sent and rejected by the server.
Multi-statement input is refused in every access mode. A password is never
written to `profiles.toml`, to `--json`, to the query history, or to any screen:
the profile holds a reference to the keychain, an age-encrypted vault, an
-environment variable, a command, `~/.pgpass`, or a prompt.
+environment variable, a command, `~/.pgpass`, or a prompt. An API key in
+`settings.toml` is the same — a literal key there is a configuration error, not a
+convenience.
[SECURITY.md](SECURITY.md) is what to do if you find a hole in that.
@@ -145,7 +272,6 @@ $XDG_CONFIG_HOME/opendba/ (or ~/.config/opendba/)
$XDG_DATA_HOME/opendba/ (or ~/.local/share/opendba/)
sql// the .sql files the sidebar lists
models/ weights, and a manifest saying what they are
- lib/ llama.cpp, written out of this program
$XDG_STATE_HOME/opendba/ (or ~/.local/state/opendba/)
history.db statements you have run
@@ -157,14 +283,15 @@ $XDG_STATE_HOME/opendba/ (or ~/.local/state/opendba/)
Both `.toml` files are refused if another user can read them, and the directory
is forced to `0700`. None of that is enforced on Windows.
-Everything in `settings.toml` is on the settings screen. Two settings are worth
-knowing about from the outside, because they depend on your font and your
-terminal rather than on your taste:
+Everything in `settings.toml` except the assistant is on the settings screen; the
+assistant has a screen of its own. Two settings are worth knowing about from the
+outside, because they depend on your font and your terminal rather than on your
+taste:
```toml
[appearance]
- bar = "pipes" # pipes smooth shade rail segments braille ascii
- mouse = "on" # off gives the terminal its mouse back, for selecting text
+ bar = "pipes" # pipes smooth shade rail segments braille ascii
+ mouse = "on" # off gives the terminal its mouse back, for selecting text
```
To see the bars in your own font before choosing:
@@ -175,36 +302,38 @@ go run ./src/cli/cmd/screens
## Keys
-`ctrl+k` opens the command palette, which reaches every screen without knowing a
-single shortcut, and `?` prints the rest of this table inside the program.
+`/` opens the command list, which reaches every screen without knowing a single
+shortcut. On a screen where you type, a slash is a slash, so `ctrl+k` does it
+there. `?` prints the rest of this table inside the program.
| key | does |
|---|---|
-| `e` `a` | editor, ask |
-| `ctrl+r` | run the statement |
+| `e` `a` `s` `i` | editor, ask, tables, indexes |
+| `ctrl+enter` `ctrl+r` `f5` | run the statement |
+| `f7` | give up on the statement this tab is waiting for |
+| `f6` | what the server would do with the statement |
| `ctrl+s` | save the tab to a file |
+| `ctrl+e` | write the result to a file |
| `ctrl+n` `ctrl+w` | open a tab, close it |
| `ctrl+1`…`ctrl+9` | go to a tab by its place |
| `ctrl+b` | show or hide the schema beside the editor |
-| `ctrl+e` | write the result to a file |
| `ctrl+p` | connections: where you are working, and what else you could open |
| `ctrl+d` | the databases and schemas of the connection you are on |
-| `ctrl+g` | what you have run |
-| `f6` | what the server would do with the statement |
+| `ctrl+g` | what you have run, or what you have asked |
+| `ctrl+o` `ctrl+t` | change what answers, show its working |
| `z` | zoom a result to the whole window |
-| `f7` | give up on the statement this tab is waiting for |
+| `y` `Y` | copy the value, copy the row |
+| `tab` | move between the panes |
| `esc` | go back, leaving what is running to run |
-| `q` | quit, and again to confirm |
+| `q` | quit, then `enter` to confirm |
Terminals that speak the Kitty keyboard protocol (Ghostty, kitty, WezTerm) also
-send `ctrl+enter` to run a statement, and the footer says so once the terminal
-has said it can tell them apart. On macOS the modifiers are drawn the way the
-keyboard prints them.
+send `ctrl+tab` and `ctrl+shift+tab` for the neighbouring tab, and the footer
+names `ctrl+enter` for run once the terminal has said it can tell it apart. On
+macOS the modifiers are drawn the way the keyboard prints them.
## What is not there yet
-- **Relations** are on the driver interface and both drivers implement them, but
- nothing calls them yet.
- **MySQL and MariaDB** are planned behind the same driver interface.
PostgreSQL, SQL Server and SQLite exist.
- **SQL Server has one fewer safety layer than PostgreSQL.** There is no session
@@ -212,6 +341,9 @@ keyboard prints them.
enforces, so a read only profile there rests on the client-side classifier and
on the permissions of the login, and a statement deadline is the client's
alone. Connect as a login that cannot write.
+- **`theme` and `accent`** are read out of `settings.toml` and round-tripped, but
+ nothing is drawn from them yet. The colour that does have an effect is the one
+ set per connection.
Not planned: a web interface, migrations, backup and restore, or anything that
writes to your database without you typing it first.
@@ -226,13 +358,29 @@ writes to your database without you typing it first.
go run ./src/tools/cmd/dev check --ci
```
+One thing that surprises people reading the tree: the llama.cpp shared libraries
+under `src/cli/internal/ai/providers/local/embedded/` are committed on purpose.
+They are what lets a local model run with nothing fetched at run time. It costs
+the project the OpenSSF `Binary-Artifacts` check, which is a trade made
+knowingly rather than an oversight.
+
+## Contributors
+
+
+
+
+
+[CONTRIBUTING.md](CONTRIBUTING.md) is how to join them.
+
## Built with
[Bubble Tea, Bubbles, Lip Gloss and Glamour](https://charm.land) for the
interface, [pgx](https://github.com/jackc/pgx) for PostgreSQL,
[go-mssqldb](https://github.com/microsoft/go-mssqldb) for SQL Server,
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) for SQLite with no
-cgo, [ANTLR](https://www.antlr.org) for the grammars, and
+cgo, [ANTLR](https://www.antlr.org) for the grammars,
+[llama.cpp](https://github.com/ggml-org/llama.cpp) through
+[purego](https://github.com/ebitengine/purego) for local models, and
[go-keyring](https://github.com/zalando/go-keyring) and
[age](https://github.com/FiloSottile/age) for the secrets.
diff --git a/SECURITY.md b/SECURITY.md
index 18f379a..d0ba1f3 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,7 +16,7 @@ Until the first stable release, only the latest commit on `main` receives fixes.
## What this project treats as a vulnerability
-OPENDBA stands between a person and their production database, so the bar is:
+OpenDBA stands between a person and their production database, so the bar is:
- **A statement reaching the database that the safety layer should have stopped.**
The classifier is default-deny over a real parse tree; anything that gets a write
@@ -32,7 +32,7 @@ OPENDBA stands between a person and their production database, so the bar is:
## What it does not
- A database role with write privileges doing what its privileges allow.
- OPENDBA documents the read-only role and warns when the connected role can write,
+ OpenDBA documents the read-only role and warns when the connected role can write,
but it cannot be the boundary.
- Findings that require an attacker to already control the user's account.
diff --git a/img/ask_ai.png b/img/ask_ai.png
new file mode 100644
index 0000000..2503e84
Binary files /dev/null and b/img/ask_ai.png differ
diff --git a/img/dashboard.png b/img/dashboard.png
new file mode 100644
index 0000000..61eaed9
Binary files /dev/null and b/img/dashboard.png differ
diff --git a/img/dashboard_hints.png b/img/dashboard_hints.png
new file mode 100644
index 0000000..cbab7c3
Binary files /dev/null and b/img/dashboard_hints.png differ
diff --git a/img/gopher.png b/img/gopher.png
new file mode 100644
index 0000000..b29c7bb
Binary files /dev/null and b/img/gopher.png differ
diff --git a/img/query_editor.png b/img/query_editor.png
new file mode 100644
index 0000000..8b26796
Binary files /dev/null and b/img/query_editor.png differ
diff --git a/img/query_editor_records.png b/img/query_editor_records.png
new file mode 100644
index 0000000..a59f38c
Binary files /dev/null and b/img/query_editor_records.png differ
diff --git a/img/tables.png b/img/tables.png
new file mode 100644
index 0000000..5c946db
Binary files /dev/null and b/img/tables.png differ
diff --git a/src/cli/internal/cli/cli.go b/src/cli/internal/cli/cli.go
index 92b6af3..26ec5b5 100644
--- a/src/cli/internal/cli/cli.go
+++ b/src/cli/internal/cli/cli.go
@@ -548,6 +548,7 @@ flags:
--connection the connection to use, the first one by default
--schema limit the report to one schema
--limit maximum number of rows to read
+ --yes run a statement that changes data without being asked
Everything is read only unless the connection says otherwise, and a statement
that changes data is refused before it reaches the server.
diff --git a/src/cli/pkg/sqldialect/fuzz_test.go b/src/cli/pkg/sqldialect/fuzz_test.go
new file mode 100644
index 0000000..4ca1099
--- /dev/null
+++ b/src/cli/pkg/sqldialect/fuzz_test.go
@@ -0,0 +1,69 @@
+package sqldialect
+
+import "testing"
+
+func seedDialect(f *testing.F) {
+ f.Helper()
+ for _, sql := range []string{
+ "SELECT 1",
+ "select id from users where id = $1",
+ "WITH removed AS (DELETE FROM users RETURNING *) SELECT count(*) FROM removed",
+ "SELECT $tag$ delete from t; $tag$",
+ "EXPLAIN (COSTS OFF) SELECT 1",
+ "UPDATE users SET name = 'x' WHERE id = 1",
+ "CREATE INDEX CONCURRENTLY ON users (id)",
+ "SELECT 1; SELECT 2",
+ "-- nothing but a comment",
+ "",
+ ";",
+ "\x00",
+ "SELECT '",
+ } {
+ f.Add(sql)
+ }
+}
+
+// FuzzAnalyzeSurvivesAnything holds the parsers to what the classifier assumes
+// of them: whatever bytes arrive, Analyze returns rather than panicking, and
+// every statement it reports can be cut back out of the request it was read
+// from.
+func FuzzAnalyzeSurvivesAnything(f *testing.F) {
+ seedDialect(f)
+ dialects := []Dialect{PostgreSQL(), SQLite(), MSSQL()}
+ f.Fuzz(func(t *testing.T, sql string) {
+ for _, dialect := range dialects {
+ analysis := dialect.Analyze(sql)
+ for _, statement := range analysis.Statements {
+ if statement.Start < 0 || statement.Stop < statement.Start {
+ t.Errorf("%s reported %d..%d for %q",
+ dialect.Name(), statement.Start, statement.Stop, sql)
+ }
+ cut := []rune(statement.Slice(sql))
+ if len(cut) != 0 && len(cut) != statement.Stop-statement.Start+1 {
+ t.Errorf("%s cut %d runes for a statement spanning %d..%d of %q",
+ dialect.Name(), len(cut), statement.Start, statement.Stop, sql)
+ }
+ }
+ if !analysis.Valid() && analysis.FirstError() == "" {
+ t.Errorf("%s reported an error with nothing to say for %q", dialect.Name(), sql)
+ }
+ }
+ })
+}
+
+// FuzzAtStaysInsideTheAnalysis is what the editor leans on to say which
+// statement the cursor is in: any offset, valid or not, is answered without
+// reaching past the statements that were found.
+func FuzzAtStaysInsideTheAnalysis(f *testing.F) {
+ seedDialect(f)
+ dialect := PostgreSQL()
+ f.Fuzz(func(t *testing.T, sql string) {
+ analysis := dialect.Analyze(sql)
+ for offset := -1; offset <= len([]rune(sql)); offset++ {
+ statement, found := analysis.At(offset)
+ if found && statement.Stop < statement.Start {
+ t.Errorf("At(%d) gave %d..%d for %q", offset, statement.Start, statement.Stop, sql)
+ }
+ }
+ })
+}
diff --git a/src/cli/pkg/sqlguard/fuzz_test.go b/src/cli/pkg/sqlguard/fuzz_test.go
new file mode 100644
index 0000000..696dbe1
--- /dev/null
+++ b/src/cli/pkg/sqlguard/fuzz_test.go
@@ -0,0 +1,64 @@
+package sqlguard
+
+import (
+ "testing"
+
+ "github.com/sonquer/opendba/src/cli/pkg/sqldialect"
+)
+
+func seedGuard(f *testing.F) {
+ f.Helper()
+ for _, golden := range [][]goldenCase{postgresGolden, sqliteGolden, mssqlGolden} {
+ for _, test := range golden {
+ f.Add(test.sql)
+ }
+ }
+ f.Add("")
+ f.Add(";")
+ f.Add(";;;")
+ f.Add("SELECT 1; DROP TABLE users")
+ f.Add("\x00")
+}
+
+// FuzzClassifyKeepsReadOnlyReadOnly is the property the whole safety layer rests
+// on: whatever is typed, a statement allowed in read only mode reads and nothing
+// more, and it is exactly one statement.
+func FuzzClassifyKeepsReadOnlyReadOnly(f *testing.F) {
+ seedGuard(f)
+ guards := map[string]Guard{
+ "postgresql": New(sqldialect.PostgreSQL()),
+ "sqlite": New(sqldialect.SQLite()),
+ "mssql": New(sqldialect.MSSQL()),
+ }
+ f.Fuzz(func(t *testing.T, sql string) {
+ for name, guard := range guards {
+ result := guard.Classify(sql, ModeReadOnly)
+ if !result.Allowed() {
+ continue
+ }
+ if result.Statements != 1 {
+ t.Errorf("%s allowed %d statements for %q", name, result.Statements, sql)
+ }
+ analysis := guard.dialect.Analyze(sql)
+ if len(analysis.Statements) != 1 || !analysis.Statements[0].Reads() {
+ t.Errorf("%s allowed a statement that does not only read: %q", name, sql)
+ }
+ }
+ })
+}
+
+// FuzzClassifyRefusesMoreThanOneStatement holds the rule that multi statement
+// input is refused in every mode, which is what stops a second statement being
+// smuggled past a verdict given for the first.
+func FuzzClassifyRefusesMoreThanOneStatement(f *testing.F) {
+ seedGuard(f)
+ guard := New(sqldialect.PostgreSQL())
+ f.Fuzz(func(t *testing.T, sql string) {
+ for _, mode := range []Mode{ModeReadOnly, ModeReadWrite} {
+ result := guard.Classify(sql, mode)
+ if result.Statements > 1 && !result.Blocked() {
+ t.Errorf("%s let %d statements through: %q", mode, result.Statements, sql)
+ }
+ }
+ })
+}
diff --git a/src/tools/internal/checks/checks.go b/src/tools/internal/checks/checks.go
index 531de35..0abc4b2 100644
--- a/src/tools/internal/checks/checks.go
+++ b/src/tools/internal/checks/checks.go
@@ -137,7 +137,7 @@ func (c commandCheck) Run(ctx context.Context) (core.Report, error) {
func Format(module workspace.Module, runner exec.Runner) core.Check {
return commandCheck{
name: "format",
- describe: "gofmt -s -l reports nothing",
+ describe: "go fmt rewrites nothing",
module: module,
runner: runner,
args: []string{"fmt", "./..."},
diff --git a/src/tools/pkg/tuitest/session.go b/src/tools/pkg/tuitest/session.go
index c2123c6..03536a2 100644
--- a/src/tools/pkg/tuitest/session.go
+++ b/src/tools/pkg/tuitest/session.go
@@ -12,10 +12,11 @@ import (
)
const (
- defaultQuiet = 120 * time.Millisecond
- defaultPoll = 15 * time.Millisecond
- replyPoll = 2 * time.Millisecond
- graceRounds = 3
+ defaultQuiet = 120 * time.Millisecond
+ defaultPoll = 15 * time.Millisecond
+ defaultTimeout = 10 * time.Second
+ replyPoll = 2 * time.Millisecond
+ graceRounds = 3
)
// Options is what a session needs to start a program.
@@ -60,6 +61,9 @@ func Start(opts Options) (*Session, error) {
if opts.Quiet <= 0 {
opts.Quiet = defaultQuiet
}
+ if opts.Timeout <= 0 {
+ opts.Timeout = defaultTimeout
+ }
if opts.Now == nil {
opts.Now = time.Now
}
@@ -244,7 +248,7 @@ func (s *Session) Close() (int, error) {
case err := <-waited:
_ = s.pty.Close()
return exitCode(s.command, err), nil
- case <-time.After(s.options.Quiet * 20):
+ case <-time.After(s.options.Timeout):
}
if s.command.Process != nil {
_ = s.command.Process.Kill()
diff --git a/src/tools/pkg/tuitest/session_test.go b/src/tools/pkg/tuitest/session_test.go
index 44d969d..d8797d4 100644
--- a/src/tools/pkg/tuitest/session_test.go
+++ b/src/tools/pkg/tuitest/session_test.go
@@ -12,7 +12,10 @@ func started(t *testing.T, opts Options) *Session {
if err != nil {
t.Fatalf("Start = %v", err)
}
- t.Cleanup(func() { _, _ = session.Close() })
+ t.Cleanup(func() {
+ _ = session.Send([]byte{0x03})
+ _, _ = session.Close()
+ })
return session
}
@@ -164,4 +167,17 @@ func TestASessionFallsBackToWhatEveryRunWants(t *testing.T) {
if session.options.Quiet != defaultQuiet || session.options.Now == nil {
t.Errorf("quiet = %v", session.options.Quiet)
}
+ if session.options.Timeout != defaultTimeout {
+ t.Errorf("timeout = %v, want %v", session.options.Timeout, defaultTimeout)
+ }
+}
+
+func TestAProgramThatWillNotLeaveIsKilled(t *testing.T) {
+ opts := fakeOptions(t, 40, 10)
+ opts.Timeout = 50 * time.Millisecond
+ session := started(t, opts)
+ session.Await(soon(), func(f Frame) bool { return f.Contains("READY") })
+ if _, err := session.Close(); err == nil {
+ t.Error("a program that never exits was reported as having exited")
+ }
}