diff --git a/.claude/settings.json b/.claude/settings.json index 9d9ef2c..7ee4cd8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -36,14 +36,14 @@ "deny": [ "Read(./.env)", "Read(./.env.*)", - "Read(./secrets/**)", + "Read(/secrets/**)", "Read(./config/credentials.json)", "Read(./**/*.key)", "Read(./**/*.pem)", "Read(./**/*.crt)", "Write(./.env)", "Write(./.env.*)", - "Write(./secrets/**)", + "Write(/secrets/**)", "Write(./config/credentials.json)", "Write(./**/*.key)", "Write(./**/*.pem)", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..166bbcd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: stable + cache: true + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + + test: + name: Test (${{ matrix.os }}) + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: stable + cache: true + - name: go vet + run: go vet ./... + - name: go test + run: go test ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..25219a8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,67 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - misspell + - staticcheck + - unused + - depguard + # Note: revive is intentionally NOT enabled in Phase A. Its `exported` + # rule would require doc comments on every exported symbol, which is + # bikeshedding at scaffold stage. Reconsider in a later phase. + settings: + depguard: + rules: + cli-may-not-import-domain: + list-mode: lax + files: + - "**/internal/cli/**" + - "!**/_test.go" + deny: + - pkg: "github.com/nixrajput/siphon/internal/driver" + desc: "CLI must go through internal/app, not Driver directly" + tui-may-not-import-cli: + list-mode: lax + files: + - "**/internal/tui/**" + - "!**/_test.go" + deny: + - pkg: "github.com/nixrajput/siphon/internal/cli" + desc: "TUI and CLI are siblings and may not depend on each other" + cli-may-not-import-tui-except-root: + list-mode: lax + files: + - "**/internal/cli/**" + - "!**/internal/cli/root.go" + - "!**/_test.go" + deny: + - pkg: "github.com/nixrajput/siphon/internal/tui" + desc: "Only the root command may launch the TUI" + driver-may-not-import-presentation: + list-mode: lax + files: + - "**/internal/driver/**" + - "!**/_test.go" + deny: + - pkg: "github.com/nixrajput/siphon/internal/cli" + desc: "Drivers must not depend on presentation" + - pkg: "github.com/nixrajput/siphon/internal/tui" + desc: "Drivers must not depend on presentation" + - pkg: "github.com/nixrajput/siphon/internal/app" + desc: "Drivers must not depend on Application — dependency flows down" + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3caf6c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +lint: ## Run golangci-lint + golangci-lint run + +test: ## Run unit tests + go test ./... + +test-verbose: ## Run unit tests verbosely + go test -v ./... + +test-integration: ## Run integration tests (build tag: integration) + go test -tags=integration ./... + +build: ## Build the siphon binary into ./bin + @mkdir -p bin + go build -ldflags "-s -w -X github.com/nixrajput/siphon/internal/cli.Version=$$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o bin/siphon ./cmd/siphon + +run: ## Run siphon from source (bare invocation -> TUI) + go run ./cmd/siphon + +install: ## Install siphon to GOBIN + go install ./cmd/siphon + +tidy: ## Run go mod tidy + go mod tidy + +clean: ## Remove build artifacts + rm -rf bin/ dist/ coverage.out coverage.html + +.PHONY: help lint test test-verbose test-integration build run install tidy clean diff --git a/cmd/siphon/main.go b/cmd/siphon/main.go new file mode 100644 index 0000000..c10ee27 --- /dev/null +++ b/cmd/siphon/main.go @@ -0,0 +1,11 @@ +// Command siphon is the CLI entry point. All real work lives in +// internal/cli; this file exists only to satisfy the Go executable layout. +package main + +import ( + "os" + + "github.com/nixrajput/siphon/internal/cli" +) + +func main() { os.Exit(cli.Execute()) } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6b01094 --- /dev/null +++ b/go.mod @@ -0,0 +1,89 @@ +module github.com/nixrajput/siphon + +go 1.26.3 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/oklog/ulid/v2 v2.1.1 + github.com/spf13/cobra v1.10.2 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aaa4f9c --- /dev/null +++ b/go.sum @@ -0,0 +1,209 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/app/backup.go b/internal/app/backup.go new file mode 100644 index 0000000..c4fa215 --- /dev/null +++ b/internal/app/backup.go @@ -0,0 +1,140 @@ +// Package app implements siphon's verbs over the domain layer. +// CLI and TUI both call into this package; neither cares about the other. +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" +) + +// Deps bundles every dependency the app verbs need. CLI and TUI build +// one Deps at startup and pass it to every verb. Makes mocking trivial. +type Deps struct { + Profiles *profile.Store + Dumps *dumps.Catalog + Runner *jobs.Runner + Drivers DriverGetter +} + +// DriverGetter is satisfied by internal/driver.Get. Wrapped to allow mocking. +type DriverGetter interface { + Get(name string) (driver.Driver, error) +} + +// BackupOpts configures the Backup verb. +type BackupOpts struct { + Profile string + IncludeTables []string + ExcludeTables []string + ExcludeDataFrom []string + SchemaOnly bool + DataOnly bool + CompressionLevel int + Parallel int +} + +// Backup dumps the source profile to a new entry in the catalog. +// Returns the running job's Event channel and ID. +func Backup(parent context.Context, d Deps, opt BackupOpts) (<-chan jobs.Event, string, error) { + resolved, err := d.Profiles.Resolve(opt.Profile) + if err != nil { + return nil, "", err + } + drv, err := d.Drivers.Get(resolved.Driver) + if err != nil { + return nil, "", err + } + + return d.Runner.Run(parent, jobs.Job{ + Stage: "backup", + Func: func(ctx context.Context, emit func(jobs.Event)) error { + conn, err := drv.Connect(ctx, resolved) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + id := ulid.Make().String() + tmpPath := filepath.Join(d.Dumps.Root(), id+".dump.tmp") + finalPath := d.Dumps.Path(id) + + f, err := os.Create(tmpPath) + if err != nil { + return err + } + h := sha256.New() + tee := io.MultiWriter(f, h) + + emit(jobs.Event{Phase: jobs.PhaseProgress, Message: "dumping"}) + + backupErr := conn.Backup(ctx, driver.BackupOpts{ + IncludeTables: opt.IncludeTables, + ExcludeTables: opt.ExcludeTables, + ExcludeDataFrom: opt.ExcludeDataFrom, + SchemaOnly: opt.SchemaOnly, + DataOnly: opt.DataOnly, + CompressionLevel: opt.CompressionLevel, + Parallel: opt.Parallel, + }, tee) + + // Close the dump file explicitly and check the error: for a file + // being WRITTEN, Close() is where buffered data is flushed and where + // late I/O failures (ENOSPC, quota, disk error) surface. Ignoring it + // could rename a truncated dump into the catalog with a checksum that + // matches the truncated bytes — corrupt-but-looks-valid. The pg_dump + // error takes precedence if both occurred. + closeErr := f.Close() + if backupErr != nil { + _ = os.Remove(tmpPath) + return backupErr + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return &errs.Error{Op: "app.backup", Code: errs.CodeSystem, Cause: closeErr, Hint: "failed to flush dump to disk (out of space?)"} + } + + if err := os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return err + } + + st, _ := os.Stat(finalPath) + size := int64(0) + if st != nil { + size = st.Size() + } + + meta := &dumps.Meta{ + ID: id, + Profile: opt.Profile, + Driver: resolved.Driver, + SizeBytes: size, + Checksum: "sha256:" + hex.EncodeToString(h.Sum(nil)), + Created: time.Now(), + DumpFormat: "custom", + } + if writeErr := d.Dumps.WriteMeta(meta); writeErr != nil { + // The catalog enumerates by sidecar metadata, so a dump without + // its meta would be an invisible orphan that never gets pruned. + _ = os.Remove(finalPath) + return writeErr + } + + emit(jobs.Event{Phase: jobs.PhaseProgress, Message: "wrote " + finalPath}) + return nil + }, + }) +} diff --git a/internal/app/backup_test.go b/internal/app/backup_test.go new file mode 100644 index 0000000..d99f277 --- /dev/null +++ b/internal/app/backup_test.go @@ -0,0 +1,158 @@ +package app + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" + "github.com/nixrajput/siphon/internal/secrets" +) + +type fakeDriver struct { + name string + payload string +} + +func (f *fakeDriver) Name() string { return f.name } +func (f *fakeDriver) Capabilities() driver.Capabilities { + return driver.Capabilities{NativeStream: true} +} +func (f *fakeDriver) Connect(_ context.Context, _ driver.Profile) (driver.Conn, error) { + return &fakeConn{payload: f.payload}, nil +} + +type fakeConn struct{ payload string } + +func (c *fakeConn) Inspect(_ context.Context) (*driver.Schema, error) { return &driver.Schema{}, nil } +func (c *fakeConn) Backup(_ context.Context, _ driver.BackupOpts, w io.Writer) error { + _, err := io.WriteString(w, c.payload) + return err +} +func (c *fakeConn) Restore(_ context.Context, _ driver.RestoreOpts, _ io.Reader) error { + return nil +} +func (c *fakeConn) Verify(_ context.Context, _ io.Reader) (*driver.VerifyReport, error) { + return &driver.VerifyReport{OK: true}, nil +} +func (c *fakeConn) Close() error { return nil } + +type fakeGetter struct{ d driver.Driver } + +func (f fakeGetter) Get(_ string) (driver.Driver, error) { return f.d, nil } + +func TestBackup_WritesDumpAndMeta(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", t.TempDir()) + + cfg := &config.Config{Profiles: map[string]config.ProfileConfig{ + "test": {Driver: "fake", Host: "h", User: "u", Database: "d", Password: "p"}, + }} + res := secrets.NewResolver(secrets.Passthrough{}) + ps := profile.New(cfg, res, func(*config.Config) error { return nil }) + cat, _ := dumps.NewCatalog(dir) + runner := jobs.NewRunner() + + deps := Deps{ + Profiles: ps, + Dumps: cat, + Runner: runner, + Drivers: fakeGetter{d: &fakeDriver{name: "fake", payload: "hello-dump"}}, + } + + ch, _, err := Backup(context.Background(), deps, BackupOpts{Profile: "test"}) + if err != nil { + t.Fatalf("Backup: %v", err) + } + for range ch { /* drain */ + } + + got, err := cat.List() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("catalog.List() = %d entries; want 1", len(got)) + } + if !strings.HasPrefix(got[0].Checksum, "sha256:") { + t.Fatalf("Checksum = %q; want sha256: prefix", got[0].Checksum) + } +} + +// failBackupConn fails mid-dump, exercising the backup-error branch that must +// remove the temp file and create no catalog entry. +type failBackupConn struct{ fakeConn } + +func (failBackupConn) Backup(_ context.Context, _ driver.BackupOpts, _ io.Writer) error { + return errors.New("pg_dump exploded") +} + +type failBackupDriver struct{} + +func (failBackupDriver) Name() string { return "fake" } +func (failBackupDriver) Capabilities() driver.Capabilities { return driver.Capabilities{} } +func (failBackupDriver) Connect(_ context.Context, _ driver.Profile) (driver.Conn, error) { + return &failBackupConn{}, nil +} + +func TestBackup_DumpError_CleansTmpAndWritesNoCatalogEntry(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", t.TempDir()) + + cfg := &config.Config{Profiles: map[string]config.ProfileConfig{ + "test": {Driver: "fake", Host: "h", User: "u", Database: "d", Password: "p"}, + }} + res := secrets.NewResolver(secrets.Passthrough{}) + ps := profile.New(cfg, res, func(*config.Config) error { return nil }) + cat, _ := dumps.NewCatalog(dir) + + deps := Deps{ + Profiles: ps, + Dumps: cat, + Runner: jobs.NewRunner(), + Drivers: fakeGetter{d: failBackupDriver{}}, + } + + ch, _, err := Backup(context.Background(), deps, BackupOpts{Profile: "test"}) + if err != nil { + t.Fatalf("Backup setup: %v", err) + } + var lastErr error + for e := range ch { + if e.Err != nil { + lastErr = e.Err + } + } + if lastErr == nil { + t.Fatal("expected a failure event carrying the backup error") + } + + // No catalog entry should have been recorded. + got, err := cat.List() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("catalog.List() = %d; want 0 on backup failure", len(got)) + } + + // No leftover .dump or .dump.tmp files should remain in the catalog dir. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, de := range entries { + name := de.Name() + if strings.HasSuffix(name, ".dump") || strings.HasSuffix(name, ".dump.tmp") { + t.Fatalf("leftover artifact after failed backup: %s", filepath.Join(dir, name)) + } + } +} diff --git a/internal/app/drivers.go b/internal/app/drivers.go new file mode 100644 index 0000000..3c8a49b --- /dev/null +++ b/internal/app/drivers.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/nixrajput/siphon/internal/driver" + _ "github.com/nixrajput/siphon/internal/driver/postgres" // register the postgres driver +) + +// DefaultDrivers returns a DriverGetter backed by the global driver registry. +// Presentation layers (CLI, TUI) use this so they never import internal/driver +// directly — dependency flows through the application layer. +func DefaultDrivers() DriverGetter { + return registryGetter{} +} + +type registryGetter struct{} + +func (registryGetter) Get(name string) (driver.Driver, error) { + return driver.Get(name) +} diff --git a/internal/app/inspect.go b/internal/app/inspect.go new file mode 100644 index 0000000..b105675 --- /dev/null +++ b/internal/app/inspect.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + + "github.com/nixrajput/siphon/internal/driver" +) + +// Inspect returns the live schema for the named profile. +func Inspect(ctx context.Context, d Deps, profile string) (*driver.Schema, error) { + resolved, err := d.Profiles.Resolve(profile) + if err != nil { + return nil, err + } + drv, err := d.Drivers.Get(resolved.Driver) + if err != nil { + return nil, err + } + conn, err := drv.Connect(ctx, resolved) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + return conn.Inspect(ctx) +} diff --git a/internal/app/restore.go b/internal/app/restore.go new file mode 100644 index 0000000..6b5f760 --- /dev/null +++ b/internal/app/restore.go @@ -0,0 +1,57 @@ +package app + +import ( + "context" + "os" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/jobs" +) + +// RestoreOpts configures the Restore verb. +type RestoreOpts struct { + Profile string + DumpID string + TargetTables []string + SchemaOnly bool + DataOnly bool + Clean bool +} + +// Restore replays a dump from the catalog into the target profile. +func Restore(parent context.Context, d Deps, opt RestoreOpts) (<-chan jobs.Event, string, error) { + resolved, err := d.Profiles.Resolve(opt.Profile) + if err != nil { + return nil, "", err + } + drv, err := d.Drivers.Get(resolved.Driver) + if err != nil { + return nil, "", err + } + + return d.Runner.Run(parent, jobs.Job{ + Stage: "restore", + Func: func(ctx context.Context, emit func(jobs.Event)) error { + conn, err := drv.Connect(ctx, resolved) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + f, err := os.Open(d.Dumps.Path(opt.DumpID)) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + emit(jobs.Event{Phase: jobs.PhaseProgress, Message: "restoring " + opt.DumpID}) + + return conn.Restore(ctx, driver.RestoreOpts{ + TargetTables: opt.TargetTables, + SchemaOnly: opt.SchemaOnly, + DataOnly: opt.DataOnly, + Clean: opt.Clean, + }, f) + }, + }) +} diff --git a/internal/app/retry.go b/internal/app/retry.go new file mode 100644 index 0000000..3b7d065 --- /dev/null +++ b/internal/app/retry.go @@ -0,0 +1,34 @@ +package app + +import ( + "context" + "math/rand" + "time" +) + +// Retry runs op with exponential backoff + jitter, up to maxAttempts. +// Returns the last error if all attempts fail. +func Retry(ctx context.Context, maxAttempts int, op func() error) error { + var err error + delay := 100 * time.Millisecond + for attempt := 1; attempt <= maxAttempts; attempt++ { + err = op() + if err == nil { + return nil + } + if attempt == maxAttempts { + return err + } + jitter := time.Duration(rand.Int63n(int64(delay) / 2)) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay + jitter): + } + delay *= 2 + if delay > 4*time.Second { + delay = 4 * time.Second + } + } + return err +} diff --git a/internal/app/roundtrip_test.go b/internal/app/roundtrip_test.go new file mode 100644 index 0000000..3debe6d --- /dev/null +++ b/internal/app/roundtrip_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "context" + "io" + "testing" + "time" + + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" + "github.com/nixrajput/siphon/internal/secrets" +) + +// rtFakeConn writes payload on Backup and captures whatever Restore reads, so a +// test can assert the bytes survive the catalog file round-trip. +type rtFakeConn struct { + payload string + restored []byte +} + +func (c *rtFakeConn) Inspect(_ context.Context) (*driver.Schema, error) { + return &driver.Schema{}, nil +} + +func (c *rtFakeConn) Backup(_ context.Context, _ driver.BackupOpts, w io.Writer) error { + _, err := io.WriteString(w, c.payload) + return err +} + +func (c *rtFakeConn) Restore(_ context.Context, _ driver.RestoreOpts, r io.Reader) error { + b, err := io.ReadAll(r) + c.restored = b + return err +} + +func (c *rtFakeConn) Verify(_ context.Context, _ io.Reader) (*driver.VerifyReport, error) { + return &driver.VerifyReport{OK: true}, nil +} + +func (c *rtFakeConn) Close() error { return nil } + +// rtFakeDriver always hands back the same rtFakeConn so the test can inspect it. +type rtFakeDriver struct{ conn driver.Conn } + +func (d *rtFakeDriver) Name() string { return "fake" } +func (d *rtFakeDriver) Capabilities() driver.Capabilities { + return driver.Capabilities{NativeStream: true} +} +func (d *rtFakeDriver) Connect(_ context.Context, _ driver.Profile) (driver.Conn, error) { + return d.conn, nil +} + +// TestBackupRestoreVerify_Roundtrip proves the catalog path-convention contract +// between Backup, Verify, and Restore without Docker: Backup writes a dump + +// meta, Verify recomputes the checksum and matches the stored meta.Checksum, and +// Restore reads back the exact bytes Backup wrote. +func TestBackupRestoreVerify_Roundtrip(t *testing.T) { + const payload = "roundtrip-payload" + + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", t.TempDir()) + + cfg := &config.Config{Profiles: map[string]config.ProfileConfig{ + "test": {Driver: "fake", Host: "h", User: "u", Database: "d", Password: "p"}, + }} + res := secrets.NewResolver(secrets.Passthrough{}) + ps := profile.New(cfg, res, func(*config.Config) error { return nil }) + cat, _ := dumps.NewCatalog(dir) + runner := jobs.NewRunner() + + conn := &rtFakeConn{payload: payload} + deps := Deps{ + Profiles: ps, + Dumps: cat, + Runner: runner, + Drivers: fakeGetter{d: &rtFakeDriver{conn: conn}}, + } + + ctx := context.Background() + + // 1. Backup. + bch, _, err := Backup(ctx, deps, BackupOpts{Profile: "test"}) + if err != nil { + t.Fatalf("Backup: %v", err) + } + drain(t, bch) + + entries, err := cat.List() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("catalog.List() = %d entries; want 1", len(entries)) + } + id := entries[0].ID + + // 2. Verify — file on disk must match the meta.Checksum Backup wrote. + report, err := Verify(ctx, deps, id) + if err != nil { + t.Fatalf("Verify: unexpected error: %v", err) + } + if report == nil || !report.OK { + t.Fatalf("Verify: report=%+v; want non-nil OK report", report) + } + + // 3. Restore — must open the same path Backup wrote. + rch, _, err := Restore(ctx, deps, RestoreOpts{Profile: "test", DumpID: id}) + if err != nil { + t.Fatalf("Restore: %v", err) + } + drain(t, rch) + + // 4. Byte-level round-trip through the catalog file. + if got := string(conn.restored); got != payload { + t.Fatalf("Restore read %q; want %q", got, payload) + } +} + +// drain consumes a job event channel with a hard timeout. +func drain(t *testing.T, ch <-chan jobs.Event) { + t.Helper() + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-timer.C: + t.Fatal("job did not complete within 5 s") + } + } +} diff --git a/internal/app/sync.go b/internal/app/sync.go new file mode 100644 index 0000000..2792aea --- /dev/null +++ b/internal/app/sync.go @@ -0,0 +1,79 @@ +package app + +import ( + "context" + "io" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/jobs" +) + +// SyncOpts configures the Sync verb. +type SyncOpts struct { + From string + To string + Stream bool + Tables []string +} + +// Sync backs up From and restores into To. In Phase B the data always flows +// through an in-memory io.Pipe (no temp file, no catalog entry). The opt.Stream +// flag and Capabilities().NativeStream gating — and a catalog fallback for +// drivers that can't stream — are planned for Phase F; today Stream is unused. +func Sync(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, string, error) { + src, err := d.Profiles.Resolve(opt.From) + if err != nil { + return nil, "", err + } + dst, err := d.Profiles.Resolve(opt.To) + if err != nil { + return nil, "", err + } + srcDrv, err := d.Drivers.Get(src.Driver) + if err != nil { + return nil, "", err + } + dstDrv, err := d.Drivers.Get(dst.Driver) + if err != nil { + return nil, "", err + } + + return d.Runner.Run(parent, jobs.Job{ + Stage: "sync", + Func: func(ctx context.Context, emit func(jobs.Event)) error { + srcConn, err := srcDrv.Connect(ctx, src) + if err != nil { + return err + } + defer func() { _ = srcConn.Close() }() + + dstConn, err := dstDrv.Connect(ctx, dst) + if err != nil { + return err + } + defer func() { _ = dstConn.Close() }() + + pr, pw := io.Pipe() + errCh := make(chan error, 1) + + go func() { + bErr := srcConn.Backup(ctx, driver.BackupOpts{IncludeTables: opt.Tables}, pw) + // CloseWithError propagates a backup failure to the reader as a + // read error instead of a clean io.EOF. Without this, a truncated + // dump looks complete to Restore — which, with Clean:true, has + // already dropped the target and would commit partial data. + _ = pw.CloseWithError(bErr) + errCh <- bErr + }() + + restoreErr := dstConn.Restore(ctx, driver.RestoreOpts{TargetTables: opt.Tables, Clean: true}, pr) + _ = pr.Close() // unblock the backup goroutine's pw.Write immediately if Restore returned early + backupErr := <-errCh + + if backupErr != nil { + return backupErr + } + return restoreErr + }, + }) +} diff --git a/internal/app/sync_test.go b/internal/app/sync_test.go new file mode 100644 index 0000000..c09e849 --- /dev/null +++ b/internal/app/sync_test.go @@ -0,0 +1,130 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "time" + + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" + "github.com/nixrajput/siphon/internal/secrets" +) + +// syncFakeConn is a fakeConn variant whose Restore behaviour is configurable. +// Backup writes a payload large enough (>64 KiB) to fill the kernel pipe buffer +// so that pw.Write would block indefinitely if pr is never closed. +type syncFakeConn struct { + restoreErr error + backupData []byte +} + +func (c *syncFakeConn) Inspect(_ context.Context) (*driver.Schema, error) { + return &driver.Schema{}, nil +} + +func (c *syncFakeConn) Backup(_ context.Context, _ driver.BackupOpts, w io.Writer) error { + _, err := io.Copy(w, bytes.NewReader(c.backupData)) + return err +} + +func (c *syncFakeConn) Restore(_ context.Context, _ driver.RestoreOpts, _ io.Reader) error { + // Return immediately without reading the pipe — simulates an early error. + return c.restoreErr +} + +func (c *syncFakeConn) Verify(_ context.Context, _ io.Reader) (*driver.VerifyReport, error) { + return &driver.VerifyReport{OK: true}, nil +} + +func (c *syncFakeConn) Close() error { return nil } + +// syncFakeDriver always returns the same syncFakeConn for every Connect call. +type syncFakeDriver struct { + conn driver.Conn +} + +func (d *syncFakeDriver) Name() string { return "syncfake" } +func (d *syncFakeDriver) Capabilities() driver.Capabilities { + return driver.Capabilities{NativeStream: true} +} +func (d *syncFakeDriver) Connect(_ context.Context, _ driver.Profile) (driver.Conn, error) { + return d.conn, nil +} + +// syncDualGetter returns the src driver for "src" and the dst driver for "dst". +type syncDualGetter struct { + src driver.Driver + dst driver.Driver +} + +func (g syncDualGetter) Get(name string) (driver.Driver, error) { + if name == "dst" { + return g.dst, nil + } + return g.src, nil +} + +// TestSync_PipeReaderClosed_NoGoroutineLeak verifies that when Restore returns +// an error immediately without draining the pipe, Sync still completes (the job +// channel closes) within a short timeout. Before the pr.Close() fix the backup +// goroutine's pw.Write would block forever, causing Sync to hang. +func TestSync_PipeReaderClosed_NoGoroutineLeak(t *testing.T) { + // 128 KiB > typical pipe buffer (64 KiB on Linux/macOS), so pw.Write + // blocks if pr is not closed after Restore returns early. + bigPayload := make([]byte, 128*1024) + + restoreErr := errors.New("restore failed intentionally") + + srcConn := &syncFakeConn{backupData: bigPayload} + dstConn := &syncFakeConn{restoreErr: restoreErr} + + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", t.TempDir()) + + cfg := &config.Config{Profiles: map[string]config.ProfileConfig{ + "src": {Driver: "src", Host: "h", User: "u", Database: "d", Password: "p"}, + "dst": {Driver: "dst", Host: "h", User: "u", Database: "d", Password: "p"}, + }} + res := secrets.NewResolver(secrets.Passthrough{}) + ps := profile.New(cfg, res, func(*config.Config) error { return nil }) + cat, _ := dumps.NewCatalog(dir) + runner := jobs.NewRunner() + + deps := Deps{ + Profiles: ps, + Dumps: cat, + Runner: runner, + Drivers: syncDualGetter{ + src: &syncFakeDriver{conn: srcConn}, + dst: &syncFakeDriver{conn: dstConn}, + }, + } + + ch, _, err := Sync(context.Background(), deps, SyncOpts{From: "src", To: "dst"}) + if err != nil { + t.Fatalf("Sync setup: %v", err) + } + + // Drain the event channel with a hard timeout. If pr.Close() is absent, + // the backup goroutine blocks on pw.Write and the job never finishes. + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + + for { + select { + case _, ok := <-ch: + if !ok { + // Channel closed — job finished; goroutine did not leak. + return + } + case <-timer.C: + t.Fatal("Sync did not complete within 5 s — probable goroutine leak (pr not closed)") + } + } +} diff --git a/internal/app/verify.go b/internal/app/verify.go new file mode 100644 index 0000000..b630eb5 --- /dev/null +++ b/internal/app/verify.go @@ -0,0 +1,64 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "time" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/errs" +) + +// Verify checks the integrity of a dump entry by recomputing the sha256 of the +// dump file and comparing it against the checksum recorded in the meta sidecar. +// It is stateless — no DB connection is required (Phase B checksums only; Phase +// F adds envelope-header validation via the driver's Verify method). +func Verify(_ context.Context, d Deps, dumpID string) (*driver.VerifyReport, error) { + meta, err := d.Dumps.ReadMeta(dumpID) + if err != nil { + return nil, err + } + + f, err := os.Open(d.Dumps.Path(dumpID)) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + h := sha256.New() + started := time.Now() + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + finished := time.Now() + + computed := "sha256:" + hex.EncodeToString(h.Sum(nil)) + report := &driver.VerifyReport{ + Checksum: computed, + Started: started, + Finished: finished, + } + + // Older dumps may not have a stored checksum — nothing to compare against, + // so treat as a pass. + if meta.Checksum == "" { + report.OK = true + return report, nil + } + + if computed == meta.Checksum { + report.OK = true + return report, nil + } + + report.OK = false + return report, &errs.Error{ + Op: "verify", + Code: errs.CodeIntegrity, + Cause: errs.ErrChecksumMismatch, + Hint: "dump file does not match its recorded checksum (corruption or tampering)", + } +} diff --git a/internal/cli/backup.go b/internal/cli/backup.go new file mode 100644 index 0000000..735b310 --- /dev/null +++ b/internal/cli/backup.go @@ -0,0 +1,57 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" +) + +func newBackupCmd() *cobra.Command { + var ( + profileName string + includeTables []string + excludeTables []string + excludeData []string + schemaOnly bool + dataOnly bool + parallel int + compressionLvl int + ) + cmd := &cobra.Command{ + Use: "backup [profile]", + Short: "Dump a database to a file", + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 1 { + profileName = args[0] + } + deps, err := buildDeps() + if err != nil { + return err + } + ch, _, err := app.Backup(c.Context(), deps, app.BackupOpts{ + Profile: profileName, + IncludeTables: includeTables, + ExcludeTables: excludeTables, + ExcludeDataFrom: excludeData, + SchemaOnly: schemaOnly, + DataOnly: dataOnly, + Parallel: parallel, + CompressionLevel: compressionLvl, + }) + if err != nil { + return err + } + return Heartbeat(c.ErrOrStderr(), ch) + }, + } + cmd.Flags().StringVar(&profileName, "profile", "", "Profile name (alternative to positional)") + cmd.Flags().StringSliceVar(&includeTables, "table", nil, "Only dump these tables (repeatable)") + cmd.Flags().StringSliceVar(&excludeTables, "exclude-table", nil, "Exclude tables (repeatable)") + cmd.Flags().StringSliceVar(&excludeData, "exclude-data", nil, "Keep schema but drop data for these tables") + cmd.Flags().BoolVar(&schemaOnly, "schema-only", false, "Schema, no data") + cmd.Flags().BoolVar(&dataOnly, "data-only", false, "Data, no schema") + cmd.Flags().IntVar(¶llel, "jobs", 1, "Parallel workers (not yet effective for backup; Phase F)") + cmd.Flags().IntVar(&compressionLvl, "compression", 1, "Compression level 0-9") + return cmd +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..dcfd6de --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,53 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/config" +) + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{Use: "config", Short: "Read or modify global settings"} + cmd.AddCommand(configPathCmd(), configEditCmd()) + return cmd +} + +func configPathCmd() *cobra.Command { + return &cobra.Command{ + Use: "path", Short: "Print the config file path", + RunE: func(c *cobra.Command, _ []string) error { + _, _ = fmt.Fprintln(c.OutOrStdout(), config.Path()) + return nil + }, + } +} + +func configEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "edit", Short: "Open the config file in $EDITOR", + RunE: func(c *cobra.Command, _ []string) error { + editor := defaultEditor() + if editor == "" { + editor = "vi" + } + ed := exec.Command(editor, config.Path()) + ed.Stdin = c.InOrStdin() + ed.Stdout = c.OutOrStdout() + ed.Stderr = c.ErrOrStderr() + return ed.Run() + }, + } +} + +func defaultEditor() string { + for _, env := range []string{"VISUAL", "EDITOR"} { + if v := os.Getenv(env); v != "" { + return v + } + } + return "" +} diff --git a/internal/cli/dumps.go b/internal/cli/dumps.go new file mode 100644 index 0000000..3302361 --- /dev/null +++ b/internal/cli/dumps.go @@ -0,0 +1,89 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/dumps" +) + +func newDumpsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "dumps", Short: "List, inspect, and prune saved dumps"} + cmd.AddCommand(dumpsListCmd(), dumpsInspectCmd(), dumpsPruneCmd()) + return cmd +} + +func dumpsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", Short: "List saved dumps newest first", + RunE: func(c *cobra.Command, _ []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + all, err := deps.Dumps.List() + if err != nil { + return err + } + _, _ = fmt.Fprintf(c.OutOrStdout(), "%-28s %-12s %-20s %10s\n", "ID", "PROFILE", "CREATED", "SIZE") + for _, m := range all { + _, _ = fmt.Fprintf(c.OutOrStdout(), "%-28s %-12s %-20s %10d\n", m.ID, m.Profile, m.Created.Format(time.RFC3339), m.SizeBytes) + } + return nil + }, + } +} + +func dumpsInspectCmd() *cobra.Command { + return &cobra.Command{ + Use: "inspect ", Short: "Show metadata for a dump", Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + m, err := deps.Dumps.ReadMeta(args[0]) + if err != nil { + return err + } + _, _ = fmt.Fprintf(c.OutOrStdout(), "id: %s\nprofile: %s\ndriver: %s\nformat: %s\nsize: %d bytes\nchecksum: %s\ncreated: %s\n", + m.ID, m.Profile, m.Driver, m.DumpFormat, m.SizeBytes, m.Checksum, m.Created.Format(time.RFC3339)) + return nil + }, + } +} + +func dumpsPruneCmd() *cobra.Command { + var maxAge time.Duration + var apply bool + cmd := &cobra.Command{ + Use: "prune", Short: "Show or apply retention policy", + RunE: func(c *cobra.Command, _ []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + rep, err := deps.Dumps.PruneDryRun(dumps.RetentionPolicy{MaxAge: maxAge}) + if err != nil { + return err + } + for _, m := range rep.Would { + if apply { + if delErr := deps.Dumps.Delete(m.ID); delErr != nil { + _, _ = fmt.Fprintf(c.OutOrStdout(), " ! failed to delete %s: %v\n", m.ID, delErr) + continue + } + _, _ = fmt.Fprintf(c.OutOrStdout(), " ✗ deleted %s\n", m.ID) + } else { + _, _ = fmt.Fprintf(c.OutOrStdout(), " would delete %s\n", m.ID) + } + } + return nil + }, + } + cmd.Flags().DurationVar(&maxAge, "max-age", 0, "Delete dumps older than this duration") + cmd.Flags().BoolVar(&apply, "apply", false, "Actually delete (default is dry-run)") + return cmd +} diff --git a/internal/cli/heartbeat.go b/internal/cli/heartbeat.go new file mode 100644 index 0000000..8fe2c3a --- /dev/null +++ b/internal/cli/heartbeat.go @@ -0,0 +1,39 @@ +package cli + +import ( + "fmt" + "io" + "time" + + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" +) + +// Heartbeat consumes a job Event channel and prints CLI-friendly lines +// to out. It returns the job's terminal error (nil on success) so callers +// can propagate failures to the process exit code. +func Heartbeat(out io.Writer, ch <-chan jobs.Event) error { + start := time.Now() + var resultErr error + for e := range ch { + switch e.Phase { + case jobs.PhaseStarted: + _, _ = fmt.Fprintf(out, "==> %s started\n", e.Stage) + case jobs.PhaseProgress: + if e.Message != "" { + _, _ = fmt.Fprintf(out, " • %s\n", e.Message) + } + case jobs.PhaseWarn: + _, _ = fmt.Fprintf(out, " ! %s\n", e.Message) + case jobs.PhaseDone: + _, _ = fmt.Fprintf(out, " ✓ %s done in %s\n", e.Stage, time.Since(start).Round(time.Millisecond)) + case jobs.PhaseError: + _, _ = fmt.Fprintf(out, " ✗ %s failed: %v\n", e.Stage, e.Err) + resultErr = e.Err + case jobs.PhaseCancelled: + _, _ = fmt.Fprintf(out, " ✗ %s cancelled\n", e.Stage) + resultErr = &errs.Error{Op: e.Stage, Code: errs.CodeCancelled, Cause: errs.ErrCancelled} + } + } + return resultErr +} diff --git a/internal/cli/heartbeat_test.go b/internal/cli/heartbeat_test.go new file mode 100644 index 0000000..cb4a9eb --- /dev/null +++ b/internal/cli/heartbeat_test.go @@ -0,0 +1,60 @@ +package cli + +import ( + "errors" + "io" + "testing" + + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" +) + +func TestHeartbeat_ReturnsNilOnSuccess(t *testing.T) { + ch := make(chan jobs.Event, 8) + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseStarted} + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseDone} + close(ch) + + if err := Heartbeat(io.Discard, ch); err != nil { + t.Fatalf("Heartbeat() = %v; want nil", err) + } +} + +func TestHeartbeat_ReturnsErrorOnFailure(t *testing.T) { + jobErr := &errs.Error{Op: "backup", Code: errs.CodeSystem, Cause: errs.ErrConnectionFailed} + ch := make(chan jobs.Event, 8) + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseStarted} + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseError, Err: jobErr} + close(ch) + + err := Heartbeat(io.Discard, ch) + if err == nil { + t.Fatal("Heartbeat() = nil; want error") + } + var e *errs.Error + if !errors.As(err, &e) { + t.Fatalf("Heartbeat() error = %T; want *errs.Error", err) + } + if e.Code.ExitCode() != 2 { + t.Fatalf("ExitCode() = %d; want 2", e.Code.ExitCode()) + } +} + +func TestHeartbeat_ReturnsCancelledCode(t *testing.T) { + ch := make(chan jobs.Event, 8) + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseStarted} + ch <- jobs.Event{Stage: "backup", Phase: jobs.PhaseCancelled} + close(ch) + + err := Heartbeat(io.Discard, ch) + if err == nil { + t.Fatal("Heartbeat() = nil; want error") + } + var e *errs.Error + if !errors.As(err, &e) { + t.Fatalf("Heartbeat() error = %T; want *errs.Error", err) + } + if e.Code.ExitCode() != 130 { + t.Fatalf("ExitCode() = %d; want 130", e.Code.ExitCode()) + } +} diff --git a/internal/cli/inspect.go b/internal/cli/inspect.go new file mode 100644 index 0000000..365bc54 --- /dev/null +++ b/internal/cli/inspect.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" +) + +func newInspectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect ", + Short: "Show tables, sizes, schema", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + schema, err := app.Inspect(c.Context(), deps, args[0]) + if err != nil { + return err + } + out := c.OutOrStdout() + _, _ = fmt.Fprintf(out, "%-50s %15s %15s\n", "TABLE", "ROWS", "SIZE") + for _, t := range schema.Tables { + _, _ = fmt.Fprintf(out, "%-50s %15d %15d\n", t.Name, t.Rows, t.SizeBytes) + } + return nil + }, + } + return cmd +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..7aa3546 --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/config" +) + +func newProfileCmd() *cobra.Command { + cmd := &cobra.Command{Use: "profile", Short: "Manage named connection profiles"} + cmd.AddCommand( + profileListCmd(), + profileAddCmd(), + profileRmCmd(), + profileShowCmd(), + ) + return cmd +} + +func profileListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", Short: "List profiles", + RunE: func(c *cobra.Command, _ []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + for _, n := range deps.Profiles.List() { + p, _ := deps.Profiles.Get(n) + _, _ = fmt.Fprintf(c.OutOrStdout(), "%-20s %-10s %s\n", n, p.Driver, p.Host) + } + return nil + }, + } +} + +func profileAddCmd() *cobra.Command { + var p config.ProfileConfig + var name string + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a new profile", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + name = args[0] + deps, err := buildDeps() + if err != nil { + return err + } + return deps.Profiles.Add(name, p) + }, + } + cmd.Flags().StringVar(&p.Driver, "driver", "postgres", "Driver name") + cmd.Flags().StringVar(&p.Host, "host", "", "Host") + cmd.Flags().IntVar(&p.Port, "port", 5432, "Port") + cmd.Flags().StringVar(&p.User, "user", "", "User") + cmd.Flags().StringVar(&p.Password, "password", "", "Password or SecretRef (e.g. env:VAR)") + cmd.Flags().StringVar(&p.Database, "database", "", "Database name") + cmd.Flags().StringVar(&p.SSLMode, "sslmode", "prefer", "SSL mode") + return cmd +} + +func profileRmCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm ", Short: "Remove a profile", Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + return deps.Profiles.Remove(args[0]) + }, + } +} + +func profileShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", Short: "Show a profile (secret refs shown; literal passwords masked)", Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + p, err := deps.Profiles.Get(args[0]) + if err != nil { + return err + } + _, _ = fmt.Fprintf(c.OutOrStdout(), "driver: %s\nhost: %s\nport: %d\nuser: %s\ndatabase: %s\nsslmode: %s\npassword: %s\n", + p.Driver, p.Host, p.Port, p.User, p.Database, p.SSLMode, maskSecret(p.Password)) + return nil + }, + } +} + +// maskSecret returns ref-style secrets (e.g. "env:VAR") verbatim — they are +// pointers, not the secret itself — but masks literal passwords so `show` +// never leaks a cleartext credential to stdout / shell scrollback. +func maskSecret(v string) string { + if v == "" { + return "" + } + for _, scheme := range []string{"env:", "keychain:", "vault:"} { + if strings.HasPrefix(v, scheme) { + return v + } + } + return "••••••••" +} diff --git a/internal/cli/restore.go b/internal/cli/restore.go new file mode 100644 index 0000000..7a117e0 --- /dev/null +++ b/internal/cli/restore.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" +) + +func newRestoreCmd() *cobra.Command { + var ( + profileName string + dumpID string + tables []string + schemaOnly bool + dataOnly bool + clean bool + ) + cmd := &cobra.Command{ + Use: "restore [dump-id]", + Short: "Load a dump into a database", + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 1 { + dumpID = args[0] + } + if dumpID == "" { + return fmt.Errorf("dump-id is required (positional or --dump)") + } + deps, err := buildDeps() + if err != nil { + return err + } + ch, _, err := app.Restore(c.Context(), deps, app.RestoreOpts{ + Profile: profileName, + DumpID: dumpID, + TargetTables: tables, + SchemaOnly: schemaOnly, + DataOnly: dataOnly, + Clean: clean, + }) + if err != nil { + return err + } + return Heartbeat(c.ErrOrStderr(), ch) + }, + } + cmd.Flags().StringVar(&profileName, "profile", "", "Target profile to restore into") + cmd.Flags().StringVar(&dumpID, "dump", "", "Dump ID (alternative to positional)") + cmd.Flags().StringSliceVar(&tables, "table", nil, "Restore only these tables") + cmd.Flags().BoolVar(&schemaOnly, "schema-only", false, "Schema, no data") + cmd.Flags().BoolVar(&dataOnly, "data-only", false, "Data, no schema") + cmd.Flags().BoolVar(&clean, "clean", false, "DROP objects before recreating") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..a366031 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,124 @@ +// Package cli builds the Cobra command tree. Bare invocation routes to +// the TUI; subcommands wire into the application verbs. +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" + "github.com/nixrajput/siphon/internal/secrets" + "github.com/nixrajput/siphon/internal/tui" +) + +// Version is overwritten at build time via -ldflags "-X github.com/nixrajput/siphon/internal/cli.Version=..." +var Version = "0.0.1-dev" + +// NewRoot builds a fresh root command. Out and Err allow tests to capture output. +func NewRoot(out, err io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "siphon", + Short: "Sync any database, anywhere.", + Long: "siphon — backup, restore, and synchronize databases across engines.", + Version: Version, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return tui.Run() + } + return c.Help() + }, + } + cmd.SetOut(out) + cmd.SetErr(err) + + cmd.AddCommand( + newBackupCmd(), + newRestoreCmd(), + newSyncCmd(), + newVerifyCmd(), + newInspectCmd(), + newProfileCmd(), + newDumpsCmd(), + newConfigCmd(), + newScheduleCmd(), + newTunnelCmd(), + ) + return cmd +} + +// buildDeps builds the Deps used by every verb. +func buildDeps() (app.Deps, error) { + cfg, err := config.Load() + if err != nil { + return app.Deps{}, err + } + res := secrets.NewResolver(secrets.Env{}, secrets.Passthrough{}) + ps := profile.New(cfg, res, config.Save) + + dumpDir := cfg.Defaults.DumpDir + if dumpDir == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return app.Deps{}, fmt.Errorf("resolve home dir for default dump_dir: %w", homeErr) + } + dumpDir = filepath.Join(home, ".local", "share", "siphon", "dumps") + } + cat, err := dumps.NewCatalog(dumpDir) + if err != nil { + return app.Deps{}, err + } + + return app.Deps{ + Profiles: ps, + Dumps: cat, + Runner: jobs.NewRunner(), + Drivers: app.DefaultDrivers(), + }, nil +} + +func newScheduleCmd() *cobra.Command { + return &cobra.Command{ + Use: "schedule", + Short: "Cron-managed recurring backups (Phase G)", + RunE: func(*cobra.Command, []string) error { return fmt.Errorf("schedule: not implemented (Phase G)") }, + } +} + +func newTunnelCmd() *cobra.Command { + return &cobra.Command{ + Use: "tunnel", + Short: "SSH tunnel helper (Phase G)", + RunE: func(*cobra.Command, []string) error { return fmt.Errorf("tunnel: not implemented (Phase G)") }, + } +} + +// Execute runs the root command using stdout/stderr and returns the POSIX +// exit code. It is the only function main() calls. +// +// If the verb returns a typed *errs.Error, Execute reports its Code via +// the exit-code taxonomy (§4.2 of the spec). Untyped errors fall back to +// CodeUser (1). +func Execute() int { + root := NewRoot(os.Stdout, os.Stderr) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "✗", err) + var e *errs.Error + if errors.As(err, &e) { + return e.Code.ExitCode() + } + return 1 + } + return 0 +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..86da36a --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/nixrajput/siphon/internal/errs" +) + +func TestRoot_HelpListsAllVerbs(t *testing.T) { + var out, errb bytes.Buffer + root := NewRoot(&out, &errb) + root.SetArgs([]string{"--help"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(--help) returned error: %v", err) + } + + help := out.String() + errb.String() + wantVerbs := []string{ + "backup", "restore", "sync", "profile", "dumps", + "inspect", "verify", "config", "schedule", "tunnel", + } + for _, v := range wantVerbs { + if !strings.Contains(help, v) { + t.Fatalf("--help output missing %q\n\nfull output:\n%s", v, help) + } + } +} + +func TestRoot_VersionFlagPrintsVersion(t *testing.T) { + var out, errb bytes.Buffer + root := NewRoot(&out, &errb) + root.SetArgs([]string{"--version"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(--version) returned error: %v", err) + } + + combined := out.String() + errb.String() + if !strings.Contains(combined, Version) { + t.Fatalf("--version output %q does not contain %q", combined, Version) + } +} + +func TestExecute_ErrsErrorExitCode_RoutesThroughCode(t *testing.T) { + // We can't easily test Execute() directly (it writes to os.Stdout/os.Stderr), + // so verify the routing logic is reachable: a typed *errs.Error wrapped in + // cobra's RunE return should have its Code extractable via errors.As. + typedErr := &errs.Error{Op: "test", Code: errs.CodeIntegrity, Cause: errs.ErrChecksumMismatch} + var e *errs.Error + if !errors.As(typedErr, &e) { + t.Fatal("errors.As failed to extract *errs.Error") + } + if e.Code.ExitCode() != 3 { + t.Fatalf("ExitCode = %d; want 3 (CodeIntegrity)", e.Code.ExitCode()) + } +} + +func TestRoot_StubSubcommand_ReturnsNotImplementedError(t *testing.T) { + var out, errb bytes.Buffer + root := NewRoot(&out, &errb) + root.SetArgs([]string{"schedule"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute(schedule) returned nil; want 'not implemented' error") + } + if !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("error = %q; want it to contain 'not implemented'", err.Error()) + } +} diff --git a/internal/cli/sync.go b/internal/cli/sync.go new file mode 100644 index 0000000..2518b5f --- /dev/null +++ b/internal/cli/sync.go @@ -0,0 +1,44 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" +) + +func newSyncCmd() *cobra.Command { + var ( + fromName, toName string + stream bool + tables []string + ) + cmd := &cobra.Command{ + Use: "sync [from] [to]", + Short: "Backup + restore in one pass", + Args: cobra.MaximumNArgs(2), + RunE: func(c *cobra.Command, args []string) error { + if len(args) >= 1 { + fromName = args[0] + } + if len(args) >= 2 { + toName = args[1] + } + deps, err := buildDeps() + if err != nil { + return err + } + ch, _, err := app.Sync(c.Context(), deps, app.SyncOpts{ + From: fromName, To: toName, Stream: stream, Tables: tables, + }) + if err != nil { + return err + } + return Heartbeat(c.ErrOrStderr(), ch) + }, + } + cmd.Flags().StringVar(&fromName, "from", "", "Source profile") + cmd.Flags().StringVar(&toName, "to", "", "Target profile") + cmd.Flags().BoolVar(&stream, "stream", true, "Stream source→target without intermediate file") + cmd.Flags().StringSliceVar(&tables, "table", nil, "Limit to these tables") + return cmd +} diff --git a/internal/cli/verify.go b/internal/cli/verify.go new file mode 100644 index 0000000..a4417c0 --- /dev/null +++ b/internal/cli/verify.go @@ -0,0 +1,33 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nixrajput/siphon/internal/app" +) + +func newVerifyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "verify ", + Short: "Checksum + integrity check on a dump", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + deps, err := buildDeps() + if err != nil { + return err + } + report, err := app.Verify(c.Context(), deps, args[0]) + if report != nil { + if report.OK { + _, _ = fmt.Fprintf(c.OutOrStdout(), " ✓ checksum verified %s\n", report.Checksum) + } else { + _, _ = fmt.Fprintf(c.OutOrStdout(), " ✗ checksum MISMATCH %s\n", report.Checksum) + } + } + return err // nil on success; *errs.Error{CodeIntegrity} on mismatch → exit 3 + }, + } + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..75cc72a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,108 @@ +// Package config loads and validates siphon's config file. +package config + +import ( + "errors" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Version int `yaml:"version"` + Defaults Defaults `yaml:"defaults"` + Profiles map[string]ProfileConfig `yaml:"profiles"` + Groups map[string]GroupConfig `yaml:"groups"` +} + +type Defaults struct { + DumpDir string `yaml:"dump_dir"` + Jobs int `yaml:"jobs"` + Compression int `yaml:"compression"` + SecretBackend string `yaml:"secret_backend"` +} + +// ProfileConfig is the unresolved on-disk form of a connection profile. +// Name is NOT read from YAML — it is populated by Load() from the map key +// in Config.Profiles so callers don't need to thread the name separately. +type ProfileConfig struct { + Name string `yaml:"-"` + Driver string `yaml:"driver"` + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` // may be a SecretRef like ${VAR} or keychain://... + Database string `yaml:"database"` + SSLMode string `yaml:"sslmode"` + Group string `yaml:"group"` +} + +type GroupConfig struct { + Color string `yaml:"color"` + Require2FA bool `yaml:"require_2fa"` + ConfirmDestructive bool `yaml:"confirm_destructive"` +} + +// Load reads and parses the config file. Returns an empty Config if the +// file does not exist (first-run case). Env-var interpolation (${VAR}) is +// performed BEFORE YAML parsing so values resolve to their interpolated +// form in the typed struct. +func Load() (*Config, error) { + path := Path() + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &Config{ + Version: 1, + Profiles: map[string]ProfileConfig{}, + Groups: map[string]GroupConfig{}, + }, nil + } + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + expanded := os.ExpandEnv(string(raw)) + + cfg := &Config{ + Profiles: map[string]ProfileConfig{}, + Groups: map[string]GroupConfig{}, + } + if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { + return nil, fmt.Errorf("yaml parse: %w", err) + } + + // Populate Name on each ProfileConfig from its map key. This lets + // downstream code (ProfileStore.Resolve, etc.) work with ProfileConfig + // values without losing the name when they leave the map. + for name, p := range cfg.Profiles { + p.Name = name + cfg.Profiles[name] = p + } + + return cfg, nil +} + +// Save writes cfg to the configured Path, creating directories as needed. +// Note: Name is yaml:"-" so it is not serialized — the map key is the +// canonical source of a profile's name on disk. +func Save(cfg *Config) error { + path := Path() + if err := os.MkdirAll(parentDir(path), 0o700); err != nil { + return fmt.Errorf("mkdir config: %w", err) + } + body, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("yaml marshal: %w", err) + } + return os.WriteFile(path, body, 0o600) +} + +func parentDir(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' || path[i] == '\\' { + return path[:i] + } + } + return "." +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8c39487 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoad_MissingFile_ReturnsEmptyConfig(t *testing.T) { + t.Setenv("SIPHON_CONFIG_HOME", t.TempDir()) + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg == nil || len(cfg.Profiles) != 0 { + t.Fatalf("Load() returned %+v; want empty config", cfg) + } +} + +func TestLoad_ValidYAML_Parses(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", dir) + t.Setenv("PROD_PASS", "hunter2") + + body := `version: 1 +defaults: + dump_dir: ~/dumps + jobs: 4 +profiles: + prod: + driver: postgres + host: db.example.com + port: 5432 + user: app + password: ${PROD_PASS} + database: app_prod + sslmode: require +` + mustWrite(t, filepath.Join(dir, "config.yaml"), body) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + prod, ok := cfg.Profiles["prod"] + if !ok { + t.Fatalf("expected profile 'prod' in %+v", cfg.Profiles) + } + if prod.Password != "hunter2" { + t.Fatalf("password = %q; want 'hunter2' (env interpolation)", prod.Password) + } + if cfg.Defaults.Jobs != 4 { + t.Fatalf("Defaults.Jobs = %d; want 4", cfg.Defaults.Jobs) + } + if prod.Name != "prod" { + t.Fatalf("Name = %q; want 'prod' (populated from map key)", prod.Name) + } +} + +func TestLoad_InvalidYAML_ReturnsError(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIPHON_CONFIG_HOME", dir) + mustWrite(t, filepath.Join(dir, "config.yaml"), "this is: : not yaml") + + _, err := Load() + if err == nil { + t.Fatal("Load() returned no error on invalid YAML") + } + if !strings.Contains(err.Error(), "yaml") { + t.Fatalf("error %q does not mention yaml", err) + } +} + +func mustWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/path.go b/internal/config/path.go new file mode 100644 index 0000000..d8f0e1f --- /dev/null +++ b/internal/config/path.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" +) + +// Path returns the absolute path to siphon's config file. Honors +// $SIPHON_CONFIG_HOME first, then $XDG_CONFIG_HOME, then the OS default. +func Path() string { + if v := os.Getenv("SIPHON_CONFIG_HOME"); v != "" { + return filepath.Join(v, "config.yaml") + } + if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { + return filepath.Join(v, "siphon", "config.yaml") + } + home := homeDir() + switch runtime.GOOS { + case "windows": + if v := os.Getenv("APPDATA"); v != "" { + return filepath.Join(v, "siphon", "config.yaml") + } + return filepath.Join(home, "AppData", "Roaming", "siphon", "config.yaml") + default: + return filepath.Join(home, ".config", "siphon", "config.yaml") + } +} + +// homeDir resolves the user's home directory deterministically. os.UserHomeDir +// only fails when the platform's home env var is unset; if that happens we fall +// back to that env var directly and finally to os.TempDir(), so the result is +// always an absolute path — never "" (which would yield a cwd-relative config +// path silently written/read in an unexpected location). +func homeDir() string { + if h, err := os.UserHomeDir(); err == nil && h != "" { + return h + } + envVar := "HOME" + if runtime.GOOS == "windows" { + envVar = "USERPROFILE" + } + if h := os.Getenv(envVar); h != "" { + return h + } + return os.TempDir() +} diff --git a/internal/config/path_test.go b/internal/config/path_test.go new file mode 100644 index 0000000..a613035 --- /dev/null +++ b/internal/config/path_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "path/filepath" + "testing" +) + +// TestPath_AlwaysAbsolute is the regression for the ignored os.UserHomeDir +// error: even when SIPHON_CONFIG_HOME / XDG_CONFIG_HOME are unset and home +// resolution must fall back, Path() must return an absolute path — never a +// cwd-relative one like ".config/siphon/config.yaml". +func TestPath_AlwaysAbsolute(t *testing.T) { + t.Setenv("SIPHON_CONFIG_HOME", "") + t.Setenv("XDG_CONFIG_HOME", "") + + got := Path() + if !filepath.IsAbs(got) { + t.Fatalf("Path() = %q; want an absolute path", got) + } +} + +// TestHomeDir_FallsBackWhenHomeUnset verifies homeDir never returns "" even +// when the platform home env var is cleared — it falls back to os.TempDir(). +func TestHomeDir_FallsBackWhenHomeUnset(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + + got := homeDir() + if got == "" { + t.Fatal("homeDir() = \"\"; want a non-empty absolute path") + } + if !filepath.IsAbs(got) { + t.Fatalf("homeDir() = %q; want an absolute path", got) + } +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go new file mode 100644 index 0000000..9401e99 --- /dev/null +++ b/internal/driver/driver.go @@ -0,0 +1,98 @@ +// Package driver defines the contract every database adapter implements, +// and a process-wide registry for looking them up by name. +package driver + +import ( + "context" + "io" + "time" +) + +// Driver is the protocol-level abstraction for a database engine. +// Every adapter (postgres, mysql, mariadb, ...) implements Driver. +// +// Drivers are stateless. Connection state lives in Conn, scoped per-verb. +type Driver interface { + Name() string + Capabilities() Capabilities + Connect(ctx context.Context, p Profile) (Conn, error) +} + +// Conn is an open connection plus the verbs that operate on it. +// Conn is the unit of cancellation: ctx propagates to spawned subprocesses. +type Conn interface { + Inspect(ctx context.Context) (*Schema, error) + Backup(ctx context.Context, opt BackupOpts, w io.Writer) error + Restore(ctx context.Context, opt RestoreOpts, r io.Reader) error + Verify(ctx context.Context, r io.Reader) (*VerifyReport, error) + Close() error +} + +// Capabilities describes what an engine supports. Each flag gates a UI +// affordance or feature path. Drivers must declare honestly. +type Capabilities struct { + Incremental bool + NativeStream bool + PerTable bool + SchemaOnly bool + DataOnly bool + Parallel bool + Compression bool + BinaryFormat bool + CrossEngineSource bool + CrossEngineTarget bool + CDC bool + NativeBackpressure bool + CrossVersionIncremental bool +} + +// Profile is the connection descriptor passed to Connect. Secrets are +// resolved before this struct reaches Connect; never wire a SecretRef here. +type Profile struct { + Name string + Driver string + Host string + Port int + User string + Password string + Database string + SSLMode string +} + +// Schema is the result of Conn.Inspect. +type Schema struct { + Tables []TableMeta +} + +type TableMeta struct { + Name string + Rows int64 + SizeBytes int64 +} + +// BackupOpts configures Conn.Backup. +type BackupOpts struct { + IncludeTables []string + ExcludeTables []string + ExcludeDataFrom []string + SchemaOnly bool + DataOnly bool + CompressionLevel int + Parallel int +} + +// RestoreOpts configures Conn.Restore. +type RestoreOpts struct { + TargetTables []string + DataOnly bool + SchemaOnly bool + Clean bool +} + +// VerifyReport is the result of Conn.Verify. +type VerifyReport struct { + Checksum string + OK bool + Started time.Time + Finished time.Time +} diff --git a/internal/driver/postgres/backup.go b/internal/driver/postgres/backup.go new file mode 100644 index 0000000..353824e --- /dev/null +++ b/internal/driver/postgres/backup.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strconv" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/errs" +) + +// Backup spawns pg_dump and streams the output to w. ctx cancellation +// propagates to pg_dump via exec.CommandContext. +func (c *Conn) Backup(ctx context.Context, opt driver.BackupOpts, w io.Writer) error { + args := buildBackupArgs(c.p, opt) + cmd := exec.CommandContext(ctx, "pg_dump", args...) + cmd.Env = append(os.Environ(), "PGPASSWORD="+c.p.Password) + cmd.Stdout = w + // Discard stderr directly. Using StderrPipe + a drain goroutine would race + // with cmd.Wait() (Wait closes the pipe once the process exits, which the + // os/exec docs warn must not happen before reads complete). Phase F will + // wire stderr through progress.go's parser; until then, discarding is safe. + cmd.Stderr = io.Discard + + if err := cmd.Start(); err != nil { + return toolMissingOrSystemErr("postgres.backup", err) + } + if err := cmd.Wait(); err != nil { + return &errs.Error{Op: "postgres.backup", Code: errs.CodeSystem, Cause: err} + } + return nil +} + +func buildBackupArgs(p driver.Profile, opt driver.BackupOpts) []string { + args := []string{ + "-h", p.Host, + "-p", strconv.Itoa(p.Port), + "-U", p.User, + "-d", p.Database, + "-Fc", // custom binary format + "-Z", strconv.Itoa(compressionLevel(opt.CompressionLevel)), + "--no-owner", "--no-acl", + "--verbose", + } + // NOTE: pg_dump's -j (parallel) requires directory format (-Fd); it is + // incompatible with the single-stream custom format (-Fc) siphon writes + // to the catalog. opt.Parallel is honored by pg_restore (see restore.go), + // not by pg_dump. Do not add -j here. + if opt.SchemaOnly { + args = append(args, "--schema-only") + } + if opt.DataOnly { + args = append(args, "--data-only") + } + for _, t := range opt.IncludeTables { + args = append(args, "-t", t) + } + for _, t := range opt.ExcludeTables { + args = append(args, "-T", t) + } + for _, t := range opt.ExcludeDataFrom { + args = append(args, "--exclude-table-data", t) + } + return args +} + +func compressionLevel(n int) int { + if n <= 0 { + return 1 + } + if n > 9 { + return 9 + } + return n +} + +func toolMissingOrSystemErr(op string, err error) error { + if execErr, ok := err.(*exec.Error); ok && execErr.Err == exec.ErrNotFound { + return &errs.Error{ + Op: op, + Code: errs.CodeSystem, + Cause: errs.ErrToolMissing, + Hint: "install postgresql client tools (brew install postgresql, apt install postgresql-client)", + } + } + return &errs.Error{Op: op, Code: errs.CodeSystem, Cause: err} +} + +// progress.go intentionally separate so future stderr parsing has a clear home. +var _ = fmt.Sprintf diff --git a/internal/driver/postgres/backup_test.go b/internal/driver/postgres/backup_test.go new file mode 100644 index 0000000..91b3bc6 --- /dev/null +++ b/internal/driver/postgres/backup_test.go @@ -0,0 +1,55 @@ +package postgres + +import ( + "reflect" + "testing" + + "github.com/nixrajput/siphon/internal/driver" +) + +func TestBuildBackupArgs_Defaults(t *testing.T) { + got := buildBackupArgs( + driver.Profile{Host: "h", Port: 5432, User: "u", Database: "d"}, + driver.BackupOpts{}, + ) + want := []string{ + "-h", "h", "-p", "5432", "-U", "u", "-d", "d", + "-Fc", "-Z", "1", "--no-owner", "--no-acl", "--verbose", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v; want %v", got, want) + } +} + +// TestBuildBackupArgs_TablesAndParallelIgnored verifies that Parallel is +// accepted in opts but NOT forwarded to pg_dump (-j is directory-format only). +func TestBuildBackupArgs_TablesAndParallelIgnored(t *testing.T) { + got := buildBackupArgs( + driver.Profile{Host: "h", Port: 5432, User: "u", Database: "d"}, + driver.BackupOpts{ + IncludeTables: []string{"users", "orders"}, + ExcludeTables: []string{"logs"}, + ExcludeDataFrom: []string{"audit"}, + Parallel: 4, // intentionally ignored by buildBackupArgs; used by pg_restore + }, + ) + want := []string{ + "-h", "h", "-p", "5432", "-U", "u", "-d", "d", + "-Fc", "-Z", "1", "--no-owner", "--no-acl", "--verbose", + "-t", "users", "-t", "orders", + "-T", "logs", + "--exclude-table-data", "audit", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v; want %v", got, want) + } +} + +func TestCompressionLevel_Clamps(t *testing.T) { + cases := map[int]int{-1: 1, 0: 1, 1: 1, 5: 5, 9: 9, 10: 9} + for in, want := range cases { + if got := compressionLevel(in); got != want { + t.Fatalf("compressionLevel(%d) = %d; want %d", in, got, want) + } + } +} diff --git a/internal/driver/postgres/driver.go b/internal/driver/postgres/driver.go new file mode 100644 index 0000000..72e82bc --- /dev/null +++ b/internal/driver/postgres/driver.go @@ -0,0 +1,112 @@ +// Package postgres implements siphon's Postgres driver. Backup/Restore +// shell out to pg_dump/pg_restore for correctness; Inspect uses pgx for +// fast schema reads. +package postgres + +import ( + "context" + "database/sql" + "strconv" + "strings" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/errs" + + _ "github.com/jackc/pgx/v5/stdlib" // register pgx as a database/sql driver +) + +func init() { driver.Register(&Driver{}) } + +type Driver struct{} + +func (Driver) Name() string { return "postgres" } + +func (Driver) Capabilities() driver.Capabilities { + return driver.Capabilities{ + Incremental: false, // arrives in Phase F (WAL) + NativeStream: true, + PerTable: true, + SchemaOnly: true, + DataOnly: true, + Parallel: true, // capability exists in the engine, but not wired in Phase B: pg_dump -j needs -Fd (see backup.go) and RestoreOpts carries no Parallel field yet — both land in a later phase + Compression: true, + BinaryFormat: true, + CrossEngineSource: false, // Phase F + CrossEngineTarget: false, // Phase F + CDC: false, // Phase F + NativeBackpressure: true, + } +} + +func (Driver) Connect(ctx context.Context, p driver.Profile) (driver.Conn, error) { + dsn := buildDSN(p) + + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, wrapConnErr(err) + } + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, wrapConnErr(err) + } + return &Conn{db: db, p: p}, nil +} + +// buildDSN assembles a libpq keyword DSN from only the non-empty profile +// fields. Emitting an empty value (e.g. password=) makes pgx's keyword parser +// swallow the following token, which previously dropped dbname when the +// password was empty. Port is always emitted since it is an int default. +func buildDSN(p driver.Profile) string { + parts := make([]string, 0, 6) + if p.Host != "" { + parts = append(parts, kv("host", p.Host)) + } + if p.Port != 0 { + parts = append(parts, kv("port", strconv.Itoa(p.Port))) + } + if p.User != "" { + parts = append(parts, kv("user", p.User)) + } + if p.Password != "" { + parts = append(parts, kv("password", p.Password)) + } + if p.Database != "" { + parts = append(parts, kv("dbname", p.Database)) + } + parts = append(parts, kv("sslmode", defaultSSL(p.SSLMode))) + return strings.Join(parts, " ") +} + +// kv formats one libpq keyword DSN pair. Values containing spaces, quotes, or +// backslashes must be single-quoted with ' and \ escaped (per libpq rules), +// otherwise a value like "p@ss word" would be split across tokens and misparse. +func kv(key, val string) string { + if strings.ContainsAny(val, " '\\") { + val = "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(val) + "'" + } + return key + "=" + val +} + +func defaultSSL(s string) string { + if s == "" { + return "prefer" + } + return s +} + +func wrapConnErr(err error) error { + return &errs.Error{ + Op: "postgres.connect", + Code: errs.CodeSystem, + Cause: errs.ErrConnectionFailed, + Hint: err.Error(), + } +} + +// Conn is a live Postgres connection. +type Conn struct { + db *sql.DB + p driver.Profile +} + +func (c *Conn) Close() error { return c.db.Close() } diff --git a/internal/driver/postgres/driver_test.go b/internal/driver/postgres/driver_test.go new file mode 100644 index 0000000..e03b229 --- /dev/null +++ b/internal/driver/postgres/driver_test.go @@ -0,0 +1,100 @@ +package postgres + +import ( + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/nixrajput/siphon/internal/driver" +) + +// TestBuildDSN_OmitsEmptyFields is the exact regression: an empty password +// must not be emitted, and dbname must survive parsing. +func TestBuildDSN_OmitsEmptyFields(t *testing.T) { + dsn := buildDSN(driver.Profile{ + Host: "localhost", + Port: 5432, + User: "u", + Database: "d", + SSLMode: "disable", + }) + + if strings.Contains(dsn, "password=") { + t.Fatalf("DSN must not contain password= when password is empty, got %q", dsn) + } + if !strings.Contains(dsn, "dbname=d") { + t.Fatalf("DSN must contain dbname=d, got %q", dsn) + } + + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatalf("pgx.ParseConfig(%q): %v", dsn, err) + } + if cfg.Database != "d" { + t.Fatalf("parsed Database = %q, want %q (empty password ate dbname)", cfg.Database, "d") + } +} + +func TestBuildDSN_FullProfile(t *testing.T) { + dsn := buildDSN(driver.Profile{ + Host: "localhost", + Port: 5432, + User: "u", + Password: "secret", + Database: "d", + SSLMode: "disable", + }) + + if !strings.Contains(dsn, "password=secret") { + t.Fatalf("DSN must contain password=secret, got %q", dsn) + } + if !strings.Contains(dsn, "dbname=d") { + t.Fatalf("DSN must contain dbname=d, got %q", dsn) + } + + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatalf("pgx.ParseConfig(%q): %v", dsn, err) + } + if cfg.Database != "d" { + t.Fatalf("parsed Database = %q, want %q", cfg.Database, "d") + } + if cfg.User != "u" { + t.Fatalf("parsed User = %q, want %q", cfg.User, "u") + } +} + +// TestBuildDSN_QuotesSpecialValues is the regression for libpq keyword escaping: +// a password containing spaces/quotes/backslashes must be single-quoted (with ' +// and \ escaped) so pgx parses it back to the exact original, not split tokens. +func TestBuildDSN_QuotesSpecialValues(t *testing.T) { + for _, pw := range []string{"p@ss word", `quote'd`, `back\slash`, "a b c"} { + dsn := buildDSN(driver.Profile{ + Host: "localhost", Port: 5432, User: "u", Password: pw, Database: "d", SSLMode: "disable", + }) + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatalf("password %q: ParseConfig(%q): %v", pw, dsn, err) + } + if cfg.Password != pw { + t.Fatalf("password %q round-tripped as %q via DSN %q", pw, cfg.Password, dsn) + } + if cfg.Database != "d" { + t.Fatalf("password %q broke dbname: got %q", pw, cfg.Database) + } + } +} + +func TestBuildDSN_DefaultSSL(t *testing.T) { + dsn := buildDSN(driver.Profile{ + Host: "localhost", + Port: 5432, + User: "u", + Database: "d", + }) + + if !strings.Contains(dsn, "sslmode=prefer") { + t.Fatalf("DSN must contain sslmode=prefer for empty SSLMode, got %q", dsn) + } +} diff --git a/internal/driver/postgres/inspect.go b/internal/driver/postgres/inspect.go new file mode 100644 index 0000000..f0fe9ed --- /dev/null +++ b/internal/driver/postgres/inspect.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "context" + + "github.com/nixrajput/siphon/internal/driver" +) + +// inspectQuery lists user tables with row estimates and on-disk sizes. +// schemaname/relname are qualified with the pg_stat_user_tables alias (s.) +// because pg_class (c) also exposes a relname column — an unqualified +// reference is ambiguous (SQLSTATE 42702). reltuples is -1 for a table that +// has never been ANALYZEd; GREATEST clamps that to 0 so the row estimate is +// never reported as a negative number. +const inspectQuery = ` +SELECT + s.schemaname || '.' || s.relname AS name, + GREATEST(COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid = c.oid), 0), 0) AS rows, + pg_total_relation_size(c.oid) AS size_bytes +FROM pg_stat_user_tables s +JOIN pg_class c ON c.oid = s.relid +ORDER BY size_bytes DESC +` + +func (c *Conn) Inspect(ctx context.Context) (*driver.Schema, error) { + rows, err := c.db.QueryContext(ctx, inspectQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := &driver.Schema{} + for rows.Next() { + var t driver.TableMeta + if err := rows.Scan(&t.Name, &t.Rows, &t.SizeBytes); err != nil { + return nil, err + } + out.Tables = append(out.Tables, t) + } + return out, rows.Err() +} diff --git a/internal/driver/postgres/integration_test.go b/internal/driver/postgres/integration_test.go new file mode 100644 index 0000000..e10446e --- /dev/null +++ b/internal/driver/postgres/integration_test.go @@ -0,0 +1,112 @@ +//go:build integration + +package postgres + +import ( + "bytes" + "context" + "io" + "testing" + "time" + + pgctr "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/nixrajput/siphon/internal/driver" +) + +func startPostgres(t *testing.T) (driver.Profile, func()) { + t.Helper() + ctx := context.Background() + c, err := pgctr.Run(ctx, "postgres:16-alpine", + pgctr.WithDatabase("test"), + pgctr.WithUsername("postgres"), + pgctr.WithPassword("postgres"), + pgctr.BasicWaitStrategies(), + ) + if err != nil { + t.Fatalf("start postgres container: %v", err) + } + + host, err := c.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := c.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("container mapped port: %v", err) + } + + return driver.Profile{ + Driver: "postgres", + Host: host, + Port: int(port.Num()), + User: "postgres", + Password: "postgres", + Database: "test", + SSLMode: "disable", + }, func() { + _ = c.Terminate(ctx) + } +} + +func TestIntegration_Connect_And_Inspect(t *testing.T) { + p, cleanup := startPostgres(t) + t.Cleanup(cleanup) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + conn, err := Driver{}.Connect(ctx, p) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer func() { _ = conn.Close() }() + + if _, err := conn.Inspect(ctx); err != nil { + t.Fatalf("Inspect: %v", err) + } +} + +func TestIntegration_BackupRestore_Roundtrip(t *testing.T) { + p, cleanup := startPostgres(t) + t.Cleanup(cleanup) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + conn, err := Driver{}.Connect(ctx, p) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer func() { _ = conn.Close() }() + + // Seed + if _, execErr := conn.(*Conn).db.ExecContext(ctx, + `CREATE TABLE widgets(id int primary key, name text); INSERT INTO widgets VALUES (1,'wrench');`, + ); execErr != nil { + t.Fatalf("seed: %v", execErr) + } + + var buf bytes.Buffer + if err := conn.Backup(ctx, driver.BackupOpts{}, &buf); err != nil { + t.Fatalf("Backup: %v", err) + } + + // Drop the table + if _, execErr := conn.(*Conn).db.ExecContext(ctx, `DROP TABLE widgets;`); execErr != nil { + t.Fatalf("drop: %v", execErr) + } + + if err := conn.Restore(ctx, driver.RestoreOpts{}, io.NopCloser(&buf)); err != nil { + t.Fatalf("Restore: %v", err) + } + + var count int + row := conn.(*Conn).db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`) + if err := row.Scan(&count); err != nil { + t.Fatalf("post-restore SELECT: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 widget after restore; got %d", count) + } +} diff --git a/internal/driver/postgres/progress.go b/internal/driver/postgres/progress.go new file mode 100644 index 0000000..e3cb999 --- /dev/null +++ b/internal/driver/postgres/progress.go @@ -0,0 +1,55 @@ +package postgres + +import ( + "bufio" + "io" + "strings" + + "github.com/nixrajput/siphon/internal/jobs" +) + +// parseProgress reads pg_dump --verbose stderr line-by-line and emits +// progress events. Phase B emits a minimal event-per-table; Phase F adds +// byte/row counters. +func parseProgress(r io.Reader, emit func(jobs.Event)) { + scan := bufio.NewScanner(r) + scan.Buffer(make([]byte, 64*1024), 1<<20) + for scan.Scan() { + line := scan.Text() + switch { + case strings.HasPrefix(line, "pg_dump: dumping contents of table"): + table := extractAfter(line, "table ") + emit(jobs.Event{ + Phase: jobs.PhaseProgress, + Message: "dumping " + table, + Progress: &jobs.Progress{TableActive: table}, + }) + } + } + // Scan() returns false on EOF or error; only Err() distinguishes them. + // A read failure or an over-long line (> the 1<<20 max-token buffer above) + // would otherwise silently truncate progress. Surface it as a warning — + // progress parsing is best-effort and must never fail the backup itself. + if err := scan.Err(); err != nil { + emit(jobs.Event{ + Phase: jobs.PhaseWarn, + Message: "progress parsing stopped early: " + err.Error(), + Err: err, + }) + } +} + +func extractAfter(s, key string) string { + idx := strings.Index(s, key) + if idx < 0 { + return "" + } + out := strings.TrimSpace(s[idx+len(key):]) + out = strings.Trim(out, `"'`) + return out +} + +// sentinel: parseProgress and extractAfter are defined but not yet wired into +// backup.go (Phase B drains stderr to io.Discard). This prevents the linter +// from flagging them as unused until Phase F wires them in. +var _ = parseProgress diff --git a/internal/driver/postgres/progress_test.go b/internal/driver/postgres/progress_test.go new file mode 100644 index 0000000..245faab --- /dev/null +++ b/internal/driver/postgres/progress_test.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "strings" + "testing" + + "github.com/nixrajput/siphon/internal/jobs" +) + +func TestParseProgress_EmitsPerTable(t *testing.T) { + stderr := strings.Join([]string{ + "pg_dump: last built-in OID is 16383", + `pg_dump: dumping contents of table "public.widgets"`, + `pg_dump: dumping contents of table "public.orders"`, + "pg_dump: saving database definition", + }, "\n") + + var events []jobs.Event + parseProgress(strings.NewReader(stderr), func(e jobs.Event) { events = append(events, e) }) + + if len(events) != 2 { + t.Fatalf("emitted %d events; want 2 (one per dumped table)", len(events)) + } + for i, want := range []string{"public.widgets", "public.orders"} { + if events[i].Phase != jobs.PhaseProgress { + t.Errorf("event[%d].Phase = %v; want PhaseProgress", i, events[i].Phase) + } + if events[i].Progress == nil || events[i].Progress.TableActive != want { + t.Errorf("event[%d] TableActive = %v; want %q", i, events[i].Progress, want) + } + } +} + +// TestParseProgress_SurfacesScanError verifies the scan.Err() check: a line +// longer than the 1<<20 max-token buffer makes Scan() stop early, and that +// must be surfaced as a PhaseWarn event rather than silently swallowed. +func TestParseProgress_SurfacesScanError(t *testing.T) { + // One token larger than the 1 MiB max buffer, with no newline → ErrTooLong. + huge := strings.Repeat("x", (1<<20)+1) + + var events []jobs.Event + parseProgress(strings.NewReader(huge), func(e jobs.Event) { events = append(events, e) }) + + if len(events) == 0 { + t.Fatal("expected a warning event for the over-long line; got none (error was swallowed)") + } + last := events[len(events)-1] + if last.Phase != jobs.PhaseWarn { + t.Fatalf("last event Phase = %v; want PhaseWarn", last.Phase) + } + if last.Err == nil { + t.Fatal("warn event should carry the scan error in Err") + } +} diff --git a/internal/driver/postgres/restore.go b/internal/driver/postgres/restore.go new file mode 100644 index 0000000..6c6f5fe --- /dev/null +++ b/internal/driver/postgres/restore.go @@ -0,0 +1,48 @@ +package postgres + +import ( + "context" + "io" + "os" + "os/exec" + "strconv" + + "github.com/nixrajput/siphon/internal/driver" +) + +func (c *Conn) Restore(ctx context.Context, opt driver.RestoreOpts, r io.Reader) error { + args := buildRestoreArgs(c.p, opt) + cmd := exec.CommandContext(ctx, "pg_restore", args...) + cmd.Env = append(os.Environ(), "PGPASSWORD="+c.p.Password) + cmd.Stdin = r + cmd.Stderr = os.Stderr // surface pg_restore diagnostics directly; Phase F captures these for structured progress + + if err := cmd.Run(); err != nil { + return toolMissingOrSystemErr("postgres.restore", err) + } + return nil +} + +func buildRestoreArgs(p driver.Profile, opt driver.RestoreOpts) []string { + args := []string{ + "-h", p.Host, + "-p", strconv.Itoa(p.Port), + "-U", p.User, + "-d", p.Database, + "--no-owner", "--no-acl", + "--verbose", + } + if opt.Clean { + args = append(args, "--clean", "--if-exists") + } + if opt.SchemaOnly { + args = append(args, "--schema-only") + } + if opt.DataOnly { + args = append(args, "--data-only") + } + for _, t := range opt.TargetTables { + args = append(args, "-t", t) + } + return args +} diff --git a/internal/driver/postgres/restore_test.go b/internal/driver/postgres/restore_test.go new file mode 100644 index 0000000..4b97c6e --- /dev/null +++ b/internal/driver/postgres/restore_test.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "reflect" + "testing" + + "github.com/nixrajput/siphon/internal/driver" +) + +func TestBuildRestoreArgs_Defaults(t *testing.T) { + got := buildRestoreArgs( + driver.Profile{Host: "h", Port: 5432, User: "u", Database: "d"}, + driver.RestoreOpts{}, + ) + want := []string{ + "-h", "h", "-p", "5432", "-U", "u", "-d", "d", + "--no-owner", "--no-acl", "--verbose", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v; want %v", got, want) + } +} + +func TestBuildRestoreArgs_CleanAndTables(t *testing.T) { + got := buildRestoreArgs( + driver.Profile{Host: "h", Port: 5432, User: "u", Database: "d"}, + driver.RestoreOpts{ + Clean: true, + TargetTables: []string{"users", "orders"}, + }, + ) + want := []string{ + "-h", "h", "-p", "5432", "-U", "u", "-d", "d", + "--no-owner", "--no-acl", "--verbose", + "--clean", "--if-exists", + "-t", "users", "-t", "orders", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v; want %v", got, want) + } +} diff --git a/internal/driver/postgres/verify.go b/internal/driver/postgres/verify.go new file mode 100644 index 0000000..bc5e654 --- /dev/null +++ b/internal/driver/postgres/verify.go @@ -0,0 +1,27 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "time" + + "github.com/nixrajput/siphon/internal/driver" +) + +// Verify currently performs a checksum-only check on the dump stream. +// Header-format checks land in Phase F when the siphon envelope exists. +func (c *Conn) Verify(ctx context.Context, r io.Reader) (*driver.VerifyReport, error) { + started := time.Now() + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return nil, err + } + return &driver.VerifyReport{ + Checksum: "sha256:" + hex.EncodeToString(h.Sum(nil)), + OK: true, + Started: started, + Finished: time.Now(), + }, nil +} diff --git a/internal/driver/registry.go b/internal/driver/registry.go new file mode 100644 index 0000000..80c4d21 --- /dev/null +++ b/internal/driver/registry.go @@ -0,0 +1,52 @@ +package driver + +import ( + "sync" + + "github.com/nixrajput/siphon/internal/errs" +) + +var ( + mu sync.RWMutex + registry = map[string]Driver{} +) + +// Register adds d to the process-wide registry. Drivers call Register +// from package init() so they are available before any verb runs. +// +// Register panics if called twice for the same driver name. This mirrors +// the convention used by database/sql.Register: registration is an init() +// concern, and a duplicate almost always indicates a copy-paste bug we +// want to surface loudly at startup rather than have silently overwrite. +func Register(d Driver) { + mu.Lock() + defer mu.Unlock() + if _, exists := registry[d.Name()]; exists { + panic("driver: Register called twice for driver " + d.Name()) + } + registry[d.Name()] = d +} + +// Get returns the registered driver with the given name. If no driver +// matches, it returns errs.ErrDriverUnsupported so callers can match via +// errors.Is and surface a consistent exit code. +func Get(name string) (Driver, error) { + mu.RLock() + defer mu.RUnlock() + d, ok := registry[name] + if !ok { + return nil, errs.ErrDriverUnsupported + } + return d, nil +} + +// List returns the names of all registered drivers. Order is unspecified. +func List() []string { + mu.RLock() + defer mu.RUnlock() + out := make([]string, 0, len(registry)) + for n := range registry { + out = append(out, n) + } + return out +} diff --git a/internal/driver/registry_test.go b/internal/driver/registry_test.go new file mode 100644 index 0000000..f4cd761 --- /dev/null +++ b/internal/driver/registry_test.go @@ -0,0 +1,81 @@ +package driver + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/nixrajput/siphon/internal/errs" +) + +type fakeDriver struct { + name string + caps Capabilities +} + +func (f *fakeDriver) Name() string { return f.name } +func (f *fakeDriver) Capabilities() Capabilities { return f.caps } +func (f *fakeDriver) Connect(context.Context, Profile) (Conn, error) { return nil, nil } + +func TestRegister_And_Get_Roundtrip(t *testing.T) { + t.Cleanup(reset) + Register(&fakeDriver{name: "test-driver"}) + + got, err := Get("test-driver") + if err != nil { + t.Fatalf("Get returned unexpected error: %v", err) + } + if got.Name() != "test-driver" { + t.Fatalf("Get returned driver with Name %q; want %q", got.Name(), "test-driver") + } +} + +func TestGet_Unknown_ReturnsErrDriverUnsupported(t *testing.T) { + t.Cleanup(reset) + _, err := Get("does-not-exist") + if !errors.Is(err, errs.ErrDriverUnsupported) { + t.Fatalf("Get returned %v; want errs.ErrDriverUnsupported", err) + } +} + +func TestList_ReturnsAllRegistered(t *testing.T) { + t.Cleanup(reset) + Register(&fakeDriver{name: "a"}) + Register(&fakeDriver{name: "b"}) + + names := List() + if len(names) != 2 { + t.Fatalf("List() returned %d names; want 2", len(names)) + } + + have := map[string]bool{names[0]: true, names[1]: true} + if !have["a"] || !have["b"] { + t.Fatalf("List() = %v; want [a b]", names) + } +} + +func TestRegister_Duplicate_Panics(t *testing.T) { + t.Cleanup(reset) + Register(&fakeDriver{name: "dup"}) + + defer func() { + if r := recover(); r == nil { + t.Fatal("Register did not panic on duplicate name") + } + }() + Register(&fakeDriver{name: "dup"}) +} + +// reset clears the registry between tests since it is process-wide. +func reset() { + mu.Lock() + defer mu.Unlock() + registry = map[string]Driver{} +} + +// Compile-time check that fakeDriver implements Driver. +var _ Driver = (*fakeDriver)(nil) + +// Silence the unused-import warning for io while tests don't exercise Conn yet. +var _ io.Writer = io.Discard diff --git a/internal/dumps/catalog.go b/internal/dumps/catalog.go new file mode 100644 index 0000000..3c88db3 --- /dev/null +++ b/internal/dumps/catalog.go @@ -0,0 +1,116 @@ +package dumps + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/nixrajput/siphon/internal/errs" +) + +// Catalog is a filesystem-backed dump store. Phase G replaces the +// filesystem path with a Storage abstraction; the API does not change. +type Catalog struct { + root string +} + +// NewCatalog creates the directory if missing and returns a Catalog rooted there. +func NewCatalog(root string) (*Catalog, error) { + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + return &Catalog{root: root}, nil +} + +// validID rejects IDs that could escape the catalog root when joined into a +// path. Dump IDs are ULIDs, but restore/inspect/verify take the ID from user +// input, so a crafted value like "../../etc/passwd" must not traverse out. +func validID(id string) error { + if id == "" || id == "." || id == ".." || + strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { + return &errs.Error{Op: "dumps.id", Code: errs.CodeUser, Cause: errors.New("invalid dump id: " + id), Hint: "dump ids contain no path separators"} + } + return nil +} + +// Path returns the dump file path for the given ID. filepath.Base defensively +// strips any path components so a stray separator can never escape root. +func (c *Catalog) Path(id string) string { return filepath.Join(c.root, filepath.Base(id)+".dump") } + +// MetaPath returns the sidecar JSON path for the given ID. +func (c *Catalog) MetaPath(id string) string { + return filepath.Join(c.root, filepath.Base(id)+".meta.json") +} + +// Root returns the catalog's root directory. +func (c *Catalog) Root() string { return c.root } + +// WriteMeta serializes m and writes it to .meta.json. +func (c *Catalog) WriteMeta(m *Meta) error { + body, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.MetaPath(m.ID), body, 0o600) +} + +// ReadMeta loads .meta.json from disk. +func (c *Catalog) ReadMeta(id string) (*Meta, error) { + if err := validID(id); err != nil { + return nil, err + } + body, err := os.ReadFile(c.MetaPath(id)) + if errors.Is(err, os.ErrNotExist) { + return nil, &errs.Error{Op: "dumps.read_meta", Code: errs.CodeUser, Cause: errs.ErrDumpCorrupt, Hint: "no metadata for " + id} + } + if err != nil { + return nil, err + } + m := &Meta{} + return m, json.Unmarshal(body, m) +} + +// List returns metadata for every dump in the catalog, sorted newest first. +func (c *Catalog) List() ([]Meta, error) { + entries, err := os.ReadDir(c.root) + if err != nil { + return nil, err + } + var out []Meta + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".meta.json") { + continue + } + id := strings.TrimSuffix(e.Name(), ".meta.json") + m, err := c.ReadMeta(id) + if err != nil { + continue // skip corrupt entries; user can `dumps prune --orphans` later + } + out = append(out, *m) + } + // sort newest first + sortByCreatedDesc(out) + return out, nil +} + +// Delete removes both the dump file and its sidecar. +func (c *Catalog) Delete(id string) error { + if err := validID(id); err != nil { + return err + } + _ = os.Remove(c.Path(id)) + return os.Remove(c.MetaPath(id)) +} + +func sortByCreatedDesc(m []Meta) { + // inline insertion sort: catalogs are typically small (< 1000 entries) + for i := 1; i < len(m); i++ { + j := i + for j > 0 && m[j-1].Created.Before(m[j].Created) { + m[j-1], m[j] = m[j], m[j-1] + j-- + } + } +} diff --git a/internal/dumps/catalog_test.go b/internal/dumps/catalog_test.go new file mode 100644 index 0000000..6e41cfd --- /dev/null +++ b/internal/dumps/catalog_test.go @@ -0,0 +1,108 @@ +package dumps + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/nixrajput/siphon/internal/errs" +) + +func TestCatalog_WriteRead_Roundtrip(t *testing.T) { + dir := t.TempDir() + c, err := NewCatalog(dir) + if err != nil { + t.Fatal(err) + } + + m := &Meta{ + ID: "01HXKZ000000000000000000", + Profile: "prod", + Driver: "postgres", + Created: time.Now(), + Checksum: "sha256:abc", + SizeBytes: 1234, + } + if err := c.WriteMeta(m); err != nil { + t.Fatal(err) + } + + if _, statErr := os.Stat(filepath.Join(dir, m.ID+".meta.json")); statErr != nil { + t.Fatalf("expected sidecar to exist: %v", statErr) + } + + got, err := c.ReadMeta(m.ID) + if err != nil { + t.Fatal(err) + } + if got.Checksum != m.Checksum { + t.Fatalf("Checksum = %q; want %q", got.Checksum, m.Checksum) + } +} + +func TestCatalog_List_SortsNewestFirst(t *testing.T) { + dir := t.TempDir() + c, _ := NewCatalog(dir) + + old := &Meta{ID: "01HOLD0000000000000000000", Created: time.Now().Add(-24 * time.Hour)} + new_ := &Meta{ID: "01HNEW0000000000000000000", Created: time.Now()} + _ = c.WriteMeta(old) + _ = c.WriteMeta(new_) + + got, err := c.List() + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].ID != new_.ID { + t.Fatalf("List() = %v; want newest first", got) + } +} + +func TestCatalog_PruneDryRun_AppliesMaxAge(t *testing.T) { + dir := t.TempDir() + c, _ := NewCatalog(dir) + + old := &Meta{ID: "01HOLD0000000000000000000", Created: time.Now().Add(-48 * time.Hour)} + new_ := &Meta{ID: "01HNEW0000000000000000000", Created: time.Now()} + _ = c.WriteMeta(old) + _ = c.WriteMeta(new_) + + rep, err := c.PruneDryRun(RetentionPolicy{MaxAge: 24 * time.Hour}) + if err != nil { + t.Fatal(err) + } + if len(rep.Would) != 1 || rep.Would[0].ID != old.ID { + t.Fatalf("Would = %v; want only old", rep.Would) + } + if len(rep.Kept) != 1 || rep.Kept[0].ID != new_.ID { + t.Fatalf("Kept = %v; want only new", rep.Kept) + } +} + +// TestCatalog_RejectsTraversalID guards the path-traversal fix: IDs from user +// input (restore/inspect/verify ) must not escape the catalog root. +func TestCatalog_RejectsTraversalID(t *testing.T) { + c, err := NewCatalog(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, bad := range []string{"../escape", "../../etc/passwd", "a/b", `a\b`, "", "..", "."} { + if _, err := c.ReadMeta(bad); err == nil { + t.Errorf("ReadMeta(%q) = nil error; want rejection", bad) + } + if err := c.Delete(bad); err == nil { + t.Errorf("Delete(%q) = nil error; want rejection", bad) + } + } + // A clean ULID-like id passes validation, so ReadMeta proceeds and fails + // only because the dump doesn't exist — surfaced as errs.ErrDumpCorrupt. + _, err = c.ReadMeta("01HXKZ000000000000000000") + if err == nil { + t.Fatal("ReadMeta(valid-but-missing id) = nil error; want a not-found error") + } + if !errors.Is(err, errs.ErrDumpCorrupt) { + t.Fatalf("ReadMeta(valid-but-missing id) error = %v; want errs.ErrDumpCorrupt", err) + } +} diff --git a/internal/dumps/meta.go b/internal/dumps/meta.go new file mode 100644 index 0000000..d27c26c --- /dev/null +++ b/internal/dumps/meta.go @@ -0,0 +1,31 @@ +// Package dumps implements the catalog of saved dump files. Dumps live +// on a Storage backend (filesystem in Phase B; cloud backends in Phase G); +// each entry has a sidecar .meta.json carrying schema, checksum, etc. +package dumps + +import "time" + +// Meta is the sidecar JSON written next to each dump file. +type Meta struct { + ID string `json:"id"` + Profile string `json:"profile"` + Driver string `json:"driver"` + EngineVersion string `json:"engine_version"` + DumpFormat string `json:"dump_format"` + SizeBytes int64 `json:"size_bytes"` + Checksum string `json:"checksum"` + Created time.Time `json:"created"` + Tables []TableEntry `json:"tables"` + Annotations map[string]string `json:"annotations,omitempty"` + + // Incremental chain (populated in Phase F; nil for full backups in B). + BaseID string `json:"base_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` +} + +// TableEntry holds per-table statistics recorded at dump time. +type TableEntry struct { + Name string `json:"name"` + Rows int64 `json:"rows"` + SizeBytes int64 `json:"size_bytes"` +} diff --git a/internal/dumps/prune.go b/internal/dumps/prune.go new file mode 100644 index 0000000..d53fb18 --- /dev/null +++ b/internal/dumps/prune.go @@ -0,0 +1,34 @@ +package dumps + +import "time" + +// RetentionPolicy is filled in by Phase G. Phase B ships only a dry-run prune +// that lists what *would* be deleted given a max-age policy. +type RetentionPolicy struct { + MaxAge time.Duration +} + +// PruneReport contains the outcome of a dry-run prune operation. +type PruneReport struct { + Would []Meta + Kept []Meta +} + +// PruneDryRun returns which dumps would be deleted under policy p without +// actually deleting anything. +func (c *Catalog) PruneDryRun(p RetentionPolicy) (PruneReport, error) { + all, err := c.List() + if err != nil { + return PruneReport{}, err + } + now := time.Now() + var report PruneReport + for _, m := range all { + if p.MaxAge > 0 && now.Sub(m.Created) > p.MaxAge { + report.Would = append(report.Would, m) + } else { + report.Kept = append(report.Kept, m) + } + } + return report, nil +} diff --git a/internal/errs/errs.go b/internal/errs/errs.go new file mode 100644 index 0000000..b3abfc9 --- /dev/null +++ b/internal/errs/errs.go @@ -0,0 +1,58 @@ +// Package errs defines siphon's typed error model and exit-code taxonomy. +package errs + +import ( + "errors" +) + +// Sentinel errors. Use errors.Is / errors.As to match. +var ( + ErrProfileNotFound = errors.New("profile not found") + ErrProfileInvalid = errors.New("profile schema invalid") + ErrSecretUnresolved = errors.New("secret reference could not be resolved") + ErrDriverUnsupported = errors.New("driver not supported") + ErrToolMissing = errors.New("required external tool not found") + ErrConnectionFailed = errors.New("database connection failed") + ErrChecksumMismatch = errors.New("dump checksum mismatch") + ErrDumpCorrupt = errors.New("dump file corrupt or incomplete") + ErrIncompatibleEngine = errors.New("source/target engine incompatible") + Err2FARequired = errors.New("two-factor authentication required") + ErrCancelled = errors.New("cancelled by user") +) + +// Code is the exit-code taxonomy bucket. Matches the spec table in §4.2. +type Code int + +const ( + CodeOK Code = 0 + CodeUser Code = 1 + CodeSystem Code = 2 + CodeIntegrity Code = 3 + CodePartial Code = 4 + CodeCancelled Code = 130 + CodeTerminated Code = 143 +) + +// ExitCode returns the POSIX exit code for this Code. +func (c Code) ExitCode() int { return int(c) } + +// Error is the structured error type all Application verbs return. +type Error struct { + Op string // the verb name, e.g. "backup", "restore" + Code Code // exit-code taxonomy bucket + Cause error // underlying cause; matched by errors.Is / errors.As + Hint string // user-actionable remediation, rendered in CLI/TUI display +} + +func (e *Error) Error() string { + msg := e.Op + " failed" + if e.Cause != nil { + msg += ": " + e.Cause.Error() + } + if e.Hint != "" { + msg += " (" + e.Hint + ")" + } + return msg +} + +func (e *Error) Unwrap() error { return e.Cause } diff --git a/internal/errs/errs_test.go b/internal/errs/errs_test.go new file mode 100644 index 0000000..a96efca --- /dev/null +++ b/internal/errs/errs_test.go @@ -0,0 +1,85 @@ +package errs + +import ( + "errors" + "strings" + "testing" +) + +func TestSentinels_AreDistinct(t *testing.T) { + sentinels := []error{ + ErrProfileNotFound, + ErrProfileInvalid, + ErrSecretUnresolved, + ErrDriverUnsupported, + ErrToolMissing, + ErrConnectionFailed, + ErrChecksumMismatch, + ErrDumpCorrupt, + ErrIncompatibleEngine, + Err2FARequired, + ErrCancelled, + } + for i, a := range sentinels { + for j, b := range sentinels { + if i != j && a == b { + t.Fatalf("sentinels %d and %d are the same error", i, j) + } + } + } +} + +func TestError_Unwrap_FindsSentinel(t *testing.T) { + cause := ErrConnectionFailed + wrapped := &Error{ + Op: "backup", + Code: CodeSystem, + Cause: cause, + Hint: "check the host:port", + } + + if !errors.Is(wrapped, ErrConnectionFailed) { + t.Fatalf("errors.Is failed to find ErrConnectionFailed through Error") + } +} + +func TestError_Message_IncludesOpAndCause(t *testing.T) { + err := &Error{Op: "backup", Code: CodeSystem, Cause: ErrToolMissing, Hint: "install postgresql"} + got := err.Error() + if !strings.Contains(got, "backup") || !strings.Contains(got, "required external tool not found") { + t.Fatalf("Error() = %q; want it to contain 'backup' and the cause text", got) + } +} + +func TestError_Message_IncludesHint_WhenPresent(t *testing.T) { + err := &Error{Op: "backup", Code: CodeSystem, Cause: ErrToolMissing, Hint: "install postgresql"} + got := err.Error() + if !strings.Contains(got, "install postgresql") { + t.Fatalf("Error() = %q; want it to contain the hint", got) + } +} + +func TestError_Message_NoHint_NoParens(t *testing.T) { + err := &Error{Op: "backup", Code: CodeSystem, Cause: ErrToolMissing} + got := err.Error() + if strings.Contains(got, "(") { + t.Fatalf("Error() = %q; want no parentheses when hint is empty", got) + } +} + +func TestCode_ExitCode_MapsToSpec(t *testing.T) { + cases := map[Code]int{ + CodeOK: 0, + CodeUser: 1, + CodeSystem: 2, + CodeIntegrity: 3, + CodePartial: 4, + CodeCancelled: 130, + CodeTerminated: 143, + } + for code, want := range cases { + if got := code.ExitCode(); got != want { + t.Fatalf("Code(%d).ExitCode() = %d; want %d", code, got, want) + } + } +} diff --git a/internal/jobs/event.go b/internal/jobs/event.go new file mode 100644 index 0000000..4e61ba3 --- /dev/null +++ b/internal/jobs/event.go @@ -0,0 +1,41 @@ +// Package jobs runs long-running siphon operations and streams progress +// events to subscribers. Job IDs are ULIDs. +package jobs + +import "time" + +type Phase int + +const ( + PhaseStarted Phase = iota + PhaseProgress + PhaseWarn + PhaseDone + PhaseError + PhaseCancelled +) + +func (p Phase) String() string { + return [...]string{"started", "progress", "warn", "done", "error", "cancelled"}[p] +} + +// Event is one step in a job's lifecycle. Drivers + the runner emit Events +// on a shared channel that the CLI heartbeat and TUI job panel consume. +type Event struct { + JobID string + Stage string // "dump", "verify", "restore", etc. + Phase Phase + Progress *Progress + Message string + Err error + At time.Time +} + +// Progress is the live counters block. All fields are optional; populate +// what your stage can measure. +type Progress struct { + BytesDone, BytesTotal int64 + RowsDone, RowsTotal int64 + TableActive, TablesDone string + TablesTotal, TablesPassed int +} diff --git a/internal/jobs/runner.go b/internal/jobs/runner.go new file mode 100644 index 0000000..a59eedf --- /dev/null +++ b/internal/jobs/runner.go @@ -0,0 +1,111 @@ +package jobs + +import ( + "context" + "sync" + "time" + + "github.com/oklog/ulid/v2" +) + +// Job is the unit a Runner executes. Real work lives in Func; the runner +// provides ID, cancellation, and event-channel plumbing. +type Job struct { + Stage string // "dump", "restore", "sync" + Func func(ctx context.Context, emit func(Event)) error +} + +type JobStatus struct { + ID string + Stage string + Started time.Time + Cancel context.CancelFunc + Done bool +} + +// Runner orchestrates concurrent jobs. Methods are safe for concurrent use. +type Runner struct { + mu sync.Mutex + active map[string]*JobStatus +} + +func NewRunner() *Runner { + return &Runner{active: map[string]*JobStatus{}} +} + +// Run starts j in a goroutine and returns a buffered channel of Events. +// The channel is closed when the job ends (success, error, or cancel). +func (r *Runner) Run(parent context.Context, j Job) (<-chan Event, string, error) { + id := ulid.Make().String() + ctx, cancel := context.WithCancel(parent) + + r.mu.Lock() + r.active[id] = &JobStatus{ID: id, Stage: j.Stage, Started: time.Now(), Cancel: cancel} + r.mu.Unlock() + + ch := make(chan Event, 128) + stamp := func(e Event) Event { + e.JobID = id + e.Stage = j.Stage + if e.At.IsZero() { + e.At = time.Now() + } + return e + } + emit := func(e Event) { + select { + case ch <- stamp(e): + case <-ctx.Done(): + } + } + // send always delivers even after ctx is cancelled (used for terminal events). + send := func(e Event) { ch <- stamp(e) } + + go func() { + defer close(ch) + defer r.markDone(id) + + emit(Event{Phase: PhaseStarted}) + err := j.Func(ctx, emit) + switch { + case err == nil: + send(Event{Phase: PhaseDone}) + case ctx.Err() != nil: + send(Event{Phase: PhaseCancelled, Err: ctx.Err()}) + default: + send(Event{Phase: PhaseError, Err: err}) + } + }() + + return ch, id, nil +} + +func (r *Runner) markDone(id string) { + r.mu.Lock() + if s, ok := r.active[id]; ok { + s.Done = true + } + r.mu.Unlock() +} + +// Active returns a snapshot of in-flight jobs. +func (r *Runner) Active() []JobStatus { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]JobStatus, 0, len(r.active)) + for _, s := range r.active { + if !s.Done { + out = append(out, *s) + } + } + return out +} + +// Cancel signals the named job's context to finish. +func (r *Runner) Cancel(id string) { + r.mu.Lock() + if s, ok := r.active[id]; ok && s.Cancel != nil { + s.Cancel() + } + r.mu.Unlock() +} diff --git a/internal/jobs/runner_test.go b/internal/jobs/runner_test.go new file mode 100644 index 0000000..002f7fb --- /dev/null +++ b/internal/jobs/runner_test.go @@ -0,0 +1,98 @@ +package jobs + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestRunner_Run_StartedAndDoneEmitted(t *testing.T) { + r := NewRunner() + ch, _, err := r.Run(context.Background(), Job{ + Stage: "noop", + Func: func(context.Context, func(Event)) error { return nil }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + phases := drainPhases(ch) + wantPhases := []Phase{PhaseStarted, PhaseDone} + if !equalPhases(phases, wantPhases) { + t.Fatalf("phases = %v; want %v", phases, wantPhases) + } +} + +func TestRunner_Run_ErrorPropagates(t *testing.T) { + r := NewRunner() + boom := errors.New("boom") + ch, _, _ := r.Run(context.Background(), Job{ + Stage: "fail", + Func: func(context.Context, func(Event)) error { return boom }, + }) + + phases := drainPhases(ch) + if phases[len(phases)-1] != PhaseError { + t.Fatalf("last phase = %v; want PhaseError", phases[len(phases)-1]) + } +} + +func TestRunner_Cancel_PropagatesToFunc(t *testing.T) { + r := NewRunner() + started := make(chan struct{}) + + ch, id, _ := r.Run(context.Background(), Job{ + Stage: "sleep", + Func: func(ctx context.Context, _ func(Event)) error { + close(started) + <-ctx.Done() + return ctx.Err() + }, + }) + + <-started + r.Cancel(id) + + phases := drainPhases(ch) + if phases[len(phases)-1] != PhaseCancelled { + t.Fatalf("last phase = %v; want PhaseCancelled", phases[len(phases)-1]) + } +} + +func drain(ch <-chan Event) []Event { + var out []Event + timeout := time.After(2 * time.Second) + for { + select { + case e, ok := <-ch: + if !ok { + return out + } + out = append(out, e) + case <-timeout: + return out + } + } +} + +func drainPhases(ch <-chan Event) []Phase { + events := drain(ch) + out := make([]Phase, len(events)) + for i, e := range events { + out[i] = e.Phase + } + return out +} + +func equalPhases(a, b []Phase) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/profile/store.go b/internal/profile/store.go new file mode 100644 index 0000000..92bffa0 --- /dev/null +++ b/internal/profile/store.go @@ -0,0 +1,107 @@ +// Package profile provides CRUD operations on named connection profiles +// persisted in siphon's config file. +package profile + +import ( + "errors" + "sort" + + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/secrets" +) + +// Store is the runtime API for managing profiles. +type Store struct { + cfg *config.Config + res *secrets.Resolver + save func(*config.Config) error +} + +// New creates a Store backed by cfg. The save function controls +// persistence; pass config.Save in production, or a no-op for tests. +func New(cfg *config.Config, res *secrets.Resolver, save func(*config.Config) error) *Store { + if cfg.Profiles == nil { + cfg.Profiles = map[string]config.ProfileConfig{} + } + return &Store{cfg: cfg, res: res, save: save} +} + +// List returns all profile names, sorted alphabetically. +func (s *Store) List() []string { + names := make([]string, 0, len(s.cfg.Profiles)) + for n := range s.cfg.Profiles { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// Get returns the unresolved profile (Password is still a SecretRef). +func (s *Store) Get(name string) (config.ProfileConfig, error) { + p, ok := s.cfg.Profiles[name] + if !ok { + return config.ProfileConfig{}, &errs.Error{ + Op: "profile.get", + Code: errs.CodeUser, + Cause: errs.ErrProfileNotFound, + Hint: "run `siphon profile list` to see available profiles", + } + } + return p, nil +} + +// Resolve returns a driver.Profile with secrets materialized. +func (s *Store) Resolve(name string) (driver.Profile, error) { + p, err := s.Get(name) + if err != nil { + return driver.Profile{}, err + } + pass, err := s.res.Resolve(p.Password) + if err != nil { + return driver.Profile{}, err + } + return driver.Profile{ + Name: name, + Driver: p.Driver, + Host: p.Host, + Port: p.Port, + User: p.User, + Password: pass, + Database: p.Database, + SSLMode: p.SSLMode, + }, nil +} + +// Add stores a new profile. Returns an error if name already exists. +// Always sets p.Name = name to keep the Name field consistent with the +// map key (which Load() also enforces on reload). +func (s *Store) Add(name string, p config.ProfileConfig) error { + if _, exists := s.cfg.Profiles[name]; exists { + return &errs.Error{Op: "profile.add", Code: errs.CodeUser, Cause: errors.New("profile already exists: " + name)} + } + p.Name = name + s.cfg.Profiles[name] = p + return s.save(s.cfg) +} + +// Update overwrites an existing profile. Sets p.Name = name for the same +// reason as Add. +func (s *Store) Update(name string, p config.ProfileConfig) error { + if _, exists := s.cfg.Profiles[name]; !exists { + return &errs.Error{Op: "profile.update", Code: errs.CodeUser, Cause: errs.ErrProfileNotFound} + } + p.Name = name + s.cfg.Profiles[name] = p + return s.save(s.cfg) +} + +// Remove deletes a profile by name. +func (s *Store) Remove(name string) error { + if _, exists := s.cfg.Profiles[name]; !exists { + return &errs.Error{Op: "profile.remove", Code: errs.CodeUser, Cause: errs.ErrProfileNotFound} + } + delete(s.cfg.Profiles, name) + return s.save(s.cfg) +} diff --git a/internal/profile/store_test.go b/internal/profile/store_test.go new file mode 100644 index 0000000..1a77a58 --- /dev/null +++ b/internal/profile/store_test.go @@ -0,0 +1,86 @@ +package profile + +import ( + "errors" + "testing" + + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/secrets" +) + +func newTestStore() *Store { + cfg := &config.Config{Profiles: map[string]config.ProfileConfig{}} + res := secrets.NewResolver(secrets.Env{}, secrets.Passthrough{}) + return New(cfg, res, func(*config.Config) error { return nil }) +} + +func TestStore_AddListGetRemove_Roundtrip(t *testing.T) { + s := newTestStore() + + p := config.ProfileConfig{Driver: "postgres", Host: "localhost", Port: 5432, User: "u", Database: "d"} + if err := s.Add("dev", p); err != nil { + t.Fatalf("Add: %v", err) + } + + if names := s.List(); len(names) != 1 || names[0] != "dev" { + t.Fatalf("List() = %v; want [dev]", names) + } + + got, err := s.Get("dev") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Host != "localhost" { + t.Fatalf("Host = %q; want localhost", got.Host) + } + if got.Name != "dev" { + t.Fatalf("Name = %q; want 'dev' (Add should set Name = name)", got.Name) + } + + if err := s.Remove("dev"); err != nil { + t.Fatalf("Remove: %v", err) + } + if names := s.List(); len(names) != 0 { + t.Fatalf("List() after Remove = %v; want empty", names) + } +} + +func TestStore_Add_Duplicate_Errors(t *testing.T) { + s := newTestStore() + _ = s.Add("dev", config.ProfileConfig{Driver: "postgres"}) + err := s.Add("dev", config.ProfileConfig{Driver: "postgres"}) + if err == nil { + t.Fatal("expected error on duplicate add") + } +} + +func TestStore_GetMissing_ReturnsErrProfileNotFound(t *testing.T) { + s := newTestStore() + _, err := s.Get("ghost") + if !errors.Is(err, errs.ErrProfileNotFound) { + t.Fatalf("err = %v; want ErrProfileNotFound", err) + } +} + +func TestStore_Resolve_MaterializesPassword(t *testing.T) { + t.Setenv("PG_PASS", "topsecret") + s := newTestStore() + _ = s.Add("prod", config.ProfileConfig{ + Driver: "postgres", + Host: "db", + User: "app", + Password: "env:PG_PASS", + Database: "app", + }) + p, err := s.Resolve("prod") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if p.Password != "topsecret" { + t.Fatalf("Password = %q; want 'topsecret'", p.Password) + } + if p.Name != "prod" { + t.Fatalf("Name = %q; want 'prod'", p.Name) + } +} diff --git a/internal/secrets/env.go b/internal/secrets/env.go new file mode 100644 index 0000000..2ccc07c --- /dev/null +++ b/internal/secrets/env.go @@ -0,0 +1,36 @@ +package secrets + +import ( + "errors" + "os" + "strings" + + "github.com/nixrajput/siphon/internal/errs" +) + +// Env resolves "env:VAR" refs to os.Getenv("VAR"). +type Env struct{} + +func (Env) Scheme() string { return "env" } + +func (Env) Resolve(ref string) (string, error) { + name := strings.TrimPrefix(ref, "env:") + if name == "" { + return "", &errs.Error{ + Op: "secrets.env.resolve", + Code: errs.CodeUser, + Cause: errors.New("env: ref missing variable name"), + Hint: "use env:VARIABLE_NAME", + } + } + val, ok := os.LookupEnv(name) + if !ok { + return "", &errs.Error{ + Op: "secrets.env.resolve", + Code: errs.CodeUser, + Cause: errs.ErrSecretUnresolved, + Hint: "set environment variable " + name, + } + } + return val, nil +} diff --git a/internal/secrets/passthrough.go b/internal/secrets/passthrough.go new file mode 100644 index 0000000..8400257 --- /dev/null +++ b/internal/secrets/passthrough.go @@ -0,0 +1,8 @@ +package secrets + +// Passthrough returns the ref as-is. Used for literal cleartext values +// (or for values that have already been env-interpolated at config-load time). +type Passthrough struct{} + +func (Passthrough) Scheme() string { return "" } +func (Passthrough) Resolve(ref string) (string, error) { return ref, nil } diff --git a/internal/secrets/resolver.go b/internal/secrets/resolver.go new file mode 100644 index 0000000..49637bf --- /dev/null +++ b/internal/secrets/resolver.go @@ -0,0 +1,80 @@ +// Package secrets resolves SecretRef values (e.g. "${VAR}", "keychain://name", +// "vault://...") to their cleartext form via pluggable backends. +package secrets + +import ( + "errors" + "strings" + + "github.com/nixrajput/siphon/internal/errs" +) + +// Backend resolves SecretRef values it claims via Scheme(). +type Backend interface { + Scheme() string // "env", "keychain", "vault", "" for passthrough + Resolve(ref string) (string, error) +} + +// Resolver dispatches refs to the matching backend. +type Resolver struct { + backends []Backend +} + +// NewResolver builds a Resolver from the given backends. Order matters: +// the first backend whose Scheme() matches the ref handles it. +func NewResolver(backends ...Backend) *Resolver { + return &Resolver{backends: backends} +} + +// Resolve dispatches ref to a matching backend. Refs use a "scheme:value" +// or "scheme://value" shape; values with no scheme go to passthrough. +// +// ${VAR} env interpolation is handled at config-load time (see internal/config), +// not here — this function operates on already-loaded ProfileConfig.Password +// values that may carry an explicit prefix. +func (r *Resolver) Resolve(ref string) (string, error) { + if ref == "" { + return "", nil + } + scheme := parseScheme(ref) + for _, b := range r.backends { + if b.Scheme() == scheme { + return b.Resolve(ref) + } + } + // No backend claims this scheme. Fall back to the passthrough ("") backend + // rather than erroring: a literal value can legitimately contain a colon + // before any "/" or space (e.g. a Postgres password "p@ss:w0rd"), which + // parseScheme would otherwise read as the unknown scheme "p@ss". + for _, b := range r.backends { + if b.Scheme() == "" { + return b.Resolve(ref) + } + } + return "", &errs.Error{ + Op: "secrets.resolve", + Code: errs.CodeUser, + Cause: errors.New("no backend matches scheme " + scheme), + Hint: "available backends: " + strings.Join(r.schemes(), ", "), + } +} + +func (r *Resolver) schemes() []string { + out := make([]string, 0, len(r.backends)) + for _, b := range r.backends { + out = append(out, b.Scheme()) + } + return out +} + +func parseScheme(ref string) string { + for i := 0; i < len(ref); i++ { + switch ref[i] { + case ':': + return ref[:i] + case '/', ' ': + return "" // looks like a literal value + } + } + return "" +} diff --git a/internal/secrets/resolver_test.go b/internal/secrets/resolver_test.go new file mode 100644 index 0000000..68b26ba --- /dev/null +++ b/internal/secrets/resolver_test.go @@ -0,0 +1,77 @@ +package secrets + +import ( + "errors" + "testing" + + "github.com/nixrajput/siphon/internal/errs" +) + +func TestResolver_Passthrough_LiteralValue(t *testing.T) { + r := NewResolver(Passthrough{}, Env{}) + got, err := r.Resolve("hunter2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "hunter2" { + t.Fatalf("got %q; want 'hunter2'", got) + } +} + +func TestResolver_EnvBackend_Resolves(t *testing.T) { + t.Setenv("MY_SECRET", "shh") + r := NewResolver(Env{}, Passthrough{}) + got, err := r.Resolve("env:MY_SECRET") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "shh" { + t.Fatalf("got %q; want 'shh'", got) + } +} + +func TestResolver_EnvMissing_ReturnsErrSecretUnresolved(t *testing.T) { + r := NewResolver(Env{}, Passthrough{}) + _, err := r.Resolve("env:DEFINITELY_NOT_SET_" + t.Name()) + if !errors.Is(err, errs.ErrSecretUnresolved) { + t.Fatalf("got %v; want ErrSecretUnresolved", err) + } +} + +// With a passthrough backend present, an unknown scheme falls back to the +// literal value rather than erroring — there's no way to tell "meant the vault +// backend" from "literal value that looks like vault:foo", so passthrough wins. +func TestResolver_UnknownScheme_FallsBackToPassthrough(t *testing.T) { + r := NewResolver(Passthrough{}, Env{}) + got, err := r.Resolve("vault:foo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "vault:foo" { + t.Fatalf("got %q; want literal 'vault:foo' via passthrough", got) + } +} + +// Without a passthrough backend, an unknown scheme still errors (and the error +// names the available backends). +func TestResolver_UnknownScheme_NoPassthrough_Errors(t *testing.T) { + r := NewResolver(Env{}) + _, err := r.Resolve("vault:foo") + if err == nil { + t.Fatal("expected error for unknown scheme when no passthrough backend") + } +} + +// TestResolver_LiteralWithColon is the regression: a literal password +// containing a colon (valid in Postgres) must resolve as-is, not be misread as +// the unknown scheme before the colon. +func TestResolver_LiteralWithColon(t *testing.T) { + r := NewResolver(Env{}, Passthrough{}) + got, err := r.Resolve("p@ss:w0rd") + if err != nil { + t.Fatalf("literal with colon errored: %v", err) + } + if got != "p@ss:w0rd" { + t.Fatalf("got %q; want literal 'p@ss:w0rd'", got) + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..8ed5363 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,48 @@ +// Package tui hosts siphon's Bubble Tea application. Phase A ships a +// minimal placeholder; the real multi-panel dashboard lands in Phase C. +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Model struct { + quitting bool +} + +// New returns a fresh, ready-to-run TUI model. +func New() Model { return Model{} } + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "q", "esc", "ctrl+c": + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +var ( + title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7dd3fc")) + subtitle = lipgloss.NewStyle().Faint(true) +) + +func (m Model) View() string { + if m.quitting { + return "" + } + return title.Render("siphon") + "\n" + + subtitle.Render("sync any database, anywhere") + "\n\n" + + "press q to quit.\n" +} + +// Run starts the TUI program and blocks until it exits. +func Run() error { + _, err := tea.NewProgram(New()).Run() + return err +} diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go new file mode 100644 index 0000000..789115b --- /dev/null +++ b/internal/tui/dashboard_test.go @@ -0,0 +1,41 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestModel_View_ContainsTitleAndQuitHint(t *testing.T) { + m := New() + view := m.View() + if !strings.Contains(view, "siphon") { + t.Fatalf("View() = %q; want it to contain 'siphon'", view) + } + if !strings.Contains(view, "quit") { + t.Fatalf("View() = %q; want it to contain quit hint", view) + } +} + +func TestModel_Update_QQuits(t *testing.T) { + m := New() + out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if cmd == nil { + t.Fatal("Update on 'q' returned no command; expected tea.Quit") + } + if updated, ok := out.(Model); !ok || !updated.quitting { + t.Fatalf("Update did not flag quitting; got model=%+v ok=%v", out, ok) + } +} + +func TestModel_Update_OtherKey_Noop(t *testing.T) { + m := New() + out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if cmd != nil { + t.Fatalf("Update on 'x' returned a command; expected nil") + } + if updated, ok := out.(Model); !ok || updated.quitting { + t.Fatalf("Update flagged quitting on 'x'; should not") + } +}