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

+

+ the OpenDBA gopher +

+ +

OpenDBA

A terminal workbench for PostgreSQL, SQL Server and SQLite.
@@ -6,48 +10,30 @@

- ci - nightly - release - openssf scorecard - licence - go - status + ci + nightly + go + platforms + licence + status

-```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 -``` +

+ the dashboard, on a PostgreSQL connection in read / write mode +

-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 + +

+ the explanation behind a dashboard reading +

+ +`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 + +

+ the query editor with the schema beside it and a result below +

+ +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 + +

+ one record opened on a page of its own +

+ +`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 + +

+ the tables of every schema, with rows, size and how much is read from memory +

+ +`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 conversation about the connected database, answered by a local model +

+ +`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 + + + the people who have contributed to OpenDBA + + +[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") + } }