Skip to content
Open
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
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,30 @@ Offline tools (`lint`, `check_migration`, `drift`) work immediately after the pu

No server, no credentials. Same promise as before.

### Push snapshots to an OCI registry

Any OCI registry can hold snapshots: GitHub Container Registry, Google Artifact Registry, Amazon ECR, Docker Hub, Harbor, or a self-hosted one. The registry handles authentication, retention, and access control, so there is no server to run.

Authenticate the same way you would for `docker push`, register the remote, and push:

```sh
docker login ghcr.io
dryrun remote add ghcr --ref ghcr.io/myorg/dryrun --default
dryrun snapshot take --push
```

`snapshot take --push` captures and publishes in one step. Consumers pull:

```sh
dryrun snapshot pull --remote ghcr
```

`pull` fetches only the latest take by default, so cold pulls (fresh CI, empty `history.db`) stay cheap regardless of how much history the registry holds. Use `--full` to backfill the entire history, or `--since 7d` (also `2w`, `24h`, or a UTC date like `2026-01-01`) for a window. `push` always sends your full local history; since it is incremental by content hash, an owner that pushes on a cadence only uploads the new observations each run.

`--ref` is the registry base. Each database gets its own repository under it, `<ref>/<project_id>/<database_id>`, so `myapp`'s `auth` database lands at `ghcr.io/myorg/dryrun/myapp/auth`. Snapshots map to OCI artifacts addressed by content hash, so pushing the same one twice changes nothing and shared blobs deduplicate on the registry. For Google Artifact Registry, run `gcloud auth configure-docker us-docker.pkg.dev` in place of `docker login`; the rest is identical.

See [`docs/dryrun-toml.md`](docs/dryrun-toml.md) for per-profile remotes and sharing one stream across projects.

## MCP server

Add `dryrun` to your AI assistant. If you installed via Homebrew, `dryrun` is already on your PATH:
Expand Down Expand Up @@ -285,17 +309,14 @@ See the [Tutorial](TUTORIAL.md) for live database setup, SSE transport, and Clau

