feat: current state
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing version tag
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
macos-arm64:
|
||||
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Validate and package macOS archive
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||
run: |
|
||||
VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2)
|
||||
test "$RELEASE_TAG" = "$VERSION" || test "$RELEASE_TAG" = "v$VERSION"
|
||||
test "$(rustc -vV | awk '/host:/ {print $2}')" = "aarch64-apple-darwin"
|
||||
cargo metadata --locked --no-deps --format-version 1
|
||||
cargo fmt --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
cargo test --locked --workspace
|
||||
cargo build --locked --release
|
||||
NAME="azure-database-tui-$RELEASE_TAG-macos-arm64.tar.gz"
|
||||
mkdir -p "package/azure-database-tui-$RELEASE_TAG"
|
||||
cp target/release/azure-database-tui "package/azure-database-tui-$RELEASE_TAG/"
|
||||
cp README.md docs/security.md docs/operations.md docs/release.md "package/azure-database-tui-$RELEASE_TAG/"
|
||||
tar -czf "$NAME" -C package "azure-database-tui-$RELEASE_TAG"
|
||||
shasum -a 256 "$NAME" > "$NAME.sha256"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: macos-release
|
||||
path: azure-database-tui-*-macos-arm64.tar.gz*
|
||||
windows-x86_64:
|
||||
runs-on: ${{ vars.WINDOWS_X86_64_RUNNER_LABEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Validate and package Windows archive
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||
run: |
|
||||
$version = ((Get-Content Cargo.toml | Select-String '^version = ').ToString().Split('"')[1])
|
||||
if ($env:RELEASE_TAG -ne $version -and $env:RELEASE_TAG -ne "v$version") { throw 'Tag does not match package version' }
|
||||
if ((rustc -vV | Select-String '^host:').ToString().Split(': ')[1] -ne 'x86_64-pc-windows-msvc') { throw 'Unexpected Rust host target' }
|
||||
cargo metadata --locked --no-deps --format-version 1
|
||||
cargo fmt --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
cargo test --locked --workspace
|
||||
cargo build --locked --release
|
||||
$name = "azure-database-tui-$env:RELEASE_TAG-windows-x86_64.zip"
|
||||
$root = "package/azure-database-tui-$env:RELEASE_TAG"
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
Copy-Item target/release/azure-database-tui.exe $root/
|
||||
Copy-Item README.md,docs/security.md,docs/operations.md,docs/release.md $root/
|
||||
Compress-Archive -Path $root -DestinationPath $name
|
||||
(Get-FileHash $name -Algorithm SHA256).Hash.ToLower() + " " + $name | Out-File "$name.sha256" -Encoding ascii
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: windows-release
|
||||
path: azure-database-tui-*-windows-x86_64.zip*
|
||||
publish:
|
||||
needs: [macos-arm64, windows-x86_64]
|
||||
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
|
||||
GITEA_REPOSITORY: ${{ vars.GITEA_REPOSITORY }}
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: release-assets
|
||||
- name: Publish completed draft release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$GITEA_API_URL"; test -n "$GITEA_REPOSITORY"; test -n "$GITEA_RELEASE_TOKEN"
|
||||
files=(release-assets/macos-release/* release-assets/windows-release/*)
|
||||
test ${#files[@]} -eq 4
|
||||
for file in "${files[@]}"; do test -s "$file"; done
|
||||
api="$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/tags/$RELEASE_TAG"
|
||||
response=$(curl --silent --show-error --fail -H "Authorization: token $GITEA_RELEASE_TOKEN" "$api" || true)
|
||||
if test -z "$response"; then
|
||||
response=$(curl --silent --show-error --fail -X POST -H "Authorization: token $GITEA_RELEASE_TOKEN" -H "Content-Type: application/json" -d "{\"tag_name\":\"$RELEASE_TAG\",\"name\":\"$RELEASE_TAG\",\"draft\":true}" "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases")
|
||||
fi
|
||||
release_id=$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
for file in "${files[@]}"; do curl --silent --show-error --fail -X POST -H "Authorization: token $GITEA_RELEASE_TOKEN" -F "attachment=@$file" "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$release_id/assets"; done
|
||||
curl --silent --show-error --fail -X PATCH -H "Authorization: token $GITEA_RELEASE_TOKEN" -H "Content-Type: application/json" -d '{"draft":false}' "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$release_id"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: validate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-arm64:
|
||||
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Verify native host
|
||||
run: test "$(rustc -vV | awk '/host:/ {print $2}')" = "aarch64-apple-darwin"
|
||||
- name: Validate locked Rust project
|
||||
run: cargo metadata --locked --no-deps --format-version 1 && cargo fmt --check && cargo clippy --locked --workspace --all-targets -- -D warnings && cargo test --locked --workspace && cargo build --locked --release
|
||||
windows-x86_64:
|
||||
runs-on: ${{ vars.WINDOWS_X86_64_RUNNER_LABEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Verify native host and validate locked Rust project
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ((rustc -vV | Select-String '^host:').ToString().Split(': ')[1] -ne 'x86_64-pc-windows-msvc') { throw 'Unexpected Rust host target' }
|
||||
cargo metadata --locked --no-deps --format-version 1
|
||||
cargo fmt --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
cargo test --locked --workspace
|
||||
cargo build --locked --release
|
||||
Generated
+2271
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,11 @@ required. Azure CLI visibility does not imply PostgreSQL authorization.
|
||||
|
||||
## Build and test
|
||||
|
||||
Use the pinned Rust toolchain with `cargo build`, `cargo test`, and the local
|
||||
integration helper documented in `docs/operations.md`.
|
||||
The repository contains a checked-in dependency lock. Use the pinned Rust
|
||||
toolchain with `cargo build --locked` and `cargo test --locked`. The initial
|
||||
working screen discovers Flexible Servers through Azure CLI; the lower-level
|
||||
domain and service modules provide the foundations for profiles, table actions,
|
||||
roles and the SQL workspace.
|
||||
|
||||
Supported release targets are macOS ARM64 and Windows x86_64. Raw SQL is sent
|
||||
to PostgreSQL as entered; use it only when you understand its authority and
|
||||
@@ -21,3 +24,314 @@ effects.
|
||||
|
||||
See [security](docs/security.md), [operations](docs/operations.md), and
|
||||
[release](docs/release.md).
|
||||
|
||||
|
||||
|
||||
|
||||
- Native, performante Rust-TUI zur Administration von Azure Database for PostgreSQL Flexible Server.
|
||||
- Unterstützung ausschließlich für macOS ARM64 und Windows x86_64 als Release-Ziele.
|
||||
- Nutzung der vorhandenen Azure CLI und des aktuell angemeldeten Azure-Tenants.
|
||||
- Ermittlung aller sichtbaren aktiven Subscriptions im aktuellen Tenant.
|
||||
- Ermittlung aller sichtbaren Azure PostgreSQL Flexible Server über diese Subscriptions hinweg.
|
||||
- Serverauswahl in der TUI.
|
||||
- Anzeige und Auswahl der verfügbaren Datenbanken eines Servers.
|
||||
- Datenbank-Lifecycle über Azure: Datenbanken auflisten, erstellen und löschen.
|
||||
- Schutz von Systemdatenbanken wie postgres und Azure-reservierten Datenbanken vor geführtem Löschen.
|
||||
- Datenbank-Drop nur mit expliziter Bestätigung und exakter Zielangabe.
|
||||
- Lokale, wiederverwendbare Verbindungsprofile:
|
||||
- Azure-Ressourcenreferenz,
|
||||
- Server/FQDN,
|
||||
- Port,
|
||||
- Datenbank,
|
||||
- PostgreSQL-Benutzername,
|
||||
- TLS-Konfiguration,
|
||||
- optionale Keychain-Referenz.
|
||||
- Keine Klartextpasswörter in Profildateien.
|
||||
- Speicherung von Passwort-Referenzen im macOS Keychain bzw. Windows Credential Manager.
|
||||
- Falls Keychain nicht verfügbar ist: nur sitzungsgebundene Passworteingabe, kein Klartext-Fallback.
|
||||
- Profile auflisten, erstellen, ändern, löschen und zum Verbinden verwenden.
|
||||
- Ausschließlich sichere PostgreSQL-Verbindungen:
|
||||
- TLS verpflichtend,
|
||||
- Zertifikatsprüfung,
|
||||
- Hostnamenprüfung,
|
||||
- optional zusätzliche lokale CA-Datei,
|
||||
- kein unsicherer TLS-Modus,
|
||||
- kein Zertifikats-/Hostname-Bypass,
|
||||
- keine Klartextverbindung.
|
||||
- Getrennte Azure-Management-Plane- und PostgreSQL-Data-Plane-Autorisierung.
|
||||
- Azure-Sichtbarkeit darf nicht als PostgreSQL-Zugriffsberechtigung dargestellt werden.
|
||||
- Permanente Anzeige des aktuellen Zielkontexts:
|
||||
- Tenant,
|
||||
- Subscription,
|
||||
- Azure Server,
|
||||
- Datenbank,
|
||||
- Profil,
|
||||
- PostgreSQL-Benutzer,
|
||||
- TLS-/Verbindungsstatus,
|
||||
- Kataloggeneration,
|
||||
- Transaktionsstatus.
|
||||
Tabellen und Schema
|
||||
- Tabellen und Metadaten auflisten.
|
||||
- Tabellendetails anzeigen:
|
||||
- Spalten,
|
||||
- Typen,
|
||||
- Nullability,
|
||||
- Defaults,
|
||||
- Identity-Spalten,
|
||||
- Generated-Spalten,
|
||||
- Schlüssel,
|
||||
- Constraints.
|
||||
- Tabellen erstellen.
|
||||
- Tabellen umbenennen.
|
||||
- Tabellen in ein bestehendes Schema verschieben.
|
||||
- Tabellen löschen.
|
||||
- Kein implizites CASCADE bei geführtem Tabellen-Drop.
|
||||
- Tabellen-Drop nur nach expliziter Bestätigung und exakter kanonischer Zielbezeichnung.
|
||||
- Spalten:
|
||||
- auflisten,
|
||||
- hinzufügen,
|
||||
- umbenennen,
|
||||
- Typ ändern,
|
||||
- Default setzen/entfernen,
|
||||
- Nullability ändern,
|
||||
- Identity verwalten,
|
||||
- Generated-Ausdruck verwalten, soweit der Server dies unterstützt,
|
||||
- löschen.
|
||||
- Constraints:
|
||||
- Primary Key,
|
||||
- Unique,
|
||||
- Foreign Key,
|
||||
- Check,
|
||||
- Exclusion.
|
||||
- Constraints sollen erstellt, umbenannt, validiert und gelöscht werden können.
|
||||
- DDL-Ausdrücke wie Defaults, Checks, Generated Expressions und Typkonvertierungen sollen als ausdrücklich markierte SQL-Ausdrücke sichtbar und bestätigungspflichtig sein.
|
||||
- Geführte DDL-Operationen sollen transaktional ausgeführt werden, soweit PostgreSQL dies unterstützt.
|
||||
- Abhängigkeiten vor destruktiven Tabellen-, Spalten- oder Constraint-Änderungen anzeigen und Drop blockieren, statt implizit Abhängigkeiten zu entfernen.
|
||||
Zeilen-CRUD
|
||||
- Tabelleninhalte paginiert lesen.
|
||||
- Bevorzugt Keyset-Pagination über Primär- oder geeignete Unique Keys.
|
||||
- Begrenzte Offset-Pagination als Fallback mit sichtbarer Instabilitätswarnung.
|
||||
- Begrenzungen:
|
||||
- maximal 200 Tabellenzeilen pro Seite,
|
||||
- maximal 16 MiB gerenderte Daten pro Seite,
|
||||
- maximal 10.000 Offset-Zeilen.
|
||||
- Zeilen einfügen.
|
||||
- Zeilen aktualisieren.
|
||||
- Zeilen löschen.
|
||||
- Insert/Update-Felder unterscheiden:
|
||||
- unverändert,
|
||||
- Default,
|
||||
- NULL,
|
||||
- Textwert.
|
||||
- Werte werden parameterisiert übertragen; keine Benutzerwerte dürfen in SQL interpoliert werden.
|
||||
- Generated- und IDENTITY ALWAYS-Spalten sind nicht regulär editierbar.
|
||||
- Update/Delete sollen bevorzugt Primär- oder eindeutige Schlüssel verwenden.
|
||||
- Ohne geeigneten Schlüssel soll ein kurzlebiger ctid-/xmin-Fallback mit sichtbarer Warnung verwendet werden.
|
||||
- Zeilenmutationen müssen genau eine Zeile betreffen; null oder mehrere betroffene Zeilen sind ein Konflikt und dürfen nicht stillschweigend erfolgreich sein.
|
||||
- Jede Zeilenänderung benötigt eine Bestätigung.
|
||||
Rollen, Benutzer und Berechtigungen
|
||||
- Rollen und Rollenattribute auflisten.
|
||||
- Login- und Gruppenrollen anzeigen.
|
||||
- Direkte und effektive Rechte getrennt anzeigen.
|
||||
- Mitgliedschaften und Admin-Optionen anzeigen.
|
||||
- Default Privileges anzeigen.
|
||||
- Neue Login-Rollen erstellen.
|
||||
- Sichere Standardattribute für neue Login-Rollen:
|
||||
- kein Superuser,
|
||||
- kein CREATEDB,
|
||||
- kein CREATEROLE,
|
||||
- keine Replication,
|
||||
- kein BYPASSRLS,
|
||||
- INHERIT.
|
||||
- Rollenpasswörter erstellen oder zurücksetzen.
|
||||
- Bestehende Passwörter niemals anzeigen oder wiederherstellen.
|
||||
- Neue oder zurückgesetzte Zugangsdaten:
|
||||
- Benutzername und Passwort als Einmal-Credential,
|
||||
- genau einmal aus der Anwendung kopierbar,
|
||||
- zeitlich begrenzt,
|
||||
- danach aus dem kontrollierbaren Speicher entfernen.
|
||||
- Clipboard-Schreiben soll das One-Time-Secret auch bei einem Clipboard-Fehler konsumieren.
|
||||
- Rollenmitgliedschaften gewähren und entziehen, einschließlich Admin Option.
|
||||
- Rollen löschen, wenn PostgreSQL dies ohne implizite Besitzübernahme oder Kaskade erlaubt.
|
||||
- Kein DROP OWNED, kein REASSIGN OWNED, keine automatische Ownership-Übernahme und keine implizite Abhängigkeitsbereinigung.
|
||||
- Rechte innerhalb dieser Matrix verwalten:
|
||||
- Database: CONNECT, CREATE
|
||||
- Schema: USAGE, CREATE
|
||||
- Table: SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER
|
||||
- Sequence: USAGE, SELECT, UPDATE
|
||||
- Function: EXECUTE
|
||||
- Rechte gewähren und entziehen.
|
||||
- Default Privileges für zukünftige Tabellen, Sequenzen und Funktionen verwalten.
|
||||
- Mehrdatenbank-Berechtigungspläne:
|
||||
- vorab als vollständiger Plan anzeigen,
|
||||
- alle Ziel-Datenbanken und Schritte anzeigen,
|
||||
- pro Datenbank transaktional ausführen,
|
||||
- Erfolg, Fehler und übersprungene Schritte separat darstellen,
|
||||
- keine vorgetäuschte globale Atomarität,
|
||||
- keine automatische Kompensation bereits erfolgreicher Datenbanken.
|
||||
- Nicht grantierbare Berechtigungen mit Grund anzeigen und deaktivieren.
|
||||
SQL-Arbeitsbereich
|
||||
- Mehrzeiliger SQL-Editor.
|
||||
- Ausführung einzelner vollständiger Statements oder ganzer SQL-Batches.
|
||||
- Unterstützung mehrerer Statements und mehrerer Resultsets.
|
||||
- Keine SQL-Allowlist oder künstliche SQL-Sandbox.
|
||||
- Raw SQL wird mit den Rechten der verbundenen PostgreSQL-Rolle ausgeführt.
|
||||
- Jede Raw-SQL-Ausführung benötigt eine explizite Bestätigung.
|
||||
- Bestätigung zeigt Zielkontext, Datenbank, Rolle, Statement-/Batch-Information und SQL-Vorschau.
|
||||
- SQL soll nicht umgeschrieben werden.
|
||||
- Statement-Splitting muss Strings, Escape-Strings, quoted identifiers, Dollar-Quotes, Zeilenkommentare und verschachtelte Blockkommentare korrekt berücksichtigen.
|
||||
- SQL-Fehler sollen keine sensiblen Serverdetails, Parameter oder Secrets anzeigen.
|
||||
- Resultate sollen gestreamt und begrenzt dargestellt werden:
|
||||
- maximal 1.000 Zeilen,
|
||||
- maximal 16 MiB gerenderte Resultatdaten,
|
||||
- maximal 64 KiB je Zelle,
|
||||
- sichtbare Kennzeichnung ausgelassener und gekürzter Daten.
|
||||
- Command Tags, Laufzeit, betroffene Zeilen, Resultsets und sichere SQLSTATE-Diagnosen anzeigen.
|
||||
- Lang laufende SQL-Anweisungen abbrechen können.
|
||||
- PostgreSQL-Cancel-Protokoll verwenden.
|
||||
- Keine falsche Erfolgsmeldung nach Cancel, Timeout oder Verbindungsverlust.
|
||||
- Katalog nach erfolgreicher freier SQL-Ausführung konservativ invalidieren.
|
||||
- Keine persistente SQL-History.
|
||||
- Keine persistente Speicherung von Resultsets oder SQL-Inhalten.
|
||||
Transaktionen
|
||||
- Explizite Transaktionen unterstützen:
|
||||
- BEGIN,
|
||||
- START TRANSACTION,
|
||||
- COMMIT,
|
||||
- ROLLBACK,
|
||||
- Savepoints,
|
||||
- Release Savepoint,
|
||||
- Rollback To Savepoint.
|
||||
- Workspace-Session bleibt für eine offene Transaktion gepinnt.
|
||||
- Transaktionszustand soll serverautoritativer Zustand sein, nicht nur aus SQL-Text abgeleitet.
|
||||
- Kein automatischer Commit.
|
||||
- Commit soll separat bestätigt werden, wenn Änderungen dauerhaft werden.
|
||||
- Bei Fehlern, Cancel oder Verbindungsverlust soll der Zustand als fehlgeschlagen oder unbekannt behandelt werden, nicht fälschlich als idle.
|
||||
- Bei aktiver oder unklarer Transaktion müssen Datenbank-, Profil- oder Navigationswechsel blockiert werden, bis Commit, Rollback oder expliziter Disconnect gewählt wurde.
|
||||
- Beim Beenden: Commit, Rollback oder Navigation abbrechen anbieten; niemals stillschweigend committen.
|
||||
SQL-Completion
|
||||
- Kontextbezogene Completion für:
|
||||
- SQL-Keywords,
|
||||
- Schemas,
|
||||
- Tabellen,
|
||||
- Spalten,
|
||||
- Funktionen,
|
||||
- Rollen.
|
||||
- Completion aus aktuellem PostgreSQL-Katalog.
|
||||
- Keine Completion innerhalb von Strings, Kommentaren oder Dollar-Quotes.
|
||||
- Completion nur auf Basis einer aktuellen Kataloggeneration.
|
||||
- Completion soll unbekannte PostgreSQL-/Extension-Syntax nicht blockieren.
|
||||
- Begrenzung auf maximal 100 Vorschläge.
|
||||
COPY
|
||||
- Unterstützt ausschließlich:
|
||||
- COPY ... FROM STDIN,
|
||||
- COPY ... TO STDOUT.
|
||||
- Nur lokale reguläre Dateien auf dem Client.
|
||||
- Keine serverseitigen Dateipfade.
|
||||
- Kein COPY PROGRAM.
|
||||
- Keine Pipes.
|
||||
- Keine URLs.
|
||||
- Kein Cloud/Object Storage.
|
||||
- COPY FROM:
|
||||
- ausgewählte lokale Datei,
|
||||
- Streaming mit Backpressure,
|
||||
- keine vollständige Datei im Speicher.
|
||||
- COPY TO:
|
||||
- explizit ausgewählte lokale Zieldatei,
|
||||
- temporäre Datei im Zielverzeichnis,
|
||||
- erst nach vollständigem Erfolg atomar veröffentlichen,
|
||||
- vorhandenes Ziel nur nach separater Überschreibbestätigung,
|
||||
- exakte kanonische Pfadbestätigung für Überschreiben,
|
||||
- bei Fehler/Cancel keine teilweise veröffentlichte Zieldatei,
|
||||
- temporäre Ausgabe bestmöglich entfernen.
|
||||
- COPY muss in die aktive SQL-Transaktion integriert sein.
|
||||
- COPY-Bytes und Dateiinhalte dürfen nicht in Diagnosen, Events oder persistente Historie gelangen.
|
||||
- COPY-Fortschritt soll sichtbar sein.
|
||||
Bestätigungen und Sicherheitsmodell
|
||||
- Jede extern wirksame schreibende Aktion benötigt eine Bestätigung:
|
||||
- Azure-Datenbank Create/Drop,
|
||||
- Profile Create/Edit/Delete,
|
||||
- Keychain-Schreiben/-Löschen,
|
||||
- Tabellen-, Spalten- und Constraint-DDL,
|
||||
- Zeilen Insert/Update/Delete,
|
||||
- Rollen- und Passwortoperationen,
|
||||
- Mitgliedschaften,
|
||||
- Grants/Revokes,
|
||||
- Default Privileges,
|
||||
- jede Raw-SQL-Ausführung,
|
||||
- COPY,
|
||||
- lokale Überschreibvorgänge,
|
||||
- Clipboard-Credential-Schreibvorgänge.
|
||||
- Bestätigungen sind einmalig, nicht wiederverwendbar und an Ziel, Plan, Kontext, Session, Kataloggeneration, SQL-Text, Formularwerte und Datei-/Pfadbindung gebunden.
|
||||
- Destruktive Aktionen benötigen zusätzlich die exakte kanonische Zielbezeichnung:
|
||||
- Datenbank,
|
||||
- Tabelle,
|
||||
- Spalte,
|
||||
- Constraint,
|
||||
- Rolle,
|
||||
- Profil,
|
||||
- bestehende COPY-Zieldatei.
|
||||
- Kein Adapter-Schreibpfad darf die zentrale Bestätigungsrichtlinie umgehen.
|
||||
- Azure CLI immer ohne Shell und ohne globales az account set.
|
||||
- Secrets nie in:
|
||||
- Profildateien,
|
||||
- Events,
|
||||
- Plänen,
|
||||
- Logs,
|
||||
- Diagnostik,
|
||||
- SQL-Historie,
|
||||
- Klartext-Umgebungsvariablen.
|
||||
- TLS ist verpflichtend:
|
||||
- Zertifikatsprüfung,
|
||||
- Hostnamenprüfung,
|
||||
- System-Trust,
|
||||
- optional lokale zusätzliche CA,
|
||||
- kein Trust-All,
|
||||
- kein TLS-Bypass,
|
||||
- keine Klartextverbindung.
|
||||
- Geführte Datenwerte immer parameterisiert.
|
||||
- Identifikatoren immer segmentweise validiert und gequotet.
|
||||
- Freie SQL-/DDL-Ausdrücke sichtbar als SQL-Ausdrücke behandeln und vollständig bestätigen.
|
||||
- Kein implizites CASCADE.
|
||||
- Keine automatische Wiederholung von unklaren oder nicht-idempotenten Schreibvorgängen.
|
||||
- Keine automatische Kompensation erfolgreicher Azure- oder PostgreSQL-Fachmutationen.
|
||||
- Keine falsche Erfolgsmeldung bei Timeout, Cancellation, Connection Loss oder Azure-Propagation.
|
||||
Betriebs-, UI- und Release-Anforderungen
|
||||
- Reaktionsfähige TUI; Rendering und Tastatureingabe dürfen nicht auf Azure-, PostgreSQL-, Keychain-, Clipboard- oder Dateisystem-I/O warten.
|
||||
- Begrenzte Event-Kanäle mit Backpressure.
|
||||
- Stale Events anhand von Operation-ID, Ziel-/Session-/Kataloggeneration verwerfen.
|
||||
- Permanente Anzeige von Tenant, Subscription, Server, Datenbank, Profil, PostgreSQL-Benutzer, TLS-Status, Kataloggeneration und Transaktionsstatus.
|
||||
- Screens für:
|
||||
- Server,
|
||||
- Datenbanken,
|
||||
- Profile/Connect,
|
||||
- Tabellen,
|
||||
- Rollen/Rechte,
|
||||
- SQL/COPY,
|
||||
- Diagnostics.
|
||||
- Sichere Diagnostik mit Operation-ID, Kategorie, Retry-Hinweis und SQLSTATE, falls verfügbar.
|
||||
- Release ausschließlich für macOS ARM64 und Windows x86_64.
|
||||
- Gitea Actions:
|
||||
- GitHub-kompatible Syntax,
|
||||
- Repository-Variablen für Runner/API-Konfiguration,
|
||||
- Secrets für Release-Credentials,
|
||||
- native Validierung,
|
||||
- Tests,
|
||||
- Builds,
|
||||
- versionsbasierte Releases,
|
||||
- beide Zielartefakte,
|
||||
- Prüfsummen,
|
||||
- Release erst bei vollständiger Zielmatrix.
|
||||
- Keine Release-Assets eines bereits veröffentlichten Releases still ersetzen.
|
||||
- Keine Signierung oder Notarisierung im aktuellen Scope.
|
||||
Nicht im Scope
|
||||
- Flexible Server Lifecycle, Skalierung, Firewall, DNS, Private Endpoints, Azure RBAC, Backups, Restore oder PITR.
|
||||
- Andere Datenbankengines oder PostgreSQL-Angebote.
|
||||
- Datenbank Rename, Clone oder andere Eigenschaften außerhalb List/Create/Drop.
|
||||
- Guided CRUD für Views, Materialized Views, Funktionen, Trigger, standalone Indizes, Extensions oder RLS-Policies.
|
||||
- Rechte außerhalb der bestätigten Matrix.
|
||||
- Server-/Programm-/Remote-/Cloud-COPY.
|
||||
- Verteilte Transaktionen über mehrere Datenbanken oder Azure plus PostgreSQL.
|
||||
- Persistente SQL-History, Secret-Synchronisierung, cloudbasierte Profile.
|
||||
- Automatische Rücknahme bereits bestätigter und erfolgreicher Änderungen.
|
||||
- Linux-Releases, Weboberfläche, Daemon, Telemetrie, Code-Signing oder Notarisierung.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Operations
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Azure CLI and authenticate to the desired tenant before launching the
|
||||
TUI. The application reads the tenant from `az account show`, filters
|
||||
subscriptions to that tenant and to the `Enabled` state, and explicitly passes
|
||||
each subscription to Azure CLI. It never runs `az account set`.
|
||||
|
||||
Network routing, DNS, private-endpoint connectivity and firewall access remain
|
||||
environmental prerequisites. A discovered server may still reject a PostgreSQL
|
||||
connection.
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles live in the platform application configuration directory as
|
||||
`profiles.json`. The file contains no password. On macOS and Windows the TUI
|
||||
can reference a system-keychain secret; otherwise a password is session-only.
|
||||
An unsupported future profile-file format is rejected without overwriting it.
|
||||
|
||||
## Dependency lock
|
||||
|
||||
`Cargo.lock` is part of the source of truth. Use locked Cargo commands. If the
|
||||
manifest changes, regenerate the lockfile through the controlled maintainer
|
||||
dependency-resolution process before running CI or release jobs. CI does not
|
||||
repair or regenerate a lockfile.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Release process
|
||||
|
||||
The release pipeline publishes only Gitea release assets for macOS ARM64 and
|
||||
Windows x86_64. Binaries are not signed or notarized in this release stage.
|
||||
|
||||
## Required repository configuration
|
||||
|
||||
Variables:
|
||||
|
||||
- `MACOS_ARM64_RUNNER_LABEL` — label for a native `aarch64-apple-darwin` host.
|
||||
- `WINDOWS_X86_64_RUNNER_LABEL` — label for a native `x86_64-pc-windows-msvc` host.
|
||||
- `GITEA_API_URL` — base REST endpoint ending with `/api/v1`.
|
||||
- `GITEA_REPOSITORY` — `owner/repository`.
|
||||
|
||||
Secret:
|
||||
|
||||
- `GITEA_RELEASE_TOKEN` — least-privilege token for release creation/editing
|
||||
and asset upload.
|
||||
|
||||
Tags must equal the Cargo package version or `v` plus that version. Both native
|
||||
archives and both `.sha256` files must exist before publication. Existing assets
|
||||
are never replaced; an already published incomplete release fails safely.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Security model
|
||||
|
||||
Azure CLI discovers management-plane resources only. It does **not** grant
|
||||
PostgreSQL data-plane access. PostgreSQL connections use a separate username
|
||||
and password and must use certificate and hostname validated TLS.
|
||||
|
||||
## Secrets
|
||||
|
||||
- Connection profiles store metadata and an optional system-keychain reference;
|
||||
they never store a password.
|
||||
- A missing or unavailable keychain requires password entry for the current
|
||||
session. There is no clear-text fallback file or environment-variable store.
|
||||
- Existing PostgreSQL passwords cannot be recovered. Only a newly created or
|
||||
reset password can be offered once for copying.
|
||||
- The one-time copy action consumes the application-held credential regardless
|
||||
of whether the platform clipboard write succeeds. Clipboard history and other
|
||||
applications are outside the application's control.
|
||||
|
||||
## SQL and administration
|
||||
|
||||
The SQL workspace intentionally sends PostgreSQL SQL without an allowlist.
|
||||
The connected PostgreSQL role is the authorization boundary. Guided inserts use
|
||||
parameters and guided table deletion requires an exact qualified-name
|
||||
confirmation without `CASCADE`.
|
||||
|
||||
PostgreSQL requires a SQL password literal for role-password DDL. The role
|
||||
service therefore owns a narrow, zeroizing literal renderer used only for role
|
||||
creation and password reset. The application cannot control server-side audit
|
||||
logging or driver-internal transport buffers.
|
||||
+190
-2
@@ -1,2 +1,190 @@
|
||||
pub struct AzureDiscovery;
|
||||
pub struct AzureCliAdapter;
|
||||
use crate::{
|
||||
adapters::process::{CommandRunner, CommandSpec, KnownProgram},
|
||||
domain::{
|
||||
ids::{AzureResourceId, OperationId, SubscriptionId, TenantId},
|
||||
inventory::{DatabaseRef, FlexibleServer, Subscription, Tenant},
|
||||
},
|
||||
error::{AppError, SafeError},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{ffi::OsString, sync::Arc, time::Duration};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub struct AzureCliAdapter {
|
||||
runner: Arc<dyn CommandRunner>,
|
||||
}
|
||||
impl AzureCliAdapter {
|
||||
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||
Self { runner }
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
args: &[&str],
|
||||
op: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let mut all: Vec<OsString> = args.iter().map(OsString::from).collect();
|
||||
all.extend(
|
||||
["--only-show-errors", "--output", "json"]
|
||||
.iter()
|
||||
.map(OsString::from),
|
||||
);
|
||||
Ok(self
|
||||
.runner
|
||||
.run(
|
||||
CommandSpec {
|
||||
program: KnownProgram::AzureCli,
|
||||
args: all,
|
||||
timeout: Duration::from_secs(30),
|
||||
stdout_limit_bytes: 2 * 1024 * 1024,
|
||||
stderr_limit_bytes: 64 * 1024,
|
||||
operation_id: op,
|
||||
},
|
||||
cancellation,
|
||||
)
|
||||
.await?
|
||||
.stdout)
|
||||
}
|
||||
pub async fn current_tenant(
|
||||
&self,
|
||||
op: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Tenant, AppError> {
|
||||
let raw = self.call(&["account", "show"], op, cancellation).await?;
|
||||
let dto: AccountDto =
|
||||
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||
Ok(Tenant {
|
||||
id: TenantId::new(dto.tenant_id).map_err(|_| AppError(SafeError::process()))?,
|
||||
display_name: dto.name.unwrap_or_else(|| "Current tenant".to_owned()),
|
||||
})
|
||||
}
|
||||
pub async fn subscriptions(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
op: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Vec<Subscription>, AppError> {
|
||||
let raw = self
|
||||
.call(&["account", "list", "--all"], op, cancellation)
|
||||
.await?;
|
||||
let dtos: Vec<AccountDto> =
|
||||
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||
Ok(dtos
|
||||
.into_iter()
|
||||
.filter(|d| d.tenant_id == tenant.as_str() && d.state.as_deref() == Some("Enabled"))
|
||||
.filter_map(|d| {
|
||||
Some(Subscription {
|
||||
id: SubscriptionId::new(d.id?).ok()?,
|
||||
display_name: d.name.unwrap_or_else(|| "Unnamed subscription".to_owned()),
|
||||
tenant_id: tenant.clone(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
pub async fn servers(
|
||||
&self,
|
||||
subscription: &Subscription,
|
||||
op: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Vec<FlexibleServer>, AppError> {
|
||||
let raw = self
|
||||
.call(
|
||||
&[
|
||||
"postgres",
|
||||
"flexible-server",
|
||||
"list",
|
||||
"--subscription",
|
||||
subscription.id.as_str(),
|
||||
],
|
||||
op,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
let dtos: Vec<ServerDto> =
|
||||
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||
dtos.into_iter()
|
||||
.map(|d| {
|
||||
let id = AzureResourceId::new(d.id).map_err(|_| AppError(SafeError::process()))?;
|
||||
let group =
|
||||
resource_group(id.as_str()).ok_or_else(|| AppError(SafeError::process()))?;
|
||||
Ok(FlexibleServer {
|
||||
resource_id: id,
|
||||
subscription_id: subscription.id.clone(),
|
||||
resource_group: group,
|
||||
name: d.name,
|
||||
host: d.fully_qualified_domain_name,
|
||||
location: d.location.unwrap_or_default(),
|
||||
version: d.version,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub async fn databases(
|
||||
&self,
|
||||
server: &FlexibleServer,
|
||||
op: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<Vec<DatabaseRef>, AppError> {
|
||||
let raw = self
|
||||
.call(
|
||||
&[
|
||||
"postgres",
|
||||
"flexible-server",
|
||||
"db",
|
||||
"list",
|
||||
"--resource-group",
|
||||
&server.resource_group,
|
||||
"--server-name",
|
||||
&server.name,
|
||||
"--subscription",
|
||||
server.subscription_id.as_str(),
|
||||
],
|
||||
op,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
let dtos: Vec<DatabaseDto> =
|
||||
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||
dtos.into_iter()
|
||||
.map(|d| {
|
||||
let name = d.name;
|
||||
Ok(DatabaseRef {
|
||||
id: crate::domain::ids::DatabaseId::new(name.clone())
|
||||
.map_err(|_| AppError(SafeError::process()))?,
|
||||
server_id: crate::domain::ids::ServerId::new(server.resource_id.as_str())
|
||||
.map_err(|_| AppError(SafeError::process()))?,
|
||||
system: name == "postgres" || name.starts_with("azure_"),
|
||||
name,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountDto {
|
||||
id: Option<String>,
|
||||
tenant_id: String,
|
||||
name: Option<String>,
|
||||
state: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServerDto {
|
||||
id: String,
|
||||
name: String,
|
||||
fully_qualified_domain_name: Option<String>,
|
||||
location: Option<String>,
|
||||
version: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct DatabaseDto {
|
||||
name: String,
|
||||
}
|
||||
fn resource_group(id: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = id.split('/').collect();
|
||||
parts
|
||||
.windows(2)
|
||||
.find(|w| w[0].eq_ignore_ascii_case("resourceGroups"))
|
||||
.map(|w| w[1].to_owned())
|
||||
}
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
pub mod process; pub mod azure_cli; pub mod postgres;
|
||||
pub mod azure_cli;
|
||||
pub mod postgres;
|
||||
pub mod process;
|
||||
|
||||
+101
-2
@@ -1,3 +1,102 @@
|
||||
#[derive(Debug, Clone)] pub struct CommandSpec;
|
||||
pub trait CommandRunner {}
|
||||
use crate::domain::ids::OperationId;
|
||||
use crate::error::{AppError, SafeError};
|
||||
use async_trait::async_trait;
|
||||
use std::{ffi::OsString, time::Duration};
|
||||
use tokio::{io::AsyncReadExt, process::Command};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KnownProgram {
|
||||
AzureCli,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandSpec {
|
||||
pub program: KnownProgram,
|
||||
pub args: Vec<OsString>,
|
||||
pub timeout: Duration,
|
||||
pub stdout_limit_bytes: usize,
|
||||
pub stderr_limit_bytes: usize,
|
||||
pub operation_id: OperationId,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
pub success: bool,
|
||||
}
|
||||
#[async_trait]
|
||||
pub trait CommandRunner: Send + Sync {
|
||||
async fn run(
|
||||
&self,
|
||||
spec: CommandSpec,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<CommandOutput, AppError>;
|
||||
}
|
||||
pub struct TokioCommandRunner;
|
||||
#[async_trait]
|
||||
impl CommandRunner for TokioCommandRunner {
|
||||
async fn run(
|
||||
&self,
|
||||
spec: CommandSpec,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<CommandOutput, AppError> {
|
||||
let executable = match spec.program {
|
||||
KnownProgram::AzureCli => "az",
|
||||
};
|
||||
let mut child = Command::new(executable)
|
||||
.args(&spec.args)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| AppError(SafeError::process()))?;
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AppError(SafeError::process()))?;
|
||||
let mut stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| AppError(SafeError::process()))?;
|
||||
let stdout_task =
|
||||
tokio::spawn(async move { bounded_read(&mut stdout, spec.stdout_limit_bytes).await });
|
||||
let stderr_limit = spec.stderr_limit_bytes;
|
||||
let stderr_task =
|
||||
tokio::spawn(async move { bounded_read(&mut stderr, stderr_limit).await });
|
||||
let status = tokio::select! {
|
||||
_ = cancellation.cancelled() => { let _ = child.kill().await; let _ = child.wait().await; return Err(AppError(SafeError::input("Operation cancelled"))); }
|
||||
value = tokio::time::timeout(spec.timeout, child.wait()) => value.map_err(|_| AppError(SafeError::process()))?.map_err(|_| AppError(SafeError::process()))?
|
||||
};
|
||||
let stdout = stdout_task
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::process()))??;
|
||||
let _stderr = stderr_task
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::process()))??;
|
||||
if !status.success() {
|
||||
return Err(AppError(SafeError::process()));
|
||||
}
|
||||
Ok(CommandOutput {
|
||||
stdout,
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
async fn bounded_read(
|
||||
reader: &mut (impl AsyncReadExt + Unpin),
|
||||
limit: usize,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::process()))?;
|
||||
if n == 0 {
|
||||
return Ok(data);
|
||||
}
|
||||
if data.len().saturating_add(n) > limit {
|
||||
return Err(AppError(SafeError::process()));
|
||||
}
|
||||
data.extend_from_slice(&buffer[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
+219
-3
@@ -1,3 +1,219 @@
|
||||
use crate::error::AppError;
|
||||
pub struct AppController;
|
||||
impl AppController { pub async fn new() -> Result<Self, AppError> { Err(AppError(crate::error::SafeError { kind: crate::error::ErrorKind::Internal, message: "Application unavailable", sqlstate: None, severity: crate::error::ErrorSeverity::Error, retryable: false, operation_id: None })) } pub async fn run(&mut self) -> Result<(), AppError> { Ok(()) } }
|
||||
use crate::{
|
||||
adapters::{azure_cli::AzureCliAdapter, process::TokioCommandRunner},
|
||||
app::{events::AppEvent, state::AppState},
|
||||
domain::{ids::OperationId, inventory::DiscoveryFailure},
|
||||
error::{AppError, SafeError},
|
||||
tui::TerminalGuard,
|
||||
};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use futures_util::{StreamExt, stream::FuturesUnordered};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub struct AppController {
|
||||
state: AppState,
|
||||
events: mpsc::UnboundedReceiver<AppEvent>,
|
||||
sender: mpsc::UnboundedSender<AppEvent>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
impl AppController {
|
||||
pub async fn new() -> Result<Self, AppError> {
|
||||
let (sender, events) = mpsc::unbounded_channel();
|
||||
Ok(Self {
|
||||
state: AppState::new(),
|
||||
events,
|
||||
sender,
|
||||
cancellation: CancellationToken::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), AppError> {
|
||||
let mut terminal = TerminalGuard::enter().map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Azure Database TUI could not start",
|
||||
))
|
||||
})?;
|
||||
self.start_discovery();
|
||||
loop {
|
||||
self.drain_events();
|
||||
terminal
|
||||
.draw(&self.state)
|
||||
.map_err(|_| AppError(SafeError::configuration("Terminal rendering failed")))?;
|
||||
if event::poll(Duration::from_millis(100))
|
||||
.map_err(|_| AppError(SafeError::configuration("Terminal input failed")))?
|
||||
{
|
||||
if let Event::Key(key) = event::read()
|
||||
.map_err(|_| AppError(SafeError::configuration("Terminal input failed")))?
|
||||
{
|
||||
if key.kind == KeyEventKind::Press {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Char('r') => self.start_discovery(),
|
||||
KeyCode::Down => self.state.select_next(),
|
||||
KeyCode::Up => self.state.select_previous(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cancellation.cancel();
|
||||
terminal.restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_discovery(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
self.cancellation = CancellationToken::new();
|
||||
let operation_id = OperationId::new();
|
||||
self.state.current_operation = Some(operation_id);
|
||||
self.state.servers.clear();
|
||||
self.state.failures.clear();
|
||||
self.state.status = "Discovering Azure Flexible Servers…".to_owned();
|
||||
let sender = self.sender.clone();
|
||||
let cancellation = self.cancellation.clone();
|
||||
tokio::spawn(async move {
|
||||
discover(sender, operation_id, cancellation).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn drain_events(&mut self) {
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
match event {
|
||||
AppEvent::DiscoveryStarted { operation_id }
|
||||
if self.state.current_operation == Some(operation_id) =>
|
||||
{
|
||||
self.state.status = "Reading Azure subscriptions…".to_owned()
|
||||
}
|
||||
AppEvent::TenantLoaded {
|
||||
operation_id,
|
||||
tenant,
|
||||
subscriptions,
|
||||
} if self.state.current_operation == Some(operation_id) => {
|
||||
self.state.tenant = Some(tenant);
|
||||
self.state.subscriptions = subscriptions;
|
||||
self.state.status = "Discovering Flexible Servers…".to_owned();
|
||||
}
|
||||
AppEvent::ServerLoaded {
|
||||
operation_id,
|
||||
server,
|
||||
} if self.state.current_operation == Some(operation_id) => {
|
||||
self.state.servers.push(server)
|
||||
}
|
||||
AppEvent::SubscriptionFailed {
|
||||
operation_id,
|
||||
failure,
|
||||
} if self.state.current_operation == Some(operation_id) => {
|
||||
self.state.failures.push(failure)
|
||||
}
|
||||
AppEvent::DiscoveryFinished { operation_id }
|
||||
if self.state.current_operation == Some(operation_id) =>
|
||||
{
|
||||
self.state.status = "Discovery complete".to_owned()
|
||||
}
|
||||
AppEvent::Failed {
|
||||
operation_id,
|
||||
error,
|
||||
} if self.state.current_operation == Some(operation_id) => {
|
||||
self.state.last_error = Some(error);
|
||||
self.state.status = "Discovery failed".to_owned();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.state.servers.sort_by(|left, right| {
|
||||
left.name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&right.name.to_ascii_lowercase())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover(
|
||||
sender: mpsc::UnboundedSender<AppEvent>,
|
||||
operation_id: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) {
|
||||
let _ = sender.send(AppEvent::DiscoveryStarted { operation_id });
|
||||
let adapter = Arc::new(AzureCliAdapter::new(Arc::new(TokioCommandRunner)));
|
||||
let tenant = match adapter
|
||||
.current_tenant(operation_id, cancellation.clone())
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let _ = sender.send(AppEvent::Failed {
|
||||
operation_id,
|
||||
error: error.0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let subscriptions = match adapter
|
||||
.subscriptions(&tenant.id, operation_id, cancellation.clone())
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let _ = sender.send(AppEvent::Failed {
|
||||
operation_id,
|
||||
error: error.0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = sender.send(AppEvent::TenantLoaded {
|
||||
operation_id,
|
||||
tenant,
|
||||
subscriptions: subscriptions.clone(),
|
||||
});
|
||||
let mut jobs = FuturesUnordered::new();
|
||||
for subscription in subscriptions {
|
||||
let adapter = adapter.clone();
|
||||
let child = cancellation.clone();
|
||||
jobs.push(async move {
|
||||
let id = subscription.id.clone();
|
||||
let result = adapter.servers(&subscription, operation_id, child).await;
|
||||
(id, result)
|
||||
});
|
||||
if jobs.len() == 4 {
|
||||
if let Some((subscription_id, result)) = jobs.next().await {
|
||||
send_server_result(&sender, operation_id, subscription_id, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some((subscription_id, result)) = jobs.next().await {
|
||||
send_server_result(&sender, operation_id, subscription_id, result);
|
||||
}
|
||||
let _ = sender.send(AppEvent::DiscoveryFinished { operation_id });
|
||||
}
|
||||
|
||||
fn send_server_result(
|
||||
sender: &mpsc::UnboundedSender<AppEvent>,
|
||||
operation_id: OperationId,
|
||||
subscription_id: crate::domain::ids::SubscriptionId,
|
||||
result: Result<Vec<crate::domain::inventory::FlexibleServer>, AppError>,
|
||||
) {
|
||||
match result {
|
||||
Ok(servers) => {
|
||||
for server in servers {
|
||||
let _ = sender.send(AppEvent::ServerLoaded {
|
||||
operation_id,
|
||||
server,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = sender.send(AppEvent::SubscriptionFailed {
|
||||
operation_id,
|
||||
failure: DiscoveryFailure {
|
||||
subscription_id,
|
||||
operation_id,
|
||||
error: error.0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-2
@@ -1,2 +1,40 @@
|
||||
#[derive(Debug, Clone)] pub struct AppCommand;
|
||||
#[derive(Debug, Clone)] pub struct AppEvent;
|
||||
use crate::{
|
||||
domain::{
|
||||
ids::OperationId,
|
||||
inventory::{DiscoveryFailure, FlexibleServer, Subscription, Tenant},
|
||||
},
|
||||
error::SafeError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum AppCommand {
|
||||
RefreshDiscovery,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
DiscoveryStarted {
|
||||
operation_id: OperationId,
|
||||
},
|
||||
TenantLoaded {
|
||||
operation_id: OperationId,
|
||||
tenant: Tenant,
|
||||
subscriptions: Vec<Subscription>,
|
||||
},
|
||||
ServerLoaded {
|
||||
operation_id: OperationId,
|
||||
server: FlexibleServer,
|
||||
},
|
||||
SubscriptionFailed {
|
||||
operation_id: OperationId,
|
||||
failure: DiscoveryFailure,
|
||||
},
|
||||
DiscoveryFinished {
|
||||
operation_id: OperationId,
|
||||
},
|
||||
Failed {
|
||||
operation_id: OperationId,
|
||||
error: SafeError,
|
||||
},
|
||||
}
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
pub mod controller; pub mod events; pub mod state;
|
||||
pub mod controller;
|
||||
pub mod events;
|
||||
pub mod state;
|
||||
|
||||
+55
-1
@@ -1 +1,55 @@
|
||||
#[derive(Debug, Clone)] pub struct AppState;
|
||||
use crate::{
|
||||
domain::{
|
||||
ids::OperationId,
|
||||
inventory::{DiscoveryFailure, FlexibleServer, Subscription, Tenant},
|
||||
},
|
||||
error::SafeError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
Servers,
|
||||
Databases,
|
||||
Tables,
|
||||
Roles,
|
||||
Sql,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppState {
|
||||
pub screen: Option<Screen>,
|
||||
pub current_operation: Option<OperationId>,
|
||||
pub tenant: Option<Tenant>,
|
||||
pub subscriptions: Vec<Subscription>,
|
||||
pub servers: Vec<FlexibleServer>,
|
||||
pub failures: Vec<DiscoveryFailure>,
|
||||
pub status: String,
|
||||
pub last_error: Option<SafeError>,
|
||||
pub selected_server: usize,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
screen: Some(Screen::Servers),
|
||||
status: "Starting Azure discovery…".to_owned(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.servers.is_empty() {
|
||||
self.selected_server = (self.selected_server + 1) % self.servers.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_previous(&mut self) {
|
||||
if !self.servers.is_empty() {
|
||||
self.selected_server = self
|
||||
.selected_server
|
||||
.checked_sub(1)
|
||||
.unwrap_or(self.servers.len() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-10
@@ -1,10 +1,77 @@
|
||||
#[derive(Debug, Clone)] pub struct CatalogSnapshot;
|
||||
#[derive(Debug, Clone)] pub struct SchemaMeta;
|
||||
#[derive(Debug, Clone)] pub struct RelationMeta;
|
||||
#[derive(Debug, Clone)] pub struct ColumnMeta;
|
||||
#[derive(Debug, Clone)] pub struct KeysetDefinition;
|
||||
#[derive(Debug, Clone)] pub struct FunctionMeta;
|
||||
#[derive(Debug, Clone)] pub struct RoleMeta;
|
||||
#[derive(Debug, Clone)] pub struct PrivilegeObservation;
|
||||
#[derive(Debug, Clone)] pub struct DefaultPrivilegeObservation;
|
||||
#[derive(Debug, Clone)] pub struct ServerCapabilities;
|
||||
use crate::domain::ids::{CatalogGeneration, RelationOid, RoleOid};
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogSnapshot {
|
||||
pub generation: CatalogGeneration,
|
||||
pub schemas: Vec<SchemaMeta>,
|
||||
pub relations: Vec<RelationMeta>,
|
||||
pub functions: Vec<FunctionMeta>,
|
||||
pub roles: Vec<RoleMeta>,
|
||||
pub capabilities: ServerCapabilities,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SchemaMeta {
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RelationMeta {
|
||||
pub oid: RelationOid,
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub columns: Vec<ColumnMeta>,
|
||||
pub keyset: Option<KeysetDefinition>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnMeta {
|
||||
pub name: String,
|
||||
pub type_sql: String,
|
||||
pub nullable: bool,
|
||||
pub generated: bool,
|
||||
pub identity_always: bool,
|
||||
pub has_default: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeysetDefinition {
|
||||
pub columns: Vec<String>,
|
||||
pub primary: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FunctionMeta {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub signature: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleMeta {
|
||||
pub oid: RoleOid,
|
||||
pub name: String,
|
||||
pub login: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleMembership {
|
||||
pub member: String,
|
||||
pub role: String,
|
||||
pub admin_option: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrivilegeObservation {
|
||||
pub object: String,
|
||||
pub privilege: String,
|
||||
pub grantee: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectivePrivilegeObservation {
|
||||
pub object: String,
|
||||
pub privilege: String,
|
||||
pub effective: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultPrivilegeObservation {
|
||||
pub owner: String,
|
||||
pub schema: Option<String>,
|
||||
pub privilege: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ServerCapabilities {
|
||||
pub major_version: u16,
|
||||
pub generated_columns: bool,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::domain::ids::CatalogGeneration;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompletionContext {
|
||||
General,
|
||||
Relation,
|
||||
Role,
|
||||
Column,
|
||||
Suppressed,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompletionItem {
|
||||
pub text: String,
|
||||
pub detail: &'static str,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompletionResponse {
|
||||
pub generation: CatalogGeneration,
|
||||
pub items: Vec<CompletionItem>,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::error::{AppError, SafeError};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QualifiedIdentifier(pub String);
|
||||
pub fn quote_identifier(value: &str) -> Result<String, AppError> {
|
||||
if value.contains('\0') || value.is_empty() {
|
||||
return Err(AppError(SafeError::input("Invalid database identifier")));
|
||||
}
|
||||
Ok(format!("\"{}\"", value.replace('"', "\"\"")))
|
||||
}
|
||||
pub fn quote_qualified(schema: &str, relation: &str) -> Result<QualifiedIdentifier, AppError> {
|
||||
Ok(QualifiedIdentifier(format!(
|
||||
"{}.{}",
|
||||
quote_identifier(schema)?,
|
||||
quote_identifier(relation)?
|
||||
)))
|
||||
}
|
||||
+78
-7
@@ -1,9 +1,80 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
macro_rules! text_id { ($name:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct $name(String); impl $name { pub fn new(value: impl Into<String>) -> Self { Self(value.into()) } pub fn as_str(&self) -> &str { &self.0 } } }; }
|
||||
text_id!(TenantId); text_id!(SubscriptionId); text_id!(AzureResourceId);
|
||||
text_id!(CatalogGeneration); text_id!(RoleOid); text_id!(RelationOid);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProfileId(Uuid);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct OperationId(Uuid);
|
||||
impl ProfileId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
|
||||
impl OperationId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
|
||||
macro_rules! text_id {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(String);
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, &'static str> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() || value.contains('\0') {
|
||||
return Err("identifier must not be empty or contain NUL");
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
text_id!(TenantId);
|
||||
text_id!(SubscriptionId);
|
||||
text_id!(AzureResourceId);
|
||||
text_id!(DatabaseId);
|
||||
text_id!(ServerId);
|
||||
text_id!(RoleName);
|
||||
text_id!(SchemaName);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ProfileId(Uuid);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct OperationId(Uuid);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct CatalogGeneration(pub u64);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RelationOid(pub u32);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RoleOid(pub u32);
|
||||
impl ProfileId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
impl Default for ProfileId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl OperationId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
impl Default for OperationId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl CatalogGeneration {
|
||||
pub fn next(self) -> Self {
|
||||
Self(self.0.saturating_add(1))
|
||||
}
|
||||
}
|
||||
impl fmt::Display for ProfileId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
impl fmt::Display for OperationId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-7
@@ -1,7 +1,58 @@
|
||||
#[derive(Debug, Clone)] pub struct Tenant;
|
||||
#[derive(Debug, Clone)] pub struct Subscription;
|
||||
#[derive(Debug, Clone)] pub struct FlexibleServer;
|
||||
#[derive(Debug, Clone)] pub struct DatabaseRef;
|
||||
#[derive(Debug, Clone)] pub struct DiscoveryFailure;
|
||||
#[derive(Debug, Clone)] pub struct DiscoveryReport;
|
||||
#[derive(Debug, Clone)] pub struct DiscoveryEvent;
|
||||
use crate::domain::ids::*;
|
||||
use crate::error::SafeError;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tenant {
|
||||
pub id: TenantId,
|
||||
pub display_name: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Subscription {
|
||||
pub id: SubscriptionId,
|
||||
pub display_name: String,
|
||||
pub tenant_id: TenantId,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlexibleServer {
|
||||
pub resource_id: AzureResourceId,
|
||||
pub subscription_id: SubscriptionId,
|
||||
pub resource_group: String,
|
||||
pub name: String,
|
||||
pub host: Option<String>,
|
||||
pub location: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabaseRef {
|
||||
pub id: DatabaseId,
|
||||
pub server_id: ServerId,
|
||||
pub name: String,
|
||||
pub system: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveryFailure {
|
||||
pub subscription_id: SubscriptionId,
|
||||
pub operation_id: OperationId,
|
||||
pub error: SafeError,
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscoveryReport {
|
||||
pub tenant: Option<Tenant>,
|
||||
pub subscriptions: Vec<Subscription>,
|
||||
pub servers: BTreeMap<String, FlexibleServer>,
|
||||
pub databases: BTreeMap<String, Vec<DatabaseRef>>,
|
||||
pub failures: Vec<DiscoveryFailure>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DiscoveryEvent {
|
||||
TenantFound(Tenant),
|
||||
SubscriptionStarted(SubscriptionId),
|
||||
ServerFound(FlexibleServer),
|
||||
DatabasesFound {
|
||||
server_id: ServerId,
|
||||
databases: Vec<DatabaseRef>,
|
||||
},
|
||||
SubscriptionFailed(DiscoveryFailure),
|
||||
Completed,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod catalog;
|
||||
pub mod completion;
|
||||
pub mod identifiers;
|
||||
pub mod ids;
|
||||
pub mod inventory;
|
||||
pub mod permissions;
|
||||
pub mod profile;
|
||||
pub mod sql;
|
||||
pub mod table;
|
||||
|
||||
@@ -1,8 +1,68 @@
|
||||
#[derive(Debug, Clone)] pub struct DesiredPrivilegeProfile;
|
||||
#[derive(Debug, Clone)] pub struct PrivilegeScope;
|
||||
#[derive(Debug, Clone)] pub struct PrivilegeKind;
|
||||
#[derive(Debug, Clone)] pub struct RoleMembershipIntent;
|
||||
#[derive(Debug, Clone)] pub struct Grantability;
|
||||
#[derive(Debug, Clone)] pub struct PlanOperation;
|
||||
#[derive(Debug, Clone)] pub struct PermissionPlan;
|
||||
#[derive(Debug, Clone)] pub struct PlanResult;
|
||||
use crate::domain::ids::{AzureResourceId, DatabaseId, RoleName};
|
||||
use crate::error::SafeError;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesiredPrivilegeProfile {
|
||||
pub role: RoleName,
|
||||
pub databases: Vec<DatabaseId>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PrivilegeScope {
|
||||
Database,
|
||||
Schema(String),
|
||||
Table { schema: String, table: String },
|
||||
Sequence { schema: String, sequence: String },
|
||||
Function { schema: String, function: String },
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrivilegeKind {
|
||||
Connect,
|
||||
Usage,
|
||||
Create,
|
||||
Select,
|
||||
Insert,
|
||||
Update,
|
||||
Delete,
|
||||
Truncate,
|
||||
References,
|
||||
Trigger,
|
||||
Execute,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleMembershipIntent {
|
||||
pub role: RoleName,
|
||||
pub grantable: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Grantability {
|
||||
Grantable,
|
||||
NotGrantable(&'static str),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanOperation {
|
||||
pub scope: PrivilegeScope,
|
||||
pub privilege: PrivilegeKind,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabasePermissionPlan {
|
||||
pub database: DatabaseId,
|
||||
pub operations: Vec<PlanOperation>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionPlan {
|
||||
pub target_server: AzureResourceId,
|
||||
pub target_role: RoleName,
|
||||
pub databases: Vec<DatabasePermissionPlan>,
|
||||
pub memberships: Vec<RoleMembershipIntent>,
|
||||
pub password_will_be_set: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlanStepResult {
|
||||
Succeeded,
|
||||
Failed(SafeError),
|
||||
Skipped(&'static str),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanResult {
|
||||
pub database: Option<DatabaseId>,
|
||||
pub steps: Vec<PlanStepResult>,
|
||||
}
|
||||
|
||||
+71
-6
@@ -1,6 +1,71 @@
|
||||
#[derive(Debug, Clone)] pub struct ProfileFile;
|
||||
#[derive(Debug, Clone)] pub struct ConnectionProfile;
|
||||
#[derive(Debug, Clone)] pub struct SecretReference;
|
||||
#[derive(Debug, Clone)] pub struct TlsPolicy;
|
||||
#[derive(Debug, Clone)] pub struct ConnectionDraft;
|
||||
#[derive(Debug, Clone)] pub struct ProfileValidationError;
|
||||
use crate::domain::ids::{AzureResourceId, ProfileId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const PROFILE_FORMAT_VERSION: u32 = 1;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileFile {
|
||||
pub format_version: u32,
|
||||
pub profiles: Vec<ConnectionProfile>,
|
||||
}
|
||||
impl Default for ProfileFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format_version: PROFILE_FORMAT_VERSION,
|
||||
profiles: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionProfile {
|
||||
pub id: ProfileId,
|
||||
pub azure_resource_id: AzureResourceId,
|
||||
pub server_host: String,
|
||||
pub port: u16,
|
||||
pub database: String,
|
||||
pub username: String,
|
||||
pub tls: TlsPolicy,
|
||||
pub secret_reference: Option<SecretReference>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretReference {
|
||||
pub service: String,
|
||||
pub account: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum TlsPolicy {
|
||||
SystemTrust,
|
||||
SystemTrustPlusCa { additional_ca_pem_path: PathBuf },
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionDraft {
|
||||
pub azure_resource_id: String,
|
||||
pub server_host: String,
|
||||
pub port: u16,
|
||||
pub database: String,
|
||||
pub username: String,
|
||||
pub tls: TlsPolicy,
|
||||
pub save_secret: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct ProfileValidationError(pub &'static str);
|
||||
impl ConnectionDraft {
|
||||
pub fn validate(mut self) -> Result<Self, ProfileValidationError> {
|
||||
self.azure_resource_id = self.azure_resource_id.trim().to_owned();
|
||||
self.server_host = self.server_host.trim().to_owned();
|
||||
self.database = self.database.trim().to_owned();
|
||||
self.username = self.username.trim().to_owned();
|
||||
if self.azure_resource_id.is_empty()
|
||||
|| self.server_host.is_empty()
|
||||
|| self.database.is_empty()
|
||||
|| self.username.is_empty()
|
||||
{
|
||||
return Err(ProfileValidationError("profile fields must not be empty"));
|
||||
}
|
||||
if self.server_host.chars().any(char::is_control) || self.port == 0 {
|
||||
return Err(ProfileValidationError("invalid profile endpoint"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
+225
-9
@@ -1,9 +1,225 @@
|
||||
#[derive(Debug, Clone)] pub struct StatementRange;
|
||||
pub struct SqlSubmission;
|
||||
#[derive(Debug, Clone)] pub struct SqlRunRequest;
|
||||
#[derive(Debug, Clone)] pub struct SqlExecutionEvent;
|
||||
#[derive(Debug, Clone)] pub struct ResultSetMeta;
|
||||
pub struct DisplayCell;
|
||||
#[derive(Debug, Clone)] pub struct CopyDirection;
|
||||
#[derive(Debug, Clone)] pub struct TransactionState;
|
||||
#[derive(Debug, Clone)] pub struct SqlLexer;
|
||||
use std::ops::Range;
|
||||
|
||||
pub const MAX_RESULT_ROWS: usize = 1_000;
|
||||
pub const MAX_RESULT_RENDERED_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const MAX_CELL_RENDERED_BYTES: usize = 64 * 1024;
|
||||
pub const SQL_EVENT_CHANNEL_CAPACITY: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StatementRange {
|
||||
pub range: Range<usize>,
|
||||
}
|
||||
pub struct SqlSubmission(String);
|
||||
impl SqlSubmission {
|
||||
pub fn new(text: String) -> Self {
|
||||
Self(text)
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CopyDirection {
|
||||
FromStdin,
|
||||
ToStdout,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransactionState {
|
||||
Idle,
|
||||
Active,
|
||||
UnknownAfterError,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResultSetMeta {
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
pub struct DisplayCell {
|
||||
value: String,
|
||||
pub truncated: bool,
|
||||
pub is_null: bool,
|
||||
}
|
||||
impl DisplayCell {
|
||||
pub fn render(value: Option<&str>) -> Self {
|
||||
match value {
|
||||
None => Self {
|
||||
value: "NULL".to_owned(),
|
||||
truncated: false,
|
||||
is_null: true,
|
||||
},
|
||||
Some(text) => {
|
||||
let (value, truncated) = truncate_utf8(text, MAX_CELL_RENDERED_BYTES);
|
||||
Self {
|
||||
value,
|
||||
truncated,
|
||||
is_null: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SqlExecutionEvent {
|
||||
StatementStarted,
|
||||
ResultSet(ResultSetMeta),
|
||||
RowRetained,
|
||||
RowsOmitted,
|
||||
CellTruncated,
|
||||
CommandComplete { tag: String },
|
||||
Failed,
|
||||
Completed,
|
||||
}
|
||||
|
||||
pub struct SqlLexer;
|
||||
impl SqlLexer {
|
||||
pub fn split(input: &str) -> Vec<StatementRange> {
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum State {
|
||||
Normal,
|
||||
Single { escape: bool },
|
||||
Double,
|
||||
Dollar(Vec<u8>),
|
||||
LineComment,
|
||||
Block(u32),
|
||||
}
|
||||
let bytes = input.as_bytes();
|
||||
let mut state = State::Normal;
|
||||
let mut start = 0;
|
||||
let mut ranges = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match &mut state {
|
||||
State::Normal => {
|
||||
if bytes[i] == b'\'' {
|
||||
let escape = i > 0
|
||||
&& matches!(bytes[i - 1], b'e' | b'E')
|
||||
&& (i < 2 || !bytes[i - 2].is_ascii_alphanumeric());
|
||||
state = State::Single { escape };
|
||||
} else if bytes[i] == b'\"' {
|
||||
state = State::Double;
|
||||
} else if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
|
||||
state = State::LineComment;
|
||||
i += 1;
|
||||
} else if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||
state = State::Block(1);
|
||||
i += 1;
|
||||
} else if bytes[i] == b'$' {
|
||||
if let Some((tag, end)) = dollar_tag(bytes, i) {
|
||||
state = State::Dollar(tag);
|
||||
i = end - 1;
|
||||
}
|
||||
} else if bytes[i] == b';' {
|
||||
let end = i + 1;
|
||||
if !input[start..end]
|
||||
.trim()
|
||||
.trim_end_matches(';')
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
ranges.push(StatementRange { range: start..end });
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
State::Single { escape } => {
|
||||
if *escape && bytes[i] == b'\\' {
|
||||
i += 1;
|
||||
} else if bytes[i] == b'\'' {
|
||||
if bytes.get(i + 1) == Some(&b'\'') {
|
||||
i += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Double => {
|
||||
if bytes[i] == b'\"' {
|
||||
if bytes.get(i + 1) == Some(&b'\"') {
|
||||
i += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Dollar(tag) => {
|
||||
if bytes[i] == b'$' && bytes[i..].starts_with(tag) {
|
||||
i += tag.len() - 1;
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::LineComment => {
|
||||
if bytes[i] == b'\n' {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::Block(depth) => {
|
||||
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||
*depth += 1;
|
||||
i += 1;
|
||||
} else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
|
||||
*depth -= 1;
|
||||
i += 1;
|
||||
if *depth == 0 {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !input[start..].trim().is_empty() {
|
||||
ranges.push(StatementRange {
|
||||
range: start..input.len(),
|
||||
});
|
||||
}
|
||||
ranges
|
||||
}
|
||||
pub fn classify_copy(statement: &str) -> Option<CopyDirection> {
|
||||
let words: Vec<String> = statement
|
||||
.split_whitespace()
|
||||
.map(|w| {
|
||||
w.trim_matches(|c: char| !c.is_ascii_alphanumeric())
|
||||
.to_ascii_uppercase()
|
||||
})
|
||||
.collect();
|
||||
if words.first().map(String::as_str) != Some("COPY") {
|
||||
return None;
|
||||
}
|
||||
if words.windows(2).any(|w| w[0] == "FROM" && w[1] == "STDIN") {
|
||||
Some(CopyDirection::FromStdin)
|
||||
} else if words.windows(2).any(|w| w[0] == "TO" && w[1] == "STDOUT") {
|
||||
Some(CopyDirection::ToStdout)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn dollar_tag(bytes: &[u8], start: usize) -> Option<(Vec<u8>, usize)> {
|
||||
let mut i = start + 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
if bytes.get(i) != Some(&b'$') {
|
||||
return None;
|
||||
}
|
||||
if i > start + 1 && !matches!(bytes[start + 1], b'a'..=b'z' | b'A'..=b'Z' | b'_') {
|
||||
return None;
|
||||
}
|
||||
Some((bytes[start..=i].to_vec(), i + 1))
|
||||
}
|
||||
fn truncate_utf8(value: &str, max: usize) -> (String, bool) {
|
||||
if value.len() <= max {
|
||||
return (value.to_owned(), false);
|
||||
}
|
||||
let suffix = "…";
|
||||
let mut end = max.saturating_sub(suffix.len());
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
(format!("{}{}", &value[..end], suffix), true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
pub const TABLE_PAGE_ROWS: usize = 200;
|
||||
pub const TABLE_PAGE_RENDERED_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const MAX_OFFSET_ROWS: usize = 10_000;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PaginationMode {
|
||||
Keyset,
|
||||
Offset { warning: &'static str },
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InsertFieldValue {
|
||||
UseDefault,
|
||||
Null,
|
||||
Text(String),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedCell {
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
pub is_null: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableRow {
|
||||
pub cells: Vec<RenderedCell>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TablePage {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<TableRow>,
|
||||
pub has_next: bool,
|
||||
pub mode: PaginationMode,
|
||||
pub truncated_by_render_limit: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InsertForm {
|
||||
pub schema: String,
|
||||
pub relation: String,
|
||||
pub values: Vec<(String, InsertFieldValue)>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DropTableConfirmation {
|
||||
pub canonical_name: String,
|
||||
pub typed_name: String,
|
||||
}
|
||||
+82
-7
@@ -1,11 +1,21 @@
|
||||
use std::fmt;
|
||||
use crate::domain::ids::OperationId;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorKind { Internal, Input, Network, Database, Process }
|
||||
pub enum ErrorKind {
|
||||
Internal,
|
||||
Input,
|
||||
Network,
|
||||
Database,
|
||||
Process,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorSeverity { Info, Warning, Error }
|
||||
pub enum ErrorSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SqlStateCode([u8; 5]);
|
||||
@@ -13,9 +23,15 @@ pub struct SqlStateCode([u8; 5]);
|
||||
impl SqlStateCode {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() == 5 && bytes.iter().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) {
|
||||
if bytes.len() == 5
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
|
||||
{
|
||||
Some(Self(bytes.try_into().ok()?))
|
||||
} else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +54,70 @@ pub struct SafeDiagnostic {
|
||||
}
|
||||
|
||||
impl fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.message.fmt(f) }
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.message.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
pub fn redact_sensitive_text(input: &str) -> String {
|
||||
if input.is_empty() { String::new() } else { "[diagnostic details omitted]".to_owned() }
|
||||
if input.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"[diagnostic details omitted]".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl SafeError {
|
||||
pub const fn input(message: &'static str) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Input,
|
||||
message,
|
||||
sqlstate: None,
|
||||
severity: ErrorSeverity::Error,
|
||||
retryable: false,
|
||||
operation_id: None,
|
||||
}
|
||||
}
|
||||
pub const fn process() -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Process,
|
||||
message: "Azure CLI operation failed",
|
||||
sqlstate: None,
|
||||
severity: ErrorSeverity::Error,
|
||||
retryable: true,
|
||||
operation_id: None,
|
||||
}
|
||||
}
|
||||
pub const fn network() -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Network,
|
||||
message: "Network operation failed",
|
||||
sqlstate: None,
|
||||
severity: ErrorSeverity::Error,
|
||||
retryable: true,
|
||||
operation_id: None,
|
||||
}
|
||||
}
|
||||
pub const fn database() -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Database,
|
||||
message: "Database operation failed",
|
||||
sqlstate: None,
|
||||
severity: ErrorSeverity::Error,
|
||||
retryable: false,
|
||||
operation_id: None,
|
||||
}
|
||||
}
|
||||
pub const fn configuration(message: &'static str) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Input,
|
||||
message,
|
||||
sqlstate: None,
|
||||
severity: ErrorSeverity::Error,
|
||||
retryable: false,
|
||||
operation_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod error;
|
||||
pub mod adapters;
|
||||
pub mod app;
|
||||
pub mod domain;
|
||||
pub mod error;
|
||||
pub mod platform;
|
||||
pub mod services;
|
||||
pub mod tui;
|
||||
|
||||
@@ -1 +1,82 @@
|
||||
pub trait ClipboardService {} pub struct SystemClipboardService;
|
||||
use crate::{
|
||||
error::{AppError, SafeError},
|
||||
platform::secret_input::{OneTimeSecret, OneTimeSecretId, SecretVault},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClipboardService: Send + Sync {
|
||||
async fn write(&self, text: String) -> Result<(), AppError>;
|
||||
async fn read(&self) -> Result<String, AppError>;
|
||||
async fn clear(&self) -> Result<(), AppError>;
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct MemoryClipboard {
|
||||
value: Mutex<String>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl ClipboardService for MemoryClipboard {
|
||||
async fn write(&self, text: String) -> Result<(), AppError> {
|
||||
*self
|
||||
.value
|
||||
.lock()
|
||||
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))? = text;
|
||||
Ok(())
|
||||
}
|
||||
async fn read(&self) -> Result<String, AppError> {
|
||||
Ok(self
|
||||
.value
|
||||
.lock()
|
||||
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))?
|
||||
.clone())
|
||||
}
|
||||
async fn clear(&self) -> Result<(), AppError> {
|
||||
self.value
|
||||
.lock()
|
||||
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))?
|
||||
.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub struct ClipboardCleaner {
|
||||
digest: [u8; 32],
|
||||
expires: Instant,
|
||||
}
|
||||
impl ClipboardCleaner {
|
||||
pub async fn clear_if_unchanged(
|
||||
&self,
|
||||
clipboard: &dyn ClipboardService,
|
||||
) -> Result<(), AppError> {
|
||||
if Instant::now() < self.expires {
|
||||
return Ok(());
|
||||
}
|
||||
let current = clipboard.read().await?;
|
||||
if Sha256::digest(current.as_bytes()).as_slice() == self.digest {
|
||||
clipboard.clear().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub async fn copy_credentials_once(
|
||||
vault: &mut SecretVault,
|
||||
id: OneTimeSecretId,
|
||||
clipboard: Arc<dyn ClipboardService>,
|
||||
) -> Result<ClipboardCleaner, AppError> {
|
||||
let secret: OneTimeSecret = vault.take_one_time(id)?;
|
||||
let content = format!(
|
||||
"username: {}\npassword: {}",
|
||||
secret.username,
|
||||
secret.password.as_str()
|
||||
);
|
||||
let digest: [u8; 32] = Sha256::digest(content.as_bytes()).into();
|
||||
clipboard.write(content).await?;
|
||||
Ok(ClipboardCleaner {
|
||||
digest,
|
||||
expires: Instant::now() + Duration::from_secs(30),
|
||||
})
|
||||
}
|
||||
|
||||
+17
-1
@@ -1 +1,17 @@
|
||||
pub trait Clock {} pub struct SystemClock;
|
||||
use async_trait::async_trait;
|
||||
use std::time::{Duration, Instant};
|
||||
#[async_trait]
|
||||
pub trait Clock: Send + Sync {
|
||||
fn now(&self) -> Instant;
|
||||
async fn sleep(&self, duration: Duration);
|
||||
}
|
||||
pub struct SystemClock;
|
||||
#[async_trait]
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
async fn sleep(&self, duration: Duration) {
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,72 @@
|
||||
pub trait SecretStore {} pub struct KeyringSecretStore; pub struct UnavailableSecretStore;
|
||||
use crate::{
|
||||
domain::profile::SecretReference,
|
||||
error::{AppError, SafeError},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use zeroize::Zeroizing;
|
||||
#[async_trait]
|
||||
pub trait SecretStore: Send + Sync {
|
||||
async fn availability(&self) -> bool;
|
||||
async fn get(&self, reference: &SecretReference) -> Result<Zeroizing<String>, AppError>;
|
||||
async fn put(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
secret: Zeroizing<String>,
|
||||
) -> Result<(), AppError>;
|
||||
async fn delete(&self, reference: &SecretReference) -> Result<(), AppError>;
|
||||
}
|
||||
pub struct UnavailableSecretStore;
|
||||
#[async_trait]
|
||||
impl SecretStore for UnavailableSecretStore {
|
||||
async fn availability(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn get(&self, _: &SecretReference) -> Result<Zeroizing<String>, AppError> {
|
||||
Err(AppError(SafeError::configuration(
|
||||
"System keychain is unavailable",
|
||||
)))
|
||||
}
|
||||
async fn put(&self, _: &SecretReference, _: Zeroizing<String>) -> Result<(), AppError> {
|
||||
Err(AppError(SafeError::configuration(
|
||||
"System keychain is unavailable",
|
||||
)))
|
||||
}
|
||||
async fn delete(&self, _: &SecretReference) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub struct KeyringSecretStore;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
#[async_trait]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
async fn availability(&self) -> bool {
|
||||
true
|
||||
}
|
||||
async fn get(&self, reference: &SecretReference) -> Result<Zeroizing<String>, AppError> {
|
||||
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||
Ok(Zeroizing::new(entry.get_password().map_err(|_| {
|
||||
AppError(SafeError::configuration("Profile password is unavailable"))
|
||||
})?))
|
||||
}
|
||||
async fn put(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
secret: Zeroizing<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||
entry.set_password(&secret).map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Profile password could not be stored",
|
||||
))
|
||||
})
|
||||
}
|
||||
async fn delete(&self, reference: &SecretReference) -> Result<(), AppError> {
|
||||
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||
let _ = entry.delete_credential();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -1 +1,6 @@
|
||||
pub mod paths; pub mod clock; pub mod profile_file; pub mod keychain; pub mod clipboard; pub mod secret_input;
|
||||
pub mod clipboard;
|
||||
pub mod clock;
|
||||
pub mod keychain;
|
||||
pub mod paths;
|
||||
pub mod profile_file;
|
||||
pub mod secret_input;
|
||||
|
||||
+23
-2
@@ -1,2 +1,23 @@
|
||||
pub struct AppPaths;
|
||||
impl AppPaths { pub fn discover() -> Self { Self } }
|
||||
use crate::error::{AppError, SafeError};
|
||||
use directories::ProjectDirs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppPaths {
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
impl AppPaths {
|
||||
pub fn discover() -> Result<Self, AppError> {
|
||||
let dirs = ProjectDirs::from("com", "azure", "azure-database-tui").ok_or_else(|| {
|
||||
AppError(SafeError::configuration(
|
||||
"Application configuration directory is unavailable",
|
||||
))
|
||||
})?;
|
||||
Ok(Self {
|
||||
config_dir: dirs.config_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
pub fn profiles_path(&self) -> PathBuf {
|
||||
self.config_dir.join("profiles.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,76 @@
|
||||
pub struct ProfileFileStore;
|
||||
use crate::{
|
||||
domain::profile::{PROFILE_FORMAT_VERSION, ProfileFile},
|
||||
error::{AppError, SafeError},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProfileRepository: Send + Sync {
|
||||
async fn load(&self) -> Result<ProfileFile, AppError>;
|
||||
async fn save(&self, profiles: &ProfileFile) -> Result<(), AppError>;
|
||||
}
|
||||
pub struct ProfileFileStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
impl ProfileFileStore {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl ProfileRepository for ProfileFileStore {
|
||||
async fn load(&self) -> Result<ProfileFile, AppError> {
|
||||
let path = self.path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !path.exists() {
|
||||
return Ok(ProfileFile::default());
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|_| {
|
||||
AppError(SafeError::configuration("Profile file could not be read"))
|
||||
})?;
|
||||
let profiles: ProfileFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| AppError(SafeError::configuration("Profile file is invalid")))?;
|
||||
if profiles.format_version != PROFILE_FORMAT_VERSION {
|
||||
return Err(AppError(SafeError::configuration(
|
||||
"Profile file version is unsupported",
|
||||
)));
|
||||
}
|
||||
Ok(profiles)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::configuration("Profile file operation failed")))?
|
||||
}
|
||||
async fn save(&self, profiles: &ProfileFile) -> Result<(), AppError> {
|
||||
let path = self.path.clone();
|
||||
let profiles = profiles.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| AppError(SafeError::configuration("Profile path is invalid")))?;
|
||||
std::fs::create_dir_all(parent).map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Profile directory could not be created",
|
||||
))
|
||||
})?;
|
||||
let data = serde_json::to_vec_pretty(&profiles).map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Profile file could not be encoded",
|
||||
))
|
||||
})?;
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
std::fs::write(&temporary, data).map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Profile file could not be written",
|
||||
))
|
||||
})?;
|
||||
std::fs::rename(temporary, path).map_err(|_| {
|
||||
AppError(SafeError::configuration(
|
||||
"Profile file could not be replaced",
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::configuration("Profile file operation failed")))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,120 @@
|
||||
pub struct SecretInputBuffer; pub struct SecretHandle; pub struct OneTimeSecret; pub struct SecretVault;
|
||||
use crate::error::{AppError, SafeError};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
pub struct SecretInputBuffer {
|
||||
value: Zeroizing<String>,
|
||||
}
|
||||
impl SecretInputBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
value: Zeroizing::new(String::new()),
|
||||
}
|
||||
}
|
||||
pub fn push(&mut self, character: char) {
|
||||
self.value.push(character);
|
||||
}
|
||||
pub fn backspace(&mut self) {
|
||||
self.value.pop();
|
||||
}
|
||||
pub fn masked_len(&self) -> usize {
|
||||
self.value.chars().count()
|
||||
}
|
||||
pub fn into_secret(self) -> Zeroizing<String> {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
impl Default for SecretInputBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SecretSlotId(Uuid);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct OneTimeSecretId(Uuid);
|
||||
pub struct SecretHandle(Zeroizing<String>);
|
||||
impl SecretHandle {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
pub(crate) fn into_inner(self) -> Zeroizing<String> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
pub struct OneTimeSecret {
|
||||
pub(crate) username: String,
|
||||
pub(crate) password: Zeroizing<String>,
|
||||
}
|
||||
pub struct SecretVault {
|
||||
slots: HashMap<SecretSlotId, Zeroizing<String>>,
|
||||
one_time: HashMap<OneTimeSecretId, (OneTimeSecret, Instant)>,
|
||||
}
|
||||
impl SecretVault {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
slots: HashMap::new(),
|
||||
one_time: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn store(&mut self, secret: Zeroizing<String>) -> SecretSlotId {
|
||||
let id = SecretSlotId(Uuid::new_v4());
|
||||
self.slots.insert(id, secret);
|
||||
id
|
||||
}
|
||||
pub fn take(&mut self, id: SecretSlotId) -> Result<SecretHandle, AppError> {
|
||||
self.slots
|
||||
.remove(&id)
|
||||
.map(SecretHandle)
|
||||
.ok_or_else(|| AppError(SafeError::input("Secret is no longer available")))
|
||||
}
|
||||
pub fn create_one_time(&mut self, username: String, password: SecretHandle) -> OneTimeSecretId {
|
||||
let id = OneTimeSecretId(Uuid::new_v4());
|
||||
self.one_time.insert(
|
||||
id,
|
||||
(
|
||||
OneTimeSecret {
|
||||
username,
|
||||
password: password.into_inner(),
|
||||
},
|
||||
Instant::now() + Duration::from_secs(60),
|
||||
),
|
||||
);
|
||||
id
|
||||
}
|
||||
pub fn take_one_time(&mut self, id: OneTimeSecretId) -> Result<OneTimeSecret, AppError> {
|
||||
self.expire();
|
||||
self.one_time
|
||||
.remove(&id)
|
||||
.map(|(secret, _)| secret)
|
||||
.ok_or_else(|| AppError(SafeError::input("Credentials are no longer available")))
|
||||
}
|
||||
pub fn dismiss_one_time(&mut self, id: OneTimeSecretId) {
|
||||
self.one_time.remove(&id);
|
||||
}
|
||||
pub fn expire(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.one_time.retain(|_, (_, expires)| *expires > now);
|
||||
}
|
||||
}
|
||||
impl Default for SecretVault {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Drop for SecretVault {
|
||||
fn drop(&mut self) {
|
||||
for value in self.slots.values_mut() {
|
||||
value.zeroize();
|
||||
}
|
||||
for (secret, _) in self.one_time.values_mut() {
|
||||
secret.password.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-1
@@ -1 +1,78 @@
|
||||
pub struct CatalogService;
|
||||
use crate::domain::{
|
||||
catalog::CatalogSnapshot,
|
||||
completion::{CompletionContext, CompletionItem, CompletionResponse},
|
||||
};
|
||||
pub struct CatalogService {
|
||||
pub snapshot: Option<CatalogSnapshot>,
|
||||
pub stale: bool,
|
||||
}
|
||||
impl CatalogService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
snapshot: None,
|
||||
stale: true,
|
||||
}
|
||||
}
|
||||
pub fn replace(&mut self, snapshot: CatalogSnapshot) {
|
||||
self.snapshot = Some(snapshot);
|
||||
self.stale = false;
|
||||
}
|
||||
pub fn invalidate(&mut self) {
|
||||
self.stale = true;
|
||||
}
|
||||
pub fn completion(
|
||||
&self,
|
||||
context: CompletionContext,
|
||||
prefix: &str,
|
||||
) -> Option<CompletionResponse> {
|
||||
let snapshot = self.snapshot.as_ref()?;
|
||||
if self.stale || matches!(context, CompletionContext::Suppressed) {
|
||||
return None;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let lowered = prefix.to_ascii_lowercase();
|
||||
for keyword in [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP",
|
||||
"GRANT", "REVOKE",
|
||||
] {
|
||||
if keyword.to_ascii_lowercase().starts_with(&lowered) {
|
||||
items.push(CompletionItem {
|
||||
text: keyword.to_owned(),
|
||||
detail: "keyword",
|
||||
});
|
||||
}
|
||||
}
|
||||
for relation in &snapshot.relations {
|
||||
let name = format!("{}.{}", relation.schema, relation.name);
|
||||
if name.to_ascii_lowercase().starts_with(&lowered) {
|
||||
items.push(CompletionItem {
|
||||
text: name,
|
||||
detail: "relation",
|
||||
});
|
||||
}
|
||||
}
|
||||
for role in &snapshot.roles {
|
||||
if role.name.to_ascii_lowercase().starts_with(&lowered) {
|
||||
items.push(CompletionItem {
|
||||
text: role.name.clone(),
|
||||
detail: "role",
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| {
|
||||
a.text
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.text.to_ascii_lowercase())
|
||||
});
|
||||
items.truncate(100);
|
||||
Some(CompletionResponse {
|
||||
generation: snapshot.generation,
|
||||
items,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Default for CatalogService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,58 @@
|
||||
pub struct DiscoveryService;
|
||||
use crate::{
|
||||
adapters::azure_cli::AzureCliAdapter,
|
||||
domain::{
|
||||
ids::OperationId,
|
||||
inventory::{DiscoveryFailure, DiscoveryReport},
|
||||
},
|
||||
error::AppError,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
pub struct DiscoveryService {
|
||||
adapter: Arc<AzureCliAdapter>,
|
||||
}
|
||||
impl DiscoveryService {
|
||||
pub fn new(adapter: Arc<AzureCliAdapter>) -> Self {
|
||||
Self { adapter }
|
||||
}
|
||||
pub async fn discover(
|
||||
&self,
|
||||
operation_id: OperationId,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<DiscoveryReport, AppError> {
|
||||
let tenant = self
|
||||
.adapter
|
||||
.current_tenant(operation_id, cancellation.clone())
|
||||
.await?;
|
||||
let subscriptions = self
|
||||
.adapter
|
||||
.subscriptions(&tenant.id, operation_id, cancellation.clone())
|
||||
.await?;
|
||||
let mut report = DiscoveryReport {
|
||||
tenant: Some(tenant),
|
||||
subscriptions: subscriptions.clone(),
|
||||
..DiscoveryReport::default()
|
||||
};
|
||||
for subscription in subscriptions {
|
||||
match self
|
||||
.adapter
|
||||
.servers(&subscription, operation_id, cancellation.clone())
|
||||
.await
|
||||
{
|
||||
Ok(servers) => {
|
||||
for server in servers {
|
||||
report
|
||||
.servers
|
||||
.insert(server.resource_id.as_str().to_owned(), server);
|
||||
}
|
||||
}
|
||||
Err(error) => report.failures.push(DiscoveryFailure {
|
||||
subscription_id: subscription.id,
|
||||
operation_id,
|
||||
error: error.0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -1 +1,6 @@
|
||||
pub mod discovery; pub mod profiles; pub mod catalog; pub mod tables; pub mod roles; pub mod sql_executor;
|
||||
pub mod catalog;
|
||||
pub mod discovery;
|
||||
pub mod profiles;
|
||||
pub mod roles;
|
||||
pub mod sql_executor;
|
||||
pub mod tables;
|
||||
|
||||
@@ -1 +1,42 @@
|
||||
pub struct ProfileService;
|
||||
use crate::{
|
||||
domain::profile::{ConnectionDraft, ConnectionProfile, ProfileFile, SecretReference},
|
||||
error::{AppError, SafeError},
|
||||
platform::profile_file::ProfileRepository,
|
||||
};
|
||||
pub struct ProfileService<R: ProfileRepository> {
|
||||
repository: R,
|
||||
}
|
||||
impl<R: ProfileRepository> ProfileService<R> {
|
||||
pub fn new(repository: R) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
pub async fn create(&self, draft: ConnectionDraft) -> Result<ConnectionProfile, AppError> {
|
||||
let draft = draft
|
||||
.validate()
|
||||
.map_err(|_| AppError(SafeError::input("Profile details are invalid")))?;
|
||||
let id = crate::domain::ids::ProfileId::new();
|
||||
let reference = if draft.save_secret {
|
||||
Some(SecretReference {
|
||||
service: "azure-database-tui".to_owned(),
|
||||
account: format!("profile/{id}"),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let profile = ConnectionProfile {
|
||||
id,
|
||||
azure_resource_id: crate::domain::ids::AzureResourceId::new(draft.azure_resource_id)
|
||||
.map_err(|_| AppError(SafeError::input("Profile details are invalid")))?,
|
||||
server_host: draft.server_host,
|
||||
port: draft.port,
|
||||
database: draft.database,
|
||||
username: draft.username,
|
||||
tls: draft.tls,
|
||||
secret_reference: reference,
|
||||
};
|
||||
let mut file: ProfileFile = self.repository.load().await?;
|
||||
file.profiles.push(profile.clone());
|
||||
self.repository.save(&file).await?;
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,77 @@
|
||||
use crate::{
|
||||
domain::{identifiers::quote_identifier, permissions::PermissionPlan},
|
||||
error::{AppError, SafeError},
|
||||
platform::secret_input::SecretHandle,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
pub struct RoleService;
|
||||
impl RoleService {
|
||||
pub fn plan_has_no_secret(plan: &PermissionPlan) -> bool {
|
||||
!plan.password_will_be_set || plan.password_will_be_set
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn password_literal(secret: &str) -> Result<Zeroizing<String>, AppError> {
|
||||
if secret.contains('\0') {
|
||||
return Err(AppError(SafeError::input(
|
||||
"Password contains an unsupported character",
|
||||
)));
|
||||
}
|
||||
let mut value = String::from("E'");
|
||||
for ch in secret.chars() {
|
||||
match ch {
|
||||
'\\' => value.push_str("\\\\"),
|
||||
'\'' => value.push_str("\\'"),
|
||||
_ => value.push(ch),
|
||||
}
|
||||
}
|
||||
value.push('\'');
|
||||
Ok(Zeroizing::new(value))
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn create_role_sql(
|
||||
role: &str,
|
||||
secret: &SecretHandle,
|
||||
) -> Result<Zeroizing<String>, AppError> {
|
||||
let quoted = quote_identifier(role)?;
|
||||
let literal = Self::password_literal(secret.expose())?;
|
||||
Ok(Zeroizing::new(format!(
|
||||
"CREATE ROLE {quoted} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT PASSWORD {}",
|
||||
literal.as_str()
|
||||
)))
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn reset_password_sql(
|
||||
role: &str,
|
||||
secret: &SecretHandle,
|
||||
) -> Result<Zeroizing<String>, AppError> {
|
||||
let quoted = quote_identifier(role)?;
|
||||
let literal = Self::password_literal(secret.expose())?;
|
||||
Ok(Zeroizing::new(format!(
|
||||
"ALTER ROLE {quoted} PASSWORD {}",
|
||||
literal.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::platform::secret_input::SecretVault;
|
||||
use zeroize::Zeroizing;
|
||||
#[test]
|
||||
fn renderer_escapes_password_literal() {
|
||||
assert_eq!(
|
||||
RoleService::password_literal("a'\\\n🙂").unwrap().as_str(),
|
||||
"E'a\\'\\\\\n🙂'"
|
||||
);
|
||||
assert_eq!(RoleService::password_literal("").unwrap().as_str(), "E''");
|
||||
assert!(RoleService::password_literal("a\0b").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn ddl_has_quoted_role() {
|
||||
let mut vault = SecretVault::new();
|
||||
let slot = vault.store(Zeroizing::new("pw".to_owned()));
|
||||
let secret = vault.take(slot).unwrap();
|
||||
let sql = RoleService::create_role_sql("role\"name", &secret).unwrap();
|
||||
assert!(sql.contains("\"role\"\"name\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,64 @@
|
||||
use crate::{
|
||||
domain::sql::{CopyDirection, SqlLexer, SqlSubmission, StatementRange, TransactionState},
|
||||
error::{AppError, SafeError},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const SQL_EVENT_CHANNEL_CAPACITY: usize = crate::domain::sql::SQL_EVENT_CHANNEL_CAPACITY;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ExecutorEvent {
|
||||
StatementStarted { index: usize },
|
||||
StatementCompleted { index: usize },
|
||||
CopyRequired(CopyDirection),
|
||||
Failed { index: usize },
|
||||
Cancelled,
|
||||
}
|
||||
pub struct SqlExecutor;
|
||||
impl SqlExecutor {
|
||||
pub fn statements(submission: &SqlSubmission) -> Vec<StatementRange> {
|
||||
SqlLexer::split(submission.as_str())
|
||||
}
|
||||
pub fn copy_mode(submission: &SqlSubmission, range: &StatementRange) -> Option<CopyDirection> {
|
||||
SqlLexer::classify_copy(&submission.as_str()[range.range.clone()])
|
||||
}
|
||||
pub fn event_channel() -> (mpsc::Sender<ExecutorEvent>, mpsc::Receiver<ExecutorEvent>) {
|
||||
mpsc::channel(SQL_EVENT_CHANNEL_CAPACITY)
|
||||
}
|
||||
pub async fn emit_plan(
|
||||
submission: &SqlSubmission,
|
||||
sender: mpsc::Sender<ExecutorEvent>,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<TransactionState, AppError> {
|
||||
let ranges = Self::statements(submission);
|
||||
let mut state = TransactionState::Idle;
|
||||
for (index, range) in ranges.iter().enumerate() {
|
||||
tokio::select! { _ = cancellation.cancelled() => { let _ = sender.send(ExecutorEvent::Cancelled).await; return Err(AppError(SafeError::input("Operation cancelled"))); }, result = sender.send(ExecutorEvent::StatementStarted { index }) => result.map_err(|_| AppError(SafeError::database()))? }
|
||||
let statement = submission.as_str()[range.range.clone()].trim();
|
||||
if let Some(copy) = SqlLexer::classify_copy(statement) {
|
||||
sender
|
||||
.send(ExecutorEvent::CopyRequired(copy))
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::database()))?;
|
||||
} else {
|
||||
state = transaction_state_for(statement, state);
|
||||
sender
|
||||
.send(ExecutorEvent::StatementCompleted { index })
|
||||
.await
|
||||
.map_err(|_| AppError(SafeError::database()))?;
|
||||
}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
fn transaction_state_for(statement: &str, previous: TransactionState) -> TransactionState {
|
||||
let upper = statement.trim().trim_end_matches(';').to_ascii_uppercase();
|
||||
if upper == "BEGIN" || upper == "START TRANSACTION" {
|
||||
TransactionState::Active
|
||||
} else if upper == "COMMIT" || upper == "ROLLBACK" {
|
||||
TransactionState::Idle
|
||||
} else {
|
||||
previous
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,71 @@
|
||||
use crate::{
|
||||
domain::{
|
||||
identifiers::{quote_identifier, quote_qualified},
|
||||
table::{DropTableConfirmation, InsertFieldValue, InsertForm, TABLE_PAGE_ROWS},
|
||||
},
|
||||
error::{AppError, SafeError},
|
||||
};
|
||||
|
||||
pub struct TableService;
|
||||
impl TableService {
|
||||
pub fn drop_sql(
|
||||
schema: &str,
|
||||
relation: &str,
|
||||
confirmation: &DropTableConfirmation,
|
||||
) -> Result<String, AppError> {
|
||||
let target = quote_qualified(schema, relation)?;
|
||||
if confirmation.canonical_name != target.0 || confirmation.typed_name != target.0 {
|
||||
return Err(AppError(SafeError::input(
|
||||
"Table name confirmation does not match",
|
||||
)));
|
||||
}
|
||||
Ok(format!("DROP TABLE {}", target.0))
|
||||
}
|
||||
pub fn insert_sql(form: &InsertForm) -> Result<(String, Vec<String>), AppError> {
|
||||
let target = quote_qualified(&form.schema, &form.relation)?;
|
||||
let mut columns = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
let mut parameters = Vec::new();
|
||||
for (name, value) in &form.values {
|
||||
match value {
|
||||
InsertFieldValue::UseDefault => {}
|
||||
InsertFieldValue::Null => {
|
||||
columns.push(quote_identifier(name)?);
|
||||
values.push("NULL".to_owned());
|
||||
}
|
||||
InsertFieldValue::Text(text) => {
|
||||
columns.push(quote_identifier(name)?);
|
||||
parameters.push(text.clone());
|
||||
values.push(format!("${}", parameters.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if columns.is_empty() {
|
||||
return Ok((
|
||||
format!("INSERT INTO {} DEFAULT VALUES", target.0),
|
||||
parameters,
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
format!(
|
||||
"INSERT INTO {} ({}) VALUES ({})",
|
||||
target.0,
|
||||
columns.join(", "),
|
||||
values.join(", ")
|
||||
),
|
||||
parameters,
|
||||
))
|
||||
}
|
||||
pub fn offset_allowed(offset: usize) -> Result<(), AppError> {
|
||||
if offset > crate::domain::table::MAX_OFFSET_ROWS {
|
||||
Err(AppError(SafeError::input(
|
||||
"Offset pagination limit reached",
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub const fn page_limit() -> usize {
|
||||
TABLE_PAGE_ROWS
|
||||
}
|
||||
}
|
||||
|
||||
+145
-1
@@ -1 +1,145 @@
|
||||
pub struct TerminalGuard;
|
||||
use crate::app::state::AppState;
|
||||
use crossterm::{
|
||||
cursor, execute,
|
||||
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
};
|
||||
use std::io::{self, Stdout};
|
||||
|
||||
pub type TuiTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
pub struct TerminalGuard {
|
||||
terminal: TuiTerminal,
|
||||
restored: bool,
|
||||
}
|
||||
|
||||
impl TerminalGuard {
|
||||
pub fn enter() -> io::Result<Self> {
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
if let Err(error) = execute!(stdout, EnterAlternateScreen, cursor::Hide) {
|
||||
let _ = terminal::disable_raw_mode();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Self {
|
||||
terminal: Terminal::new(CrosstermBackend::new(stdout))?,
|
||||
restored: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, state: &AppState) -> io::Result<()> {
|
||||
self.terminal.draw(|frame| render(frame, state)).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn restore(&mut self) {
|
||||
if self.restored {
|
||||
return;
|
||||
}
|
||||
self.restored = true;
|
||||
let _ = self.terminal.show_cursor();
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
cursor::Show
|
||||
);
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(frame: &mut ratatui::Frame<'_>, state: &AppState) {
|
||||
let area = frame.area();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(area);
|
||||
let tenant = state
|
||||
.tenant
|
||||
.as_ref()
|
||||
.map(|tenant| tenant.display_name.as_str())
|
||||
.unwrap_or("Loading current Azure tenant");
|
||||
let selected = state
|
||||
.servers
|
||||
.get(state.selected_server)
|
||||
.map(|server| {
|
||||
format!(
|
||||
"{} / {}",
|
||||
server.name,
|
||||
server.host.as_deref().unwrap_or("no FQDN")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "No server selected".to_owned());
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
" Azure Database for PostgreSQL Flexible Server ",
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::raw(format!("Tenant: {tenant}")),
|
||||
]))
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
chunks[0],
|
||||
);
|
||||
let items: Vec<ListItem<'_>> = if state.servers.is_empty() {
|
||||
vec![ListItem::new("Waiting for Azure CLI results…")]
|
||||
} else {
|
||||
state
|
||||
.servers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, server)| {
|
||||
let marker = if index == state.selected_server {
|
||||
"▶"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"{marker} {} [{}] {}",
|
||||
server.name,
|
||||
server.resource_group,
|
||||
server.host.as_deref().unwrap_or("FQDN unavailable")
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
frame.render_widget(
|
||||
List::new(items).block(
|
||||
Block::default()
|
||||
.title("Visible Flexible Servers (↑/↓ select, r refresh, q quit)")
|
||||
.borders(Borders::ALL),
|
||||
),
|
||||
chunks[1],
|
||||
);
|
||||
let mut lines = vec![
|
||||
Line::from(format!("Status: {}", state.status)),
|
||||
Line::from(format!("Selected: {selected}")),
|
||||
Line::from(format!(
|
||||
"Subscriptions: {} | Partial failures: {}",
|
||||
state.subscriptions.len(),
|
||||
state.failures.len()
|
||||
)),
|
||||
];
|
||||
if let Some(error) = &state.last_error {
|
||||
lines.push(Line::from(format!("Error: {}", error.message)));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(Block::default().title("Diagnostics").borders(Borders::ALL)),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use azure_database_tui::{
|
||||
domain::{
|
||||
identifiers::{quote_identifier, quote_qualified},
|
||||
profile::{ConnectionDraft, TlsPolicy},
|
||||
sql::{CopyDirection, SqlLexer},
|
||||
table::{DropTableConfirmation, InsertFieldValue, InsertForm},
|
||||
},
|
||||
services::tables::TableService,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn identifiers_are_quoted_per_segment() {
|
||||
assert_eq!(
|
||||
quote_identifier("Order\"Items").unwrap(),
|
||||
"\"Order\"\"Items\""
|
||||
);
|
||||
assert_eq!(
|
||||
quote_qualified("Sales", "Order Items").unwrap().0,
|
||||
"\"Sales\".\"Order Items\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_draft_rejects_empty_values() {
|
||||
let draft = ConnectionDraft {
|
||||
azure_resource_id: " ".into(),
|
||||
server_host: "host".into(),
|
||||
port: 5432,
|
||||
database: "db".into(),
|
||||
username: "user".into(),
|
||||
tls: TlsPolicy::SystemTrust,
|
||||
save_secret: false,
|
||||
};
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_keeps_semicolons_inside_lexical_regions() {
|
||||
let sql = "select ';'; -- ;\n select $$;$$; /* outer /* ; */ */ select 3;";
|
||||
assert_eq!(SqlLexer::split(sql).len(), 3);
|
||||
assert_eq!(
|
||||
SqlLexer::classify_copy("COPY t FROM STDIN"),
|
||||
Some(CopyDirection::FromStdin)
|
||||
);
|
||||
assert_eq!(
|
||||
SqlLexer::classify_copy("copy t to stdout"),
|
||||
Some(CopyDirection::ToStdout)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_values_are_not_interpolated_and_drop_needs_exact_name() {
|
||||
let form = InsertForm {
|
||||
schema: "public".into(),
|
||||
relation: "orders".into(),
|
||||
values: vec![
|
||||
(
|
||||
"amount".into(),
|
||||
InsertFieldValue::Text("1'); drop table x; --".into()),
|
||||
),
|
||||
("note".into(), InsertFieldValue::Null),
|
||||
("created".into(), InsertFieldValue::UseDefault),
|
||||
],
|
||||
};
|
||||
let (sql, values) = TableService::insert_sql(&form).unwrap();
|
||||
assert_eq!(
|
||||
sql,
|
||||
"INSERT INTO \"public\".\"orders\" (\"amount\", \"note\") VALUES ($1, NULL)"
|
||||
);
|
||||
assert_eq!(values.len(), 1);
|
||||
assert!(!sql.contains("drop table"));
|
||||
let confirmation = DropTableConfirmation {
|
||||
canonical_name: "\"public\".\"orders\"".into(),
|
||||
typed_name: "\"public\".\"orders\"".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
TableService::drop_sql("public", "orders", &confirmation).unwrap(),
|
||||
"DROP TABLE \"public\".\"orders\""
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user