Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ tusk 'postgres://user:pass@localhost:5432/mydb'
tusk -P production
```

**First run:** if no connection can be established — no config yet, an unknown
profile (`tusk -P nope`), or an unreachable database — tusk drops into an
interactive setup screen instead of erroring out. Enter a connection (or a
tunnel, see below), and tusk validates it and optionally saves it as a profile
so the next launch connects straight through.

## Daemon

`tuskd` runs a profile's rules engine headlessly — the same rules, evaluation, and
Expand Down Expand Up @@ -142,6 +148,56 @@ profiles:

**Lock**: `blocked_pid`, `blocking_pid`, `blocked_user`, `blocking_user`, `blocked_app`, `blocking_app`, `lock_type`, `mode`, `wait_duration`

## Connecting to remote / VPC databases

Production and staging databases usually aren't reachable directly — they live
inside a VPC. A profile's `connect:` block tells tusk how to reach them, and tusk
establishes the tunnel itself (and tears it down on exit). This works for both
`tusk` and the `tuskd` daemon.

**Kubernetes port-forward** (e.g. Postgres in a GKE cluster) — tusk runs
`kubectl port-forward` for you:

```yaml
profiles:
staging:
connect:
via: kube-port-forward
context: gke_myproject_us-central1_staging # optional; omit for current context
namespace: databases
target: svc/postgres # svc/pod/deployment/statefulset
remote_port: 5432
# local_port: 0 # 0 = auto-pick a free port
user: readonly
database: appdb
sslmode: disable
```

**Arbitrary tunnel command** (`exec`) — the escape hatch for SSH bastions, the
Cloud SQL Auth Proxy, or anything else. `{local_port}` is substituted with an
auto-picked free port that tusk then connects to:

```yaml
profiles:
bastion:
connect:
via: exec
command: ["ssh", "-N", "-L", "{local_port}:db.internal:5432", "bastion.example.com"]
user: readonly
database: appdb
cloudsql:
connect:
via: exec
command: ["cloud-sql-proxy", "--port", "{local_port}", "myproj:us-central1:pg"]
user: readonly
database: appdb
```

For tunnel methods, credentials and the database name come from the profile
fields (`user`/`password`/`database`/`sslmode`); host and port are supplied by
the tunnel. The default method is `direct`, which uses `url`/fields as-is.
`kubectl` / `cloud-sql-proxy` / `ssh` must be on your `PATH`.

## Views

| Command | Description |
Expand Down
128 changes: 91 additions & 37 deletions cmd/tusk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ import (
"github.com/spf13/cobra"

"github.com/fraser-isbester/tusk/internal/config"
"github.com/fraser-isbester/tusk/internal/connect"
"github.com/fraser-isbester/tusk/internal/db"
"github.com/fraser-isbester/tusk/internal/rules"
"github.com/fraser-isbester/tusk/internal/tui"
)

// session is a validated, live connection plus everything the TUI needs to
// render it. The closer tears down any tunnel and must run after db.Close.
type session struct {
db *db.DB
closeTunnel func() error
profileName string
color string
connUser string
readonly bool
ruleConfigs []rules.RuleConfig
}

func main() {
var profileFlag string

Expand All @@ -22,62 +35,52 @@ func main() {
Short: "Tusk — a terminal UI for PostgreSQL administration",
Long: "A k9s-style terminal UI for real-time PostgreSQL management.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(_ *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

var connStr string
var profileName string
var profileColor string
var connUser string
var readonly bool
var ruleConfigs []rules.RuleConfig

if len(args) > 0 {
connStr = args[0]
profileName = "cli"
} else {
profile, err := cfg.ResolveProfile(profileFlag)
if err != nil {
return fmt.Errorf("resolving profile: %w", err)
// Try to bring up a connection from args/profile. If we can't
// (unresolved profile, unreachable DB), drop into the setup screen
// instead of exiting — get people into the TUI ASAP.
sess, reason := establish(cfg, profileFlag, args)
if sess == nil {
res, ok := tui.RunSetup(cfg, reason)
if !ok {
return nil // user quit setup
}
connStr = profile.ConnectionString()
profileName = profileFlag
if profileName == "" {
profileName = cfg.DefaultProfile
sess = &session{
db: res.DB,
closeTunnel: res.Closer,
profileName: res.ProfileName,
color: res.Profile.Color,
connUser: res.Profile.User,
readonly: res.Profile.Readonly,
ruleConfigs: res.Profile.Rules,
}
profileColor = profile.Color
connUser = profile.User
readonly = profile.Readonly
ruleConfigs = profile.Rules
}
defer sess.db.Close()
defer func() { _ = sess.closeTunnel() }()

database, err := db.New(connStr)
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
defer database.Close()

// Query actual connection user if not set from profile
if connUser == "" {
// Query the actual connection user if the profile didn't set one.
if sess.connUser == "" {
var user string
if err := database.Pool().QueryRow(context.Background(), "SELECT current_user").Scan(&user); err == nil {
connUser = user
if err := sess.db.Pool().QueryRow(context.Background(), "SELECT current_user").Scan(&user); err == nil {
sess.connUser = user
}
}

var engine *rules.Engine
if len(ruleConfigs) > 0 {
compiled, err := rules.BuildRules(ruleConfigs, readonly)
if len(sess.ruleConfigs) > 0 {
compiled, err := rules.BuildRules(sess.ruleConfigs, sess.readonly)
if err != nil {
return fmt.Errorf("compiling rules: %w", err)
}
engine = rules.NewEngine(compiled, database, 5*time.Minute, 1000)
engine = rules.NewEngine(compiled, sess.db, 5*time.Minute, 1000)
}

app := tui.NewApp(database, cfg, profileName, profileColor, connUser, readonly, engine)
app := tui.NewApp(sess.db, cfg, sess.profileName, sess.color, sess.connUser, sess.readonly, engine)
if err := app.Run(); err != nil {
return fmt.Errorf("running tusk: %w", err)
}
Expand All @@ -91,3 +94,54 @@ func main() {
os.Exit(1)
}
}

// establish attempts to open and validate a connection. It returns a live
// session on success, or (nil, reason) when the caller should fall back to the
// interactive setup screen — reason explains why (shown to the user).
func establish(cfg *config.Config, profileFlag string, args []string) (*session, string) {
if len(args) > 0 {
return connectProfile(config.Profile{URL: args[0]}, "cli")
}

profile, err := cfg.ResolveProfile(profileFlag)
if err != nil {
return nil, err.Error()
}
name := profileFlag
if name == "" {
name = cfg.DefaultProfile
}
return connectProfile(profile, name)
}

// connectProfile opens any tunnel, builds the pool, and validates it with a
// ping. On any failure it tears everything down and returns a reason string so
// the caller can offer setup.
func connectProfile(profile config.Profile, name string) (*session, string) {
dsn, closer, err := connect.Open(context.Background(), profile)
if err != nil {
return nil, err.Error()
}
database, err := db.New(dsn)
if err != nil {
_ = closer()
return nil, err.Error()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := database.Pool().Ping(ctx); err != nil {
database.Close()
_ = closer()
return nil, fmt.Sprintf("could not connect to %q: %s", name, err)
}

return &session{
db: database,
closeTunnel: closer,
profileName: name,
color: profile.Color,
connUser: profile.User,
readonly: profile.Readonly,
ruleConfigs: profile.Rules,
}, ""
}
11 changes: 10 additions & 1 deletion cmd/tuskd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"

"github.com/fraser-isbester/tusk/internal/config"
"github.com/fraser-isbester/tusk/internal/connect"
"github.com/fraser-isbester/tusk/internal/db"
"github.com/fraser-isbester/tusk/internal/rules"
)
Expand Down Expand Up @@ -52,7 +53,15 @@ func main() {
return fmt.Errorf("no rules configured in profile %q", profileName)
}

database, err := db.New(profile.ConnectionString())
// Establish any tunnel (e.g. kube-port-forward into a VPC) before
// connecting; the daemon needs the same reachability as the TUI.
dsn, closeTunnel, err := connect.Open(context.Background(), profile)
if err != nil {
return fmt.Errorf("establishing connection: %w", err)
}
defer func() { _ = closeTunnel() }()

database, err := db.New(dsn)
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/fraser-isbester/tusk

go 1.26.4
go 1.26.5

require (
github.com/gdamore/tcell/v2 v2.13.9
Expand Down
Loading