- **[Tutorial](TUTORIAL.md)** for offline, online, and multi-node workflows with full tool reference
- **[Multi-node statistics](docs/multi-node-stats.md)** for cluster-wide stats collection, aggregation rules, and replica imbalance detection
- **[Configuration reference](docs/dryrun-toml.md)** for `dryrun.toml` profiles, conventions, and lint rules
- **[Configuration reference](docs/dryrun-toml.md)** for `dryrun.toml` profiles, conventions, remotes, and lint rules
- **[CLI stability](docs/cli-stability.md)** for which commands are stable versus experimental
- **[Security overview](SECURITY.md)** for the CLI/MCP split and masking
- **[boringSQL](https://boringsql.com)**, the blog and project home
- **[dryrun project page](https://boringsql.com/products/dryrun/)**, overview and docs
- **[Don't let AI touch your production database](https://boringsql.com/posts/dont-let-ai-to-prod/)**, why most Postgres MCPs are unsafe and what `dryrun` does differently
- **[RegreSQL](https://github.com/boringsql/regresql)**, SQL regression testing and **`dryrun`**'s companion tool


## Upgrading from 0.5.x

- `dump-schema --stats-only` is removed. Use `dryrun snapshot take` (primary) and `dryrun snapshot activity` (replicas).
- Snapshot JSON no longer embeds `Table.stats`, `Column.stats`, `Index.stats`, or `node_stats`. Stats are read per-kind from the history db via `HistoryStore::get_annotated`.
- `check_drift` is now schema-only. It no longer flaps when `reltuples` or `idx_scan` change.
- **[Fixturize](https://github.com/boringSQL/fixturize)**, subset and mask production data for dev/test

## License

Expand Down
16 changes: 15 additions & 1 deletion cmd/dryrun/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func main() {
root.AddCommand(
probeCmd(), initCmd(), importCmd(), dumpSchemaCmd(),
lintCmd(), driftCmd(), snapshotCmd(), profileCmd(),
mcpServeCmd(), statsCmd(), versionCmd(),
remoteCmd(), mcpServeCmd(), statsCmd(), versionCmd(),
)

if err := root.Execute(); err != nil {
Expand Down Expand Up @@ -331,6 +331,10 @@ func snapshotCmd() *cobra.Command {
c.Flags().StringVar(&historyDB, "history-db", "", "history database path")
}

var (
pushAfter bool
pushRemote string
)
takeCmd := &cobra.Command{
Use: "take",
Short: "Take a new snapshot (schema + planner + activity; primary only)",
Expand Down Expand Up @@ -375,13 +379,23 @@ func snapshotCmd() *cobra.Command {
}
fmt.Printf("Activity stats saved: %s (label=primary, %d tables, %d indexes)\n",
activity.ContentHash, len(activity.Tables), len(activity.Indexes))

if pushAfter {
dst, err := resolveSyncStore("", "", pushRemote)
if err != nil {
return err
}
return runSync(cmd.Context(), store, dst, false, fullScope(), os.Stdout)
}
return nil
},
}
addHistFlag(takeCmd)
takeCmd.Flags().StringVar(&flagMasksFile, "masks-file", "", "path to data-masking-policy.yml")
takeCmd.Flags().StringSliceVar(&flagMaskPolicy, "mask-policy", nil, "masking policy name (repeatable, comma-separated)")
takeCmd.Flags().BoolVar(&flagNoMasks, "no-masks", false, "disable planner-stats masking (raw stats land in history.db)")
takeCmd.Flags().BoolVar(&pushAfter, "push", false, "push the snapshot to a remote after capture")
takeCmd.Flags().StringVar(&pushRemote, "remote", "", "configured [[remote]] name (with --push)")

listCmd := &cobra.Command{
Use: "list",
Expand Down
171 changes: 171 additions & 0 deletions cmd/dryrun/remote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package main

import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/boringsql/dryrun/internal/config"
)

func remoteCmd() *cobra.Command {
cmd := &cobra.Command{Use: "remote", Short: "Manage [[remote]] entries in dryrun.toml"}
cmd.AddCommand(remoteAddCmd(), remoteListCmd(), remoteRmCmd())
return cmd
}

func remoteListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured remotes",
RunE: func(cmd *cobra.Command, args []string) error {
path, cfg, err := loadProjectConfig()
if err != nil {
return err
}
fmt.Printf("Config: %s\n", path)
if len(cfg.Remotes) == 0 {
fmt.Println("No remotes configured.")
return nil
}
for _, r := range cfg.Remotes {
def := ""
if r.Default {
def = " (default)"
}
fmt.Printf(" %s %s %s%s\n", r.Name, r.Type, r.Ref, def)
}
return nil
},
}
}

func remoteAddCmd() *cobra.Command {
var (
typ, ref, tokenEnv string
isDefault bool
)
cmd := &cobra.Command{
Use: "add <name>",
Short: "Add a remote to dryrun.toml",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if ref == "" {
return fmt.Errorf("--ref is required")
}
path, cfg, err := loadProjectConfig()
if err != nil {
return err
}
for _, r := range cfg.Remotes {
if r.Name == name {
return fmt.Errorf("remote %q already exists", name)
}
}
block := remoteBlock(config.RemoteConfig{
Name: name, Type: typ, Ref: ref, TokenEnv: tokenEnv, Default: isDefault,
})
data, err := os.ReadFile(path)
if err != nil {
return err
}
out := strings.TrimRight(string(data), "\n") + "\n" + block
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
return err
}
fmt.Printf("Added remote %q -> %s\n", name, ref)
return nil
},
}
cmd.Flags().StringVar(&typ, "type", "oci", "remote type")
cmd.Flags().StringVar(&ref, "ref", "", "registry base ref (e.g. ghcr.io/org/dryrun)")
cmd.Flags().StringVar(&tokenEnv, "token-env", "", "env var holding a bearer token")
cmd.Flags().BoolVar(&isDefault, "default", false, "mark as the default remote")
return cmd
}

func remoteRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <name>",
Short: "Remove a remote from dryrun.toml",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
path, _, err := loadProjectConfig()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
out, removed := removeRemoteBlock(string(data), name)
if !removed {
return fmt.Errorf("remote %q not found", name)
}
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
return err
}
fmt.Printf("Removed remote %q\n", name)
return nil
},
}
}

func remoteBlock(r config.RemoteConfig) string {
var b strings.Builder
b.WriteString("\n[[remote]]\n")
fmt.Fprintf(&b, "name = %q\n", r.Name)
fmt.Fprintf(&b, "type = %q\n", r.Type)
fmt.Fprintf(&b, "ref = %q\n", r.Ref)
if r.TokenEnv != "" {
fmt.Fprintf(&b, "token_env = %q\n", r.TokenEnv)
}
if r.Default {
b.WriteString("default = true\n")
}
return b.String()
}

// drops the [[remote]] block whose name matches, plus any blank lines before it.
// a block runs from its [[remote]] header to the next table header or EOF.
func removeRemoteBlock(content, name string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
for i := 0; i < len(lines); {
if strings.TrimSpace(lines[i]) == "[[remote]]" {
j := i + 1
for j < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[j]), "[") {
j++
}
if blockHasName(lines[i:j], name) {
for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
out = out[:len(out)-1]
}
removed = true
i = j
continue
}
out = append(out, lines[i:j]...)
i = j
continue
}
out = append(out, lines[i])
i++
}
return strings.Join(out, "\n"), removed
}

func blockHasName(block []string, name string) bool {
for _, l := range block {
k, v, ok := strings.Cut(l, "=")
if ok && strings.TrimSpace(k) == "name" && strings.Trim(strings.TrimSpace(v), `"'`) == name {
return true
}
}
return false
}
Loading