merge: Фаза 1B — persistence + REST API

- internal/config: env-конфиг (DSN, ENC-ключ, listen)
- internal/crypto: AES-256-GCM шифрование секретов
- internal/store: goose-миграции + seed, sqlc (pgx/v5, uuid override), dto JSONB, Repository, Loader/Recorder (testcontainers)
- internal/provider/registry: резолвинг провайдера
- internal/service: Check/Apply с guard на prune
- internal/api: chi REST — check/apply + CRUD accounts/templates/domains + import + PATCH привязки шаблона
- cmd/server: wiring
Финальный ревью: READY TO MERGE. 72 теста / 13 пакетов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:33:36 +07:00
41 changed files with 3720 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"context"
"log"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/vasyakrg/dns-autoresolver/internal/api"
"github.com/vasyakrg/dns-autoresolver/internal/config"
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/provider/selectel"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store"
)
func main() {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if err := store.Migrate(ctx, cfg.DBDSN); err != nil {
log.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(ctx, cfg.DBDSN)
if err != nil {
log.Fatalf("pool: %v", err)
}
defer pool.Close()
cipher, err := crypto.NewCipher(cfg.EncKey)
if err != nil {
log.Fatalf("cipher: %v", err)
}
st := store.New(pool)
reg := registry.New()
reg.Register(selectel.New())
svc := service.New(st, st, reg, cipher)
a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg}
log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil {
log.Fatal(err)
}
}
+69
View File
@@ -1,3 +1,72 @@
module github.com/vasyakrg/dns-autoresolver module github.com/vasyakrg/dns-autoresolver
go 1.26.4 go 1.26.4
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.2
github.com/testcontainers/testcontainers-go v0.43.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0
)
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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // 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/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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mfridman/interpolate v0.0.2 // 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.55.0 // indirect
github.com/moby/moby/client v0.5.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/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/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // 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.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+184
View File
@@ -0,0 +1,184 @@
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/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/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/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.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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/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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/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/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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
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.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/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/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8=
github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/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/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.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs=
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks=
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/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.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// CheckApplier is the service surface the API depends on.
type CheckApplier interface {
Check(ctx context.Context, domainID uuid.UUID) (diff.Changeset, error)
Apply(ctx context.Context, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
}
// TenantStore is the narrow persistence surface the CRUD handlers depend on.
// *store.Store satisfies it directly via its thin wrapper methods (see
// internal/store/tenant.go); tests can supply their own mock.
type TenantStore interface {
CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (store.Account, error)
ListAccounts(ctx context.Context, projectID uuid.UUID) ([]store.Account, error)
GetAccount(ctx context.Context, id, projectID uuid.UUID) (store.Account, error)
DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error
CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error)
ListTemplates(ctx context.Context, projectID uuid.UUID) ([]store.Template, error)
UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error)
DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error
GetTemplate(ctx context.Context, id, projectID uuid.UUID) (store.Template, error)
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
}
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
type Cipher interface {
Encrypt(plaintext []byte) (string, error)
Decrypt(enc string) ([]byte, error)
}
// ProviderRegistry resolves a provider.Provider by name. *registry.Registry satisfies it.
type ProviderRegistry interface {
ByName(name string) (provider.Provider, error)
}
// API holds handler dependencies.
type API struct {
Svc CheckApplier
Store TenantStore
Cipher Cipher
Reg ProviderRegistry
}
func NewRouter(a *API) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Route("/api/v1/projects/{pid}", func(r chi.Router) {
r.Route("/domains", func(r chi.Router) {
r.Post("/", a.handleCreateDomain)
r.Get("/", a.handleListDomains)
r.Route("/{did}", func(r chi.Router) {
r.Get("/check", a.handleCheck)
r.Post("/apply", a.handleApply)
r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain)
})
})
r.Route("/accounts", func(r chi.Router) {
r.Post("/", a.handleCreateAccount)
r.Get("/", a.handleListAccounts)
r.Route("/{aid}", func(r chi.Router) {
r.Delete("/", a.handleDeleteAccount)
r.Post("/import", a.handleImportZones)
})
})
r.Route("/templates", func(r chi.Router) {
r.Post("/", a.handleCreateTemplate)
r.Get("/", a.handleListTemplates)
r.Route("/{tid}", func(r chi.Router) {
r.Put("/", a.handleUpdateTemplate)
r.Delete("/", a.handleDeleteTemplate)
})
})
})
return r
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/service"
)
type mockCheckApplier struct {
lastReq service.ApplyRequest
}
func (m *mockCheckApplier) Check(context.Context, uuid.UUID) (diff.Changeset, error) {
d := model.Record{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}
return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Add, Type: d.Type, Name: d.Name, Desired: &d}}}, nil
}
func (m *mockCheckApplier) Apply(_ context.Context, _ uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) {
m.lastReq = req
return diff.Changeset{}, nil
}
func newTestAPI() (*API, *mockCheckApplier) {
m := &mockCheckApplier{}
return &API{Svc: m}, m // остальные зависимости (store/cipher) nil — CRUD-тесты добавит реализатор
}
func TestCheckEndpoint(t *testing.T) {
a, _ := newTestAPI()
router := NewRouter(a)
did := uuid.New().String()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/check", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
var resp changesetResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Updates) != 1 {
t.Fatalf("expected 1 update in response, got %+v", resp)
}
}
func TestApplyDefaultsPruneFalse(t *testing.T) {
a, m := newTestAPI()
router := NewRouter(a)
did := uuid.New().String()
body := `{"applyUpdates":true}` // applyPrunes отсутствует → false
req := httptest.NewRequest(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if m.lastReq.ApplyPrunes != false || m.lastReq.ApplyUpdates != true {
t.Fatalf("apply request mismatch: %+v", m.lastReq)
}
}
func TestApplyEmptyBodyOK(t *testing.T) {
a, m := newTestAPI()
router := NewRouter(a)
did := uuid.New().String()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if m.lastReq.ApplyPrunes != false {
t.Fatalf("expected ApplyPrunes=false for empty body, got %+v", m.lastReq)
}
}
func TestApplyMalformedBody(t *testing.T) {
a, _ := newTestAPI()
router := NewRouter(a)
did := uuid.New().String()
body := `{"applyUpdates":`
req := httptest.NewRequest(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for malformed body, got %d body %s", w.Code, w.Body.String())
}
}
func TestApplyBadUUID(t *testing.T) {
a, _ := newTestAPI()
router := NewRouter(a)
req := httptest.NewRequest(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/not-a-uuid/apply",
bytes.NewReader([]byte(`{}`)))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for bad uuid, got %d", w.Code)
}
}
+54
View File
@@ -0,0 +1,54 @@
package api
import "github.com/vasyakrg/dns-autoresolver/internal/diff"
type applyRequest struct {
ApplyUpdates bool `json:"applyUpdates"`
ApplyPrunes bool `json:"applyPrunes"`
}
type recordView struct {
Kind string `json:"kind"`
Type string `json:"type"`
Name string `json:"name"`
Desired []string `json:"desired,omitempty"`
Actual []string `json:"actual,omitempty"`
ReadOnly bool `json:"readOnly"`
}
type changesetResponse struct {
Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"`
ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"`
}
func toRecordView(d diff.RecordDiff) recordView {
rv := recordView{Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly}
if d.Desired != nil {
rv.Desired = d.Desired.Values
}
if d.Actual != nil {
rv.Actual = d.Actual.Values
}
return rv
}
func toChangesetResponse(cs diff.Changeset) changesetResponse {
resp := changesetResponse{}
for _, d := range cs.Updates() {
resp.Updates = append(resp.Updates, toRecordView(d))
}
for _, d := range cs.Prunes() {
resp.Prunes = append(resp.Prunes, toRecordView(d))
}
for _, d := range cs.Diffs {
if d.ReadOnly {
resp.ReadOnly = append(resp.ReadOnly, toRecordView(d))
}
if d.Kind == diff.InSync {
resp.InSync++
}
}
return resp
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/service"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func (a *API) handleCheck(w http.ResponseWriter, r *http.Request) {
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
cs, err := a.Svc.Check(r.Context(), did)
if err != nil {
log.Printf("api: check failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
}
func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
var req applyRequest
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
// пустое тело допустимо → значения по умолчанию (prune=false);
// любая другая ошибка decode (битый JSON, неверные типы) → 400
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
}
cs, err := a.Svc.Apply(r.Context(), did, service.ApplyRequest{
ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes,
})
if err != nil {
log.Printf("api: apply failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
}
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
type accountRequest struct {
Provider string `json:"provider"`
Secret string `json:"secret"`
Comment string `json:"comment"`
}
// accountResponse deliberately excludes the secret (plaintext or encrypted).
type accountResponse struct {
ID string `json:"id"`
Provider string `json:"provider"`
Comment string `json:"comment"`
}
func toAccountResponse(a store.Account) accountResponse {
return accountResponse{ID: a.ID.String(), Provider: a.Provider, Comment: a.Comment}
}
type templateRequest struct {
Name string `json:"name"`
Records []dto.RecordDTO `json:"records"`
}
type templateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Records []dto.RecordDTO `json:"records"`
Version int32 `json:"version"`
}
func toTemplateResponse(t store.Template) templateResponse {
return templateResponse{ID: t.ID.String(), Name: t.Name, Records: t.Doc.Records, Version: t.Version}
}
type domainRequest struct {
ProviderAccountID string `json:"providerAccountId"`
ZoneName string `json:"zoneName"`
ZoneID string `json:"zoneId"`
TemplateID *string `json:"templateId,omitempty"`
}
// updateDomainTemplateRequest is the PATCH .../domains/{did} body used to
// bind (or clear, when templateId is null/omitted) a domain's DNS template.
type updateDomainTemplateRequest struct {
TemplateID *string `json:"templateId"`
}
type domainResponse struct {
ID string `json:"id"`
ProviderAccountID string `json:"providerAccountId"`
ZoneName string `json:"zoneName"`
ZoneID string `json:"zoneId"`
TemplateID *string `json:"templateId,omitempty"`
}
func toDomainResponse(d store.Domain) domainResponse {
resp := domainResponse{
ID: d.ID.String(), ProviderAccountID: d.ProviderAccountID.String(),
ZoneName: d.ZoneName, ZoneID: d.ZoneID,
}
if d.TemplateID != nil {
s := d.TemplateID.String()
resp.TemplateID = &s
}
return resp
}
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
if s == nil || *s == "" {
return nil, true
}
id, err := uuid.Parse(*s)
if err != nil {
return nil, false
}
return &id, true
}
+358
View File
@@ -0,0 +1,358 @@
package api
import (
"encoding/json"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return false
}
return true
}
// --- accounts ---
func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
var req accountRequest
if !decodeBody(w, r, &req) {
return
}
if req.Provider == "" || req.Secret == "" {
writeErr(w, http.StatusBadRequest, "provider and secret are required")
return
}
secretEnc, err := a.Cipher.Encrypt([]byte(req.Secret))
if err != nil {
log.Printf("api: encrypt secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
acc, err := a.Store.CreateAccount(r.Context(), pid, req.Provider, secretEnc, req.Comment)
if err != nil {
log.Printf("api: create account failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toAccountResponse(acc))
}
func (a *API) handleListAccounts(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
accs, err := a.Store.ListAccounts(r.Context(), pid)
if err != nil {
log.Printf("api: list accounts failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := make([]accountResponse, 0, len(accs))
for _, acc := range accs {
resp = append(resp, toAccountResponse(acc))
}
writeJSON(w, http.StatusOK, resp)
}
func (a *API) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
aid, err := uuid.Parse(chi.URLParam(r, "aid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid account id")
return
}
if err := a.Store.DeleteAccount(r.Context(), aid, pid); err != nil {
log.Printf("api: delete account failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleImportZones lists zones from the provider for the given account and
// creates one domain per zone (template_id left unset).
func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
aid, err := uuid.Parse(chi.URLParam(r, "aid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid account id")
return
}
acc, err := a.Store.GetAccount(r.Context(), aid, pid)
if err != nil {
log.Printf("api: import: get account failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
secret, err := a.Cipher.Decrypt(acc.SecretEnc)
if err != nil {
log.Printf("api: import: decrypt secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
p, err := a.Reg.ByName(acc.Provider)
if err != nil {
log.Printf("api: import: unknown provider: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
zones, err := p.ListZones(r.Context(), provider.Credentials{Secret: string(secret)})
if err != nil {
log.Printf("api: import: list zones failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
// Imported atomically: either every zone becomes a domain or none does,
// so a mid-batch provider/DB error never leaves a partial import behind.
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
if err != nil {
log.Printf("api: import: create domains failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
created := make([]domainResponse, 0, len(doms))
for _, d := range doms {
created = append(created, toDomainResponse(d))
}
writeJSON(w, http.StatusCreated, created)
}
// --- templates ---
func (a *API) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
var req templateRequest
if !decodeBody(w, r, &req) {
return
}
if req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
doc := dto.TemplateDoc{Records: req.Records}
tpl, err := a.Store.CreateTemplate(r.Context(), pid, req.Name, doc)
if err != nil {
log.Printf("api: create template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toTemplateResponse(tpl))
}
func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
tpls, err := a.Store.ListTemplates(r.Context(), pid)
if err != nil {
log.Printf("api: list templates failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := make([]templateResponse, 0, len(tpls))
for _, t := range tpls {
resp = append(resp, toTemplateResponse(t))
}
writeJSON(w, http.StatusOK, resp)
}
func (a *API) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
tid, err := uuid.Parse(chi.URLParam(r, "tid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid template id")
return
}
var req templateRequest
if !decodeBody(w, r, &req) {
return
}
if req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
doc := dto.TemplateDoc{Records: req.Records}
tpl, err := a.Store.UpdateTemplate(r.Context(), tid, pid, req.Name, doc)
if err != nil {
log.Printf("api: update template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, toTemplateResponse(tpl))
}
func (a *API) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
tid, err := uuid.Parse(chi.URLParam(r, "tid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid template id")
return
}
if err := a.Store.DeleteTemplate(r.Context(), tid, pid); err != nil {
log.Printf("api: delete template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- domains ---
func (a *API) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
var req domainRequest
if !decodeBody(w, r, &req) {
return
}
accID, err := uuid.Parse(req.ProviderAccountID)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid providerAccountId")
return
}
if req.ZoneName == "" || req.ZoneID == "" {
writeErr(w, http.StatusBadRequest, "zoneName and zoneId are required")
return
}
templateID, ok := parseOptionalUUID(req.TemplateID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid templateId")
return
}
// Tenant isolation: the account (and template, if given) must belong to
// this project — otherwise a caller could attach a domain to another
// tenant's provider account or DNS template.
if _, err := a.Store.GetAccount(r.Context(), accID, pid); err != nil {
writeErr(w, http.StatusNotFound, "provider account not found")
return
}
if templateID != nil {
if _, err := a.Store.GetTemplate(r.Context(), *templateID, pid); err != nil {
writeErr(w, http.StatusNotFound, "template not found")
return
}
}
dom, err := a.Store.CreateDomain(r.Context(), pid, accID, req.ZoneName, req.ZoneID, templateID)
if err != nil {
log.Printf("api: create domain failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toDomainResponse(dom))
}
func (a *API) handleListDomains(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
doms, err := a.Store.ListDomains(r.Context(), pid)
if err != nil {
log.Printf("api: list domains failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := make([]domainResponse, 0, len(doms))
for _, d := range doms {
resp = append(resp, toDomainResponse(d))
}
writeJSON(w, http.StatusOK, resp)
}
// handleSetDomainTemplate binds (or clears) the DNS template used to
// check/apply a domain — this is what makes an imported domain (which
// starts with template_id=NULL) checkable, closing the import→check loop.
func (a *API) handleSetDomainTemplate(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
var req updateDomainTemplateRequest
if !decodeBody(w, r, &req) {
return
}
templateID, ok := parseOptionalUUID(req.TemplateID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid templateId")
return
}
dom, err := a.Store.SetDomainTemplate(r.Context(), did, pid, templateID)
if err != nil {
// Either the domain itself or the (scoped) template wasn't found in
// this project — treat both as 404 rather than leak which one.
writeErr(w, http.StatusNotFound, "domain or template not found")
return
}
writeJSON(w, http.StatusOK, toDomainResponse(dom))
}
func (a *API) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
if err := a.Store.DeleteDomain(r.Context(), did, pid); err != nil {
log.Printf("api: delete domain failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
+553
View File
@@ -0,0 +1,553 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
const testPID = "00000000-0000-0000-0000-000000000002"
// --- mocks ---
type mockTenantStore struct {
accounts []store.Account
createAccounts []struct{ provider, secretEnc, comment string }
templates []store.Template
createTemplate *store.Template
domains []store.Domain
createDomains int
importDomains []store.Domain
importDomainsErr error
importCalled bool
setDomainTemplateErr error
}
func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) {
m.createAccounts = append(m.createAccounts, struct{ provider, secretEnc, comment string }{prov, secretEnc, comment})
acc := store.Account{ID: uuid.New(), ProjectID: projectID, Provider: prov, SecretEnc: secretEnc, Comment: comment}
m.accounts = append(m.accounts, acc)
return acc, nil
}
func (m *mockTenantStore) ListAccounts(context.Context, uuid.UUID) ([]store.Account, error) {
return m.accounts, nil
}
func (m *mockTenantStore) GetAccount(_ context.Context, id, _ uuid.UUID) (store.Account, error) {
for _, a := range m.accounts {
if a.ID == id {
return a, nil
}
}
return store.Account{}, errors.New("account not found")
}
func (m *mockTenantStore) DeleteAccount(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (m *mockTenantStore) CreateTemplate(_ context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) {
tpl := store.Template{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: doc, Version: 1}
m.createTemplate = &tpl
m.templates = append(m.templates, tpl)
return tpl, nil
}
func (m *mockTenantStore) ListTemplates(context.Context, uuid.UUID) ([]store.Template, error) {
return m.templates, nil
}
func (m *mockTenantStore) UpdateTemplate(_ context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) {
return store.Template{ID: id, ProjectID: projectID, Name: name, Doc: doc, Version: 2}, nil
}
func (m *mockTenantStore) DeleteTemplate(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (m *mockTenantStore) GetTemplate(_ context.Context, id, _ uuid.UUID) (store.Template, error) {
for _, t := range m.templates {
if t.ID == id {
return t, nil
}
}
return store.Template{}, errors.New("template not found")
}
func (m *mockTenantStore) CreateDomain(_ context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error) {
m.createDomains++
d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID}
m.domains = append(m.domains, d)
return d, nil
}
func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domain, error) {
return m.domains, nil
}
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
if m.setDomainTemplateErr != nil {
return store.Domain{}, m.setDomainTemplateErr
}
for i, d := range m.domains {
if d.ID == domainID {
m.domains[i].TemplateID = templateID
return m.domains[i], nil
}
}
d := store.Domain{ID: domainID, ProjectID: projectID, TemplateID: templateID}
m.domains = append(m.domains, d)
return d, nil
}
func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
m.importCalled = true
if m.importDomainsErr != nil {
return nil, m.importDomainsErr
}
out := make([]store.Domain, 0, len(zones))
for _, z := range zones {
d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: z.Name, ZoneID: z.ID}
out = append(out, d)
}
m.domains = append(m.domains, out...)
m.importDomains = out
return out, nil
}
type mockCipher struct{}
func (mockCipher) Encrypt(plaintext []byte) (string, error) { return "ENC(" + string(plaintext) + ")", nil }
func (mockCipher) Decrypt(enc string) ([]byte, error) {
return []byte(strings.TrimSuffix(strings.TrimPrefix(enc, "ENC("), ")")), nil
}
type mockRegistry struct {
zones []provider.Zone
}
func (r *mockRegistry) ByName(name string) (provider.Provider, error) {
return &mockProvider{zones: r.zones}, nil
}
type mockProvider struct {
zones []provider.Zone
}
func (mockProvider) Name() string { return "mock" }
func (p mockProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) {
return p.zones, nil
}
func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
return nil, nil
}
func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
return nil
}
func newTenantTestAPI() (*API, *mockTenantStore) {
ts := &mockTenantStore{}
a := &API{Store: ts, Cipher: mockCipher{}, Reg: &mockRegistry{}}
return a, ts
}
// --- accounts ---
func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
body := `{"provider":"selectel","secret":"super-secret-token","comment":"prod"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "super-secret-token") {
t.Fatalf("response leaks plaintext secret: %s", w.Body.String())
}
if len(ts.createAccounts) != 1 {
t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts))
}
got := ts.createAccounts[0].secretEnc
if got == "super-secret-token" {
t.Fatalf("store received plaintext secret instead of encrypted value")
}
if got != "ENC(super-secret-token)" {
t.Fatalf("unexpected encrypted secret stored: %q", got)
}
var resp accountResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Provider != "selectel" || resp.Comment != "prod" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestListAccounts_NoSecretsInResponse(t *testing.T) {
a, ts := newTenantTestAPI()
ts.accounts = []store.Account{
{ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(top-secret)", Comment: "one"},
{ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(other-secret)", Comment: "two"},
}
router := NewRouter(a)
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+testPID+"/accounts", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "secret") {
t.Fatalf("response leaks secret field: %s", w.Body.String())
}
var resp []accountResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 accounts, got %d", len(resp))
}
}
func TestDeleteAccount_BadUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// --- templates ---
func TestCreateTemplate_SavesRecords(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
body := `{"name":"base","records":[{"type":"A","name":"@","ttl":300,"values":["1.2.3.4"]}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/templates", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if ts.createTemplate == nil {
t.Fatal("expected CreateTemplate to be called")
}
if len(ts.createTemplate.Doc.Records) != 1 || ts.createTemplate.Doc.Records[0].Type != "A" {
t.Fatalf("unexpected saved doc: %+v", ts.createTemplate.Doc)
}
var resp templateResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Name != "base" || len(resp.Records) != 1 {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestUpdateTemplate_BadUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
body := `{"name":"x","records":[]}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+testPID+"/templates/not-a-uuid", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// --- domains / import ---
func TestImportZones_CreatesDomainPerZone(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
a.Reg = &mockRegistry{zones: []provider.Zone{
{ID: "z1", Name: "example.com"},
{ID: "z2", Name: "example.net"},
}}
router := NewRouter(a)
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if !ts.importCalled {
t.Fatal("expected ImportDomains to be called")
}
if len(ts.importDomains) != 2 {
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
}
var resp []domainResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 domains in response, got %d", len(resp))
}
}
// TestImportZones_AtomicRollbackOnError verifies that when the store fails
// to import the batch (e.g. a mid-batch DB error), the handler surfaces a
// 500 and — per store.ImportDomains' transactional contract — no partial
// set of domains is left behind (modeled here by ImportDomains returning no
// domains alongside the error).
func TestImportZones_AtomicRollbackOnError(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
ts.importDomainsErr = errors.New("boom: mid-batch failure")
a.Reg = &mockRegistry{zones: []provider.Zone{
{ID: "z1", Name: "example.com"},
{ID: "z2", Name: "example.net"},
}}
router := NewRouter(a)
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "boom") {
t.Fatalf("internal error details leaked to response: %s", w.Body.String())
}
if len(ts.domains) != 0 {
t.Fatalf("expected no domains to be created on rollback, got %d", len(ts.domains))
}
}
func TestImportZones_BadAccountUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid/import", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCreateDomain_BadProjectUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
body := `{"providerAccountId":"` + uuid.New().String() + `","zoneName":"example.com","zoneId":"z1"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/not-a-uuid/domains", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// TestCreateDomain_AccountNotFoundInProject covers the HIGH tenant-isolation
// fix: a providerAccountId that scoped GetAccount can't find within this
// project must be rejected before any domain is created — otherwise a
// caller could attach a domain to another tenant's provider account.
func TestCreateDomain_AccountNotFoundInProject(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
// ts.accounts is empty, so GetAccount will not find this id.
foreignAccID := uuid.New()
body := `{"providerAccountId":"` + foreignAccID.String() + `","zoneName":"example.com","zoneId":"z1"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
}
if ts.createDomains != 0 {
t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains)
}
}
// TestCreateDomain_TemplateNotFoundInProject covers the same isolation fix
// for the optional templateId: a template belonging to another project (or
// nonexistent) must reject the request before the domain is created.
func TestCreateDomain_TemplateNotFoundInProject(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
router := NewRouter(a)
foreignTplID := uuid.New()
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + foreignTplID.String() + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
}
if ts.createDomains != 0 {
t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains)
}
}
// TestCreateDomain_HappyPath ensures the tenant-isolation checks don't break
// the existing success path: a valid account in-project and no template.
func TestCreateDomain_HappyPath(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
router := NewRouter(a)
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String())
}
if ts.createDomains != 1 {
t.Fatalf("expected 1 CreateDomain call, got %d", ts.createDomains)
}
var resp domainResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ZoneName != "example.com" || resp.TemplateID != nil {
t.Fatalf("unexpected response: %+v", resp)
}
}
// TestCreateDomain_ValidTemplateInProject ensures a template that scoped
// GetTemplate does find (i.e. belongs to this project) is accepted.
func TestCreateDomain_ValidTemplateInProject(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
tplID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
ts.templates = []store.Template{{ID: tplID, Name: "base"}}
router := NewRouter(a)
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + tplID.String() + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String())
}
var resp domainResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.TemplateID == nil || *resp.TemplateID != tplID.String() {
t.Fatalf("unexpected response: %+v", resp)
}
}
// --- domain template binding (import -> check loop) ---
func TestSetDomainTemplate_ValidTemplateId(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
tplID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
router := NewRouter(a)
body := `{"templateId":"` + tplID.String() + `"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body %s", w.Code, w.Body.String())
}
var resp domainResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.TemplateID == nil || *resp.TemplateID != tplID.String() {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestSetDomainTemplate_BadTemplateUUID(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID}}
router := NewRouter(a)
body := `{"templateId":"not-a-uuid"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String())
}
}
func TestSetDomainTemplate_TemplateNotFound(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID}}
ts.setDomainTemplateErr = errors.New("template not found in project")
router := NewRouter(a)
body := `{"templateId":"` + uuid.New().String() + `"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
}
}
func TestDeleteDomain_BadUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+testPID+"/domains/not-a-uuid", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"encoding/base64"
"fmt"
"os"
)
type Config struct {
DBDSN string
EncKey []byte
ListenAddr string
}
func Load() (*Config, error) {
dsn := os.Getenv("DNS_AR_DB_DSN")
if dsn == "" {
return nil, fmt.Errorf("config: DNS_AR_DB_DSN is required")
}
rawKey := os.Getenv("DNS_AR_ENC_KEY")
if rawKey == "" {
return nil, fmt.Errorf("config: DNS_AR_ENC_KEY is required")
}
key, err := base64.StdEncoding.DecodeString(rawKey)
if err != nil {
return nil, fmt.Errorf("config: DNS_AR_ENC_KEY must be base64: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("config: DNS_AR_ENC_KEY must decode to 32 bytes, got %d", len(key))
}
listen := os.Getenv("DNS_AR_LISTEN")
if listen == "" {
listen = ":8080"
}
return &Config{DBDSN: dsn, EncKey: key, ListenAddr: listen}, nil
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"encoding/base64"
"testing"
)
func setEnv(t *testing.T, k, v string) {
t.Helper()
t.Setenv(k, v)
}
func TestLoadValid(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
setEnv(t, "DNS_AR_DB_DSN", "postgres://u:p@localhost:5432/db")
setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString(key))
// DNS_AR_LISTEN не задан — дефолт
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.DBDSN == "" || len(cfg.EncKey) != 32 || cfg.ListenAddr != ":8080" {
t.Fatalf("unexpected cfg: %+v", cfg)
}
}
func TestLoadRejectsShortKey(t *testing.T) {
setEnv(t, "DNS_AR_DB_DSN", "postgres://x")
setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString([]byte("short")))
if _, err := Load(); err == nil {
t.Fatal("expected error for non-32-byte key")
}
}
func TestLoadRejectsMissingDSN(t *testing.T) {
key := make([]byte, 32)
setEnv(t, "DNS_AR_DB_DSN", "")
setEnv(t, "DNS_AR_ENC_KEY", base64.StdEncoding.EncodeToString(key))
if _, err := Load(); err == nil {
t.Fatal("expected error for missing DSN")
}
}
+54
View File
@@ -0,0 +1,54 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
// Cipher performs AES-256-GCM encryption of provider secrets.
type Cipher struct {
gcm cipher.AEAD
}
func NewCipher(key []byte) (*Cipher, error) {
if len(key) != 32 {
return nil, fmt.Errorf("crypto: key must be 32 bytes, got %d", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Cipher{gcm: gcm}, nil
}
// Encrypt returns base64(nonce‖ciphertext).
func (c *Cipher) Encrypt(plaintext []byte) (string, error) {
nonce := make([]byte, c.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := c.gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
// Decrypt reverses Encrypt.
func (c *Cipher) Decrypt(enc string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return nil, fmt.Errorf("crypto: invalid base64: %w", err)
}
ns := c.gcm.NonceSize()
if len(raw) < ns {
return nil, fmt.Errorf("crypto: ciphertext too short")
}
nonce, ct := raw[:ns], raw[ns:]
return c.gcm.Open(nil, nonce, ct, nil)
}
+64
View File
@@ -0,0 +1,64 @@
package crypto
import (
"bytes"
"testing"
)
func key32() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i + 1)
}
return k
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
c, err := NewCipher(key32())
if err != nil {
t.Fatal(err)
}
plain := []byte("selectel-api-secret-token")
enc, err := c.Encrypt(plain)
if err != nil {
t.Fatal(err)
}
if enc == string(plain) {
t.Fatal("ciphertext must differ from plaintext")
}
dec, err := c.Decrypt(enc)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(dec, plain) {
t.Fatalf("round-trip mismatch: %q != %q", dec, plain)
}
}
func TestEncryptNonDeterministic(t *testing.T) {
c, _ := NewCipher(key32())
a, _ := c.Encrypt([]byte("same"))
b, _ := c.Encrypt([]byte("same"))
if a == b {
t.Fatal("nonce must randomize ciphertext")
}
}
func TestDecryptTamperFails(t *testing.T) {
c, _ := NewCipher(key32())
enc, _ := c.Encrypt([]byte("data"))
// испортить последний символ base64
tampered := enc[:len(enc)-1] + "A"
if tampered == enc {
tampered = enc[:len(enc)-1] + "B"
}
if _, err := c.Decrypt(tampered); err == nil {
t.Fatal("GCM must reject tampered ciphertext")
}
}
func TestNewCipherRejectsBadKey(t *testing.T) {
if _, err := NewCipher([]byte("short")); err == nil {
t.Fatal("expected error for non-32-byte key")
}
}
+28
View File
@@ -0,0 +1,28 @@
package registry
import (
"fmt"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
)
// Registry resolves providers by name.
type Registry struct {
m map[string]provider.Provider
}
func New() *Registry {
return &Registry{m: make(map[string]provider.Provider)}
}
func (r *Registry) Register(p provider.Provider) {
r.m[p.Name()] = p
}
func (r *Registry) ByName(name string) (provider.Provider, error) {
p, ok := r.m[name]
if !ok {
return nil, fmt.Errorf("registry: unknown provider %q", name)
}
return p, nil
}
@@ -0,0 +1,35 @@
package registry
import (
"context"
"testing"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
)
type fakeProvider struct{ name string }
func (f fakeProvider) Name() string { return f.name }
func (fakeProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) {
return nil, nil
}
func (fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
return nil, nil
}
func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
return nil
}
func TestRegistryByName(t *testing.T) {
r := New()
r.Register(fakeProvider{name: "selectel"})
p, err := r.ByName("selectel")
if err != nil || p.Name() != "selectel" {
t.Fatalf("expected selectel, got %v err=%v", p, err)
}
if _, err := r.ByName("unknown"); err == nil {
t.Fatal("expected error for unknown provider")
}
}
+102
View File
@@ -0,0 +1,102 @@
package service
import (
"context"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// DomainRef is the minimal data the service needs about a domain.
type DomainRef struct {
ZoneID string
Provider string
SecretEnc string
Template dto.TemplateDoc
}
type Loader interface {
LoadDomain(ctx context.Context, domainID uuid.UUID) (DomainRef, error)
}
type Recorder interface {
SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error
}
type ApplyRequest struct {
ApplyUpdates bool
ApplyPrunes bool
}
type DomainService struct {
loader Loader
rec Recorder
reg *registry.Registry
cipher *crypto.Cipher
}
func New(loader Loader, rec Recorder, reg *registry.Registry, cipher *crypto.Cipher) *DomainService {
return &DomainService{loader: loader, rec: rec, reg: reg, cipher: cipher}
}
// resolve loads the domain, its provider and decrypted credentials, and computes the diff.
func (s *DomainService) resolve(ctx context.Context, domainID uuid.UUID) (provider.Provider, provider.Credentials, DomainRef, diff.Changeset, error) {
ref, err := s.loader.LoadDomain(ctx, domainID)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
creds := provider.Credentials{Secret: string(secret)}
actual, err := p.GetRecords(ctx, creds, ref.ZoneID)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
cs := diff.Diff(ref.Template.ToModel(), actual)
return p, creds, ref, cs, nil
}
// Check computes and records the diff between template and zone.
func (s *DomainService) Check(ctx context.Context, domainID uuid.UUID) (diff.Changeset, error) {
_, _, _, cs, err := s.resolve(ctx, domainID)
if err != nil {
return diff.Changeset{}, err
}
if err := s.rec.SaveCheckRun(ctx, domainID, cs); err != nil {
return diff.Changeset{}, err
}
return cs, nil
}
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
func (s *DomainService) Apply(ctx context.Context, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, domainID)
if err != nil {
return diff.Changeset{}, err
}
var toApply []diff.RecordDiff
if req.ApplyUpdates {
toApply = append(toApply, cs.Updates()...)
}
if req.ApplyPrunes {
toApply = append(toApply, cs.Prunes()...)
}
applied := diff.Changeset{Diffs: toApply}
if len(toApply) > 0 {
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
return diff.Changeset{}, err
}
}
return applied, nil
}
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
func testCipher(t *testing.T) *crypto.Cipher {
t.Helper()
key := make([]byte, 32)
c, err := crypto.NewCipher(key)
if err != nil {
t.Fatal(err)
}
return c
}
// fakeProvider records applied changesets and returns canned zone records.
type fakeProvider struct {
actual []model.Record
applied diff.Changeset
}
func (fakeProvider) Name() string { return "selectel" }
func (fakeProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) {
return nil, nil
}
func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
return f.actual, nil
}
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
f.applied = cs
return nil
}
type fakeLoader struct{ ref DomainRef }
func (l fakeLoader) LoadDomain(context.Context, uuid.UUID) (DomainRef, error) { return l.ref, nil }
type nopRecorder struct{}
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainService, *fakeProvider) {
fp := &fakeProvider{actual: actual}
reg := registry.New()
reg.Register(fp)
cipher := testCipher(t)
enc, _ := cipher.Encrypt([]byte("secret"))
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
return New(loader, nopRecorder{}, reg, cipher), fp
}
func TestCheckProducesDiff(t *testing.T) {
actual := []model.Record{{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}}
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // update
}}
svc, _ := setup(t, actual, tmpl)
cs, err := svc.Check(context.Background(), uuid.New())
if err != nil {
t.Fatal(err)
}
if len(cs.Updates()) != 1 || cs.Updates()[0].Kind != diff.Update {
t.Fatalf("expected 1 update, got %+v", cs.Updates())
}
}
func TestApplyRespectsPruneGuard(t *testing.T) {
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
actual := []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
}
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
}}
// applyPrunes=false → удаление b НЕ применяется
svc, fp := setup(t, actual, tmpl)
if _, err := svc.Apply(context.Background(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: false}); err != nil {
t.Fatal(err)
}
for _, d := range fp.applied.Diffs {
if d.Kind == diff.Delete {
t.Fatalf("prune must be skipped when ApplyPrunes=false, applied: %+v", fp.applied.Diffs)
}
}
// applyPrunes=true → удаление b применяется
svc2, fp2 := setup(t, actual, tmpl)
if _, err := svc2.Apply(context.Background(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: true}); err != nil {
t.Fatal(err)
}
var sawDelete bool
for _, d := range fp2.applied.Diffs {
if d.Kind == diff.Delete && d.Name == "b.example.com." {
sawDelete = true
}
}
if !sawDelete {
t.Fatalf("prune must be applied when ApplyPrunes=true, applied: %+v", fp2.applied.Diffs)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: accounts.sql
package db
import (
"context"
"github.com/google/uuid"
)
const createAccount = `-- name: CreateAccount :one
INSERT INTO provider_accounts (id, project_id, provider, secret_enc, comment)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, provider, secret_enc, comment, created_at
`
type CreateAccountParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Provider string `json:"provider"`
SecretEnc string `json:"secret_enc"`
Comment string `json:"comment"`
}
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (ProviderAccount, error) {
row := q.db.QueryRow(ctx, createAccount,
arg.ID,
arg.ProjectID,
arg.Provider,
arg.SecretEnc,
arg.Comment,
)
var i ProviderAccount
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Provider,
&i.SecretEnc,
&i.Comment,
&i.CreatedAt,
)
return i, err
}
const deleteAccount = `-- name: DeleteAccount :exec
DELETE FROM provider_accounts WHERE id = $1 AND project_id = $2
`
type DeleteAccountParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error {
_, err := q.db.Exec(ctx, deleteAccount, arg.ID, arg.ProjectID)
return err
}
const getAccount = `-- name: GetAccount :one
SELECT id, project_id, provider, secret_enc, comment, created_at FROM provider_accounts WHERE id = $1 AND project_id = $2
`
type GetAccountParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetAccount(ctx context.Context, arg GetAccountParams) (ProviderAccount, error) {
row := q.db.QueryRow(ctx, getAccount, arg.ID, arg.ProjectID)
var i ProviderAccount
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Provider,
&i.SecretEnc,
&i.Comment,
&i.CreatedAt,
)
return i, err
}
const listAccounts = `-- name: ListAccounts :many
SELECT id, project_id, provider, secret_enc, comment, created_at FROM provider_accounts WHERE project_id = $1 ORDER BY created_at
`
func (q *Queries) ListAccounts(ctx context.Context, projectID uuid.UUID) ([]ProviderAccount, error) {
rows, err := q.db.Query(ctx, listAccounts, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ProviderAccount
for rows.Next() {
var i ProviderAccount
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Provider,
&i.SecretEnc,
&i.Comment,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+36
View File
@@ -0,0 +1,36 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: check_runs.sql
package db
import (
"context"
"github.com/google/uuid"
)
const createCheckRun = `-- name: CreateCheckRun :one
INSERT INTO check_runs (id, domain_id, result)
VALUES ($1, $2, $3)
RETURNING id, domain_id, result, created_at
`
type CreateCheckRunParams struct {
ID uuid.UUID `json:"id"`
DomainID uuid.UUID `json:"domain_id"`
Result []byte `json:"result"`
}
func (q *Queries) CreateCheckRun(ctx context.Context, arg CreateCheckRunParams) (CheckRun, error) {
row := q.db.QueryRow(ctx, createCheckRun, arg.ID, arg.DomainID, arg.Result)
var i CheckRun
err := row.Scan(
&i.ID,
&i.DomainID,
&i.Result,
&i.CreatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+211
View File
@@ -0,0 +1,211 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: domains.sql
package db
import (
"context"
"github.com/google/uuid"
dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
const createDomain = `-- name: CreateDomain :one
INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at
`
type CreateDomainParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ProviderAccountID uuid.UUID `json:"provider_account_id"`
ZoneName string `json:"zone_name"`
ZoneID string `json:"zone_id"`
TemplateID *uuid.UUID `json:"template_id"`
}
func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Domain, error) {
row := q.db.QueryRow(ctx, createDomain,
arg.ID,
arg.ProjectID,
arg.ProviderAccountID,
arg.ZoneName,
arg.ZoneID,
arg.TemplateID,
)
var i Domain
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
)
return i, err
}
const deleteDomain = `-- name: DeleteDomain :exec
DELETE FROM domains WHERE id = $1 AND project_id = $2
`
type DeleteDomainParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) error {
_, err := q.db.Exec(ctx, deleteDomain, arg.ID, arg.ProjectID)
return err
}
const getDomain = `-- name: GetDomain :one
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE id = $1 AND project_id = $2
`
type GetDomainParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetDomain(ctx context.Context, arg GetDomainParams) (Domain, error) {
row := q.db.QueryRow(ctx, getDomain, arg.ID, arg.ProjectID)
var i Domain
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
)
return i, err
}
const importDomain = `-- name: ImportDomain :one
INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, zone_id) DO NOTHING
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at
`
type ImportDomainParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ProviderAccountID uuid.UUID `json:"provider_account_id"`
ZoneName string `json:"zone_name"`
ZoneID string `json:"zone_id"`
TemplateID *uuid.UUID `json:"template_id"`
}
func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Domain, error) {
row := q.db.QueryRow(ctx, importDomain,
arg.ID,
arg.ProjectID,
arg.ProviderAccountID,
arg.ZoneName,
arg.ZoneID,
arg.TemplateID,
)
var i Domain
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
)
return i, err
}
const listDomains = `-- name: ListDomains :many
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE project_id = $1 ORDER BY created_at
`
func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) {
rows, err := q.db.Query(ctx, listDomains, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Domain
for rows.Next() {
var i Domain
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const loadDomainFull = `-- name: LoadDomainFull :one
SELECT d.zone_id, a.provider, a.secret_enc, t.doc
FROM domains d
JOIN provider_accounts a ON a.id = d.provider_account_id
LEFT JOIN templates t ON t.id = d.template_id
WHERE d.id = $1
`
type LoadDomainFullRow struct {
ZoneID string `json:"zone_id"`
Provider string `json:"provider"`
SecretEnc string `json:"secret_enc"`
Doc *dto.TemplateDoc `json:"doc"`
}
func (q *Queries) LoadDomainFull(ctx context.Context, id uuid.UUID) (LoadDomainFullRow, error) {
row := q.db.QueryRow(ctx, loadDomainFull, id)
var i LoadDomainFullRow
err := row.Scan(
&i.ZoneID,
&i.Provider,
&i.SecretEnc,
&i.Doc,
)
return i, err
}
const updateDomainTemplate = `-- name: UpdateDomainTemplate :one
UPDATE domains SET template_id = $3 WHERE id = $1 AND project_id = $2
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at
`
type UpdateDomainTemplateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
TemplateID *uuid.UUID `json:"template_id"`
}
func (q *Queries) UpdateDomainTemplate(ctx context.Context, arg UpdateDomainTemplateParams) (Domain, error) {
row := q.db.QueryRow(ctx, updateDomainTemplate, arg.ID, arg.ProjectID, arg.TemplateID)
var i Domain
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
)
return i, err
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
type CheckRun struct {
ID uuid.UUID `json:"id"`
DomainID uuid.UUID `json:"domain_id"`
Result []byte `json:"result"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Domain struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ProviderAccountID uuid.UUID `json:"provider_account_id"`
ZoneName string `json:"zone_name"`
ZoneID string `json:"zone_id"`
TemplateID *uuid.UUID `json:"template_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Project struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ProviderAccount struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Provider string `json:"provider"`
SecretEnc string `json:"secret_enc"`
Comment string `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Template struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Doc *dto.TemplateDoc `json:"doc"`
Version int32 `json:"version"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
+150
View File
@@ -0,0 +1,150 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: templates.sql
package db
import (
"context"
"github.com/google/uuid"
dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
const createTemplate = `-- name: CreateTemplate :one
INSERT INTO templates (id, project_id, name, doc, version)
VALUES ($1, $2, $3, $4, 1)
RETURNING id, project_id, name, doc, version, created_at, updated_at
`
type CreateTemplateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Doc *dto.TemplateDoc `json:"doc"`
}
func (q *Queries) CreateTemplate(ctx context.Context, arg CreateTemplateParams) (Template, error) {
row := q.db.QueryRow(ctx, createTemplate,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Doc,
)
var i Template
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Doc,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteTemplate = `-- name: DeleteTemplate :exec
DELETE FROM templates WHERE id = $1 AND project_id = $2
`
type DeleteTemplateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) error {
_, err := q.db.Exec(ctx, deleteTemplate, arg.ID, arg.ProjectID)
return err
}
const getTemplate = `-- name: GetTemplate :one
SELECT id, project_id, name, doc, version, created_at, updated_at FROM templates WHERE id = $1 AND project_id = $2
`
type GetTemplateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetTemplate(ctx context.Context, arg GetTemplateParams) (Template, error) {
row := q.db.QueryRow(ctx, getTemplate, arg.ID, arg.ProjectID)
var i Template
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Doc,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listTemplates = `-- name: ListTemplates :many
SELECT id, project_id, name, doc, version, created_at, updated_at FROM templates WHERE project_id = $1 ORDER BY created_at
`
func (q *Queries) ListTemplates(ctx context.Context, projectID uuid.UUID) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplates, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Template
for rows.Next() {
var i Template
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Doc,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTemplate = `-- name: UpdateTemplate :one
UPDATE templates
SET name = $3, doc = $4, version = version + 1, updated_at = now()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, doc, version, created_at, updated_at
`
type UpdateTemplateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Doc *dto.TemplateDoc `json:"doc"`
}
func (q *Queries) UpdateTemplate(ctx context.Context, arg UpdateTemplateParams) (Template, error) {
row := q.db.QueryRow(ctx, updateTemplate,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Doc,
)
var i Template
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Doc,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+36
View File
@@ -0,0 +1,36 @@
package dto
import "github.com/vasyakrg/dns-autoresolver/internal/model"
// RecordDTO is the JSONB representation of one DNS record in a template.
type RecordDTO struct {
Type string `json:"type"`
Name string `json:"name"`
TTL int `json:"ttl"`
Values []string `json:"values"`
}
// TemplateDoc is stored in templates.doc (jsonb).
type TemplateDoc struct {
Records []RecordDTO `json:"records"`
}
func FromModel(recs []model.Record) TemplateDoc {
out := TemplateDoc{Records: make([]RecordDTO, 0, len(recs))}
for _, r := range recs {
out.Records = append(out.Records, RecordDTO{
Type: string(r.Type), Name: r.Name, TTL: r.TTL, Values: r.Values,
})
}
return out
}
func (d TemplateDoc) ToModel() []model.Record {
out := make([]model.Record, 0, len(d.Records))
for _, r := range d.Records {
out = append(out, model.Record{
Type: model.RecordType(r.Type), Name: r.Name, TTL: r.TTL, Values: r.Values,
})
}
return out
}
+25
View File
@@ -0,0 +1,25 @@
package dto
import (
"testing"
"github.com/vasyakrg/dns-autoresolver/internal/model"
)
func TestTemplateDocRoundTrip(t *testing.T) {
recs := []model.Record{
{Type: model.A, Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
{Type: model.MX, Name: "example.com.", TTL: 3600, Values: []string{"10 mx1.example.com."}},
}
doc := FromModel(recs)
if len(doc.Records) != 2 {
t.Fatalf("want 2 records, got %d", len(doc.Records))
}
back := doc.ToModel()
if len(back) != 2 || back[0].Type != model.A || back[1].Type != model.MX {
t.Fatalf("round-trip mismatch: %+v", back)
}
if back[0].Values[0] != "1.2.3.4" || back[1].TTL != 3600 {
t.Fatalf("field mismatch: %+v", back)
}
}
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
)
// LoadDomain joins domains+provider_accounts+templates to build the
// service.DomainRef needed to check/apply a domain's DNS records.
func (s *Store) LoadDomain(ctx context.Context, domainID uuid.UUID) (service.DomainRef, error) {
row, err := s.q.LoadDomainFull(ctx, domainID)
if err != nil {
return service.DomainRef{}, err
}
if row.Doc == nil {
return service.DomainRef{}, fmt.Errorf("store: domain %s has no template", domainID)
}
return service.DomainRef{
ZoneID: row.ZoneID,
Provider: row.Provider,
SecretEnc: row.SecretEnc,
Template: *row.Doc,
}, nil
}
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
// as a check_runs row.
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
summary := map[string]int{
"updates": len(cs.Updates()),
"prunes": len(cs.Prunes()),
}
raw, err := json.Marshal(summary)
if err != nil {
return err
}
_, err = s.q.CreateCheckRun(ctx, db.CreateCheckRunParams{
ID: uuid.New(),
DomainID: domainID,
Result: raw,
})
return err
}
// compile-time interface checks
var _ service.Loader = (*Store)(nil)
var _ service.Recorder = (*Store)(nil)
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"testing"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
func TestLoadDomainAndSaveCheckRun(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject,
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
}}
tpl, err := s.Queries().CreateTemplate(ctx, db.CreateTemplateParams{
ID: uuid.New(), ProjectID: defaultProject, Name: "base", Doc: &doc,
})
if err != nil {
t.Fatal(err)
}
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
ZoneName: "example.com", ZoneID: "zone-1", TemplateID: &tpl.ID,
})
if err != nil {
t.Fatal(err)
}
ref, err := s.LoadDomain(ctx, domain.ID)
if err != nil {
t.Fatal(err)
}
if ref.ZoneID != "zone-1" || ref.Provider != "selectel" || ref.SecretEnc != "enc-blob" {
t.Fatalf("domain ref mismatch: %+v", ref)
}
if len(ref.Template.Records) != 1 {
t.Fatalf("expected template records, got %+v", ref.Template)
}
cs := diff.Changeset{Diffs: []diff.RecordDiff{
{Kind: diff.Add, Type: model.A, Name: "www.example.com."},
{Kind: diff.Delete, Type: model.A, Name: "old.example.com."},
}}
if err := s.SaveCheckRun(ctx, domain.ID, cs); err != nil {
t.Fatal(err)
}
var count int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM check_runs WHERE domain_id = $1", domain.ID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 check_runs row, got %d", count)
}
}
func TestLoadDomainNoTemplate(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject,
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
ZoneName: "example.com", ZoneID: "zone-2", TemplateID: nil,
})
if err != nil {
t.Fatal(err)
}
if _, err := s.LoadDomain(ctx, domain.ID); err == nil {
t.Fatal("expected error for domain without template, got nil")
}
}
+28
View File
@@ -0,0 +1,28 @@
package store
import (
"context"
"database/sql"
"embed"
_ "github.com/jackc/pgx/v5/stdlib" // pgx database/sql driver
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate applies all pending goose migrations to the database at dsn.
func Migrate(ctx context.Context, dsn string) error {
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
defer db.Close()
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.UpContext(ctx, db, "migrations")
}
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestMigrateCreatesTablesAndSeed(t *testing.T) {
dsn := startPostgres(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
// таблицы существуют
for _, table := range []string{"users", "projects", "provider_accounts", "templates", "domains", "check_runs"} {
var exists bool
err := pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`, table).Scan(&exists)
if err != nil || !exists {
t.Fatalf("table %s missing (err=%v)", table, err)
}
}
// seed default project присутствует
var name string
err = pool.QueryRow(ctx, `SELECT name FROM projects WHERE id='00000000-0000-0000-0000-000000000002'`).Scan(&name)
if err != nil || name != "default" {
t.Fatalf("seed project missing: name=%q err=%v", name, err)
}
}
+63
View File
@@ -0,0 +1,63 @@
-- +goose Up
CREATE TABLE users (
id uuid PRIMARY KEY,
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE projects (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE provider_accounts (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
provider text NOT NULL,
secret_enc text NOT NULL,
comment text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE templates (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name text NOT NULL,
doc jsonb NOT NULL,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE domains (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
provider_account_id uuid NOT NULL REFERENCES provider_accounts(id) ON DELETE CASCADE,
zone_name text NOT NULL,
zone_id text NOT NULL,
template_id uuid REFERENCES templates(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE check_runs (
id uuid PRIMARY KEY,
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- seed default tenant (Фаза 1B без логина)
INSERT INTO users (id, email)
VALUES ('00000000-0000-0000-0000-000000000001', 'default@local');
INSERT INTO projects (id, user_id, name)
VALUES ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'default');
-- +goose Down
DROP TABLE check_runs;
DROP TABLE domains;
DROP TABLE templates;
DROP TABLE provider_accounts;
DROP TABLE projects;
DROP TABLE users;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE domains ADD CONSTRAINT domains_project_zone_uniq UNIQUE (project_id, zone_id);
-- +goose Down
ALTER TABLE domains DROP CONSTRAINT domains_project_zone_uniq;
+13
View File
@@ -0,0 +1,13 @@
-- name: CreateAccount :one
INSERT INTO provider_accounts (id, project_id, provider, secret_enc, comment)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetAccount :one
SELECT * FROM provider_accounts WHERE id = $1 AND project_id = $2;
-- name: ListAccounts :many
SELECT * FROM provider_accounts WHERE project_id = $1 ORDER BY created_at;
-- name: DeleteAccount :exec
DELETE FROM provider_accounts WHERE id = $1 AND project_id = $2;
+4
View File
@@ -0,0 +1,4 @@
-- name: CreateCheckRun :one
INSERT INTO check_runs (id, domain_id, result)
VALUES ($1, $2, $3)
RETURNING *;
+30
View File
@@ -0,0 +1,30 @@
-- name: CreateDomain :one
INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ImportDomain :one
INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, zone_id) DO NOTHING
RETURNING *;
-- name: UpdateDomainTemplate :one
UPDATE domains SET template_id = $3 WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: GetDomain :one
SELECT * FROM domains WHERE id = $1 AND project_id = $2;
-- name: ListDomains :many
SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at;
-- name: DeleteDomain :exec
DELETE FROM domains WHERE id = $1 AND project_id = $2;
-- name: LoadDomainFull :one
SELECT d.zone_id, a.provider, a.secret_enc, t.doc
FROM domains d
JOIN provider_accounts a ON a.id = d.provider_account_id
LEFT JOIN templates t ON t.id = d.template_id
WHERE d.id = $1;
+19
View File
@@ -0,0 +1,19 @@
-- name: CreateTemplate :one
INSERT INTO templates (id, project_id, name, doc, version)
VALUES ($1, $2, $3, $4, 1)
RETURNING *;
-- name: GetTemplate :one
SELECT * FROM templates WHERE id = $1 AND project_id = $2;
-- name: ListTemplates :many
SELECT * FROM templates WHERE project_id = $1 ORDER BY created_at;
-- name: UpdateTemplate :one
UPDATE templates
SET name = $3, doc = $4, version = version + 1, updated_at = now()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteTemplate :exec
DELETE FROM templates WHERE id = $1 AND project_id = $2;
+20
View File
@@ -0,0 +1,20 @@
package store
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
)
// Store wraps sqlc-generated queries over a pgx pool.
type Store struct {
q *db.Queries
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Store {
return &Store{q: db.New(pool), pool: pool}
}
// Queries exposes the generated queries for callers that need them directly.
func (s *Store) Queries() *db.Queries { return s.q }
+279
View File
@@ -0,0 +1,279 @@
package store
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// defaultProject is the seed default tenant project (see migrations/0001_init.sql).
var defaultProject = uuid.MustParse("00000000-0000-0000-0000-000000000002")
func newStore(t *testing.T) (*Store, context.Context) {
dsn := startPostgres(t)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return New(pool), context.Background()
}
func TestAccountCRUD(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject,
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
got, err := s.Queries().GetAccount(ctx, db.GetAccountParams{ID: acc.ID, ProjectID: defaultProject})
if err != nil || got.Provider != "selectel" || got.SecretEnc != "enc-blob" {
t.Fatalf("get mismatch: %+v err=%v", got, err)
}
list, err := s.Queries().ListAccounts(ctx, defaultProject)
if err != nil || len(list) != 1 {
t.Fatalf("list mismatch: %+v err=%v", list, err)
}
if err := s.Queries().DeleteAccount(ctx, db.DeleteAccountParams{ID: acc.ID, ProjectID: defaultProject}); err != nil {
t.Fatal(err)
}
if _, err := s.Queries().GetAccount(ctx, db.GetAccountParams{ID: acc.ID, ProjectID: defaultProject}); err == nil {
t.Fatal("expected error after delete, got nil")
}
}
func TestTemplateJSONBRoundTrip(t *testing.T) {
s, ctx := newStore(t)
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
{Type: "SRV", Name: "_autodiscover._tcp.example.com.", TTL: 3600, Values: []string{"0 0 443 mail.example.com."}},
}}
tpl, err := s.Queries().CreateTemplate(ctx, db.CreateTemplateParams{
ID: uuid.New(), ProjectID: defaultProject, Name: "base", Doc: &doc,
})
if err != nil {
t.Fatal(err)
}
got, err := s.Queries().GetTemplate(ctx, db.GetTemplateParams{ID: tpl.ID, ProjectID: defaultProject})
if err != nil {
t.Fatal(err)
}
if got.Doc == nil || len(got.Doc.Records) != 2 || got.Doc.Records[1].Type != "SRV" {
t.Fatalf("jsonb round-trip failed: %+v", got.Doc)
}
doc2 := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.example.com.", TTL: 60, Values: []string{"5.6.7.8"}},
}}
updated, err := s.Queries().UpdateTemplate(ctx, db.UpdateTemplateParams{
ID: tpl.ID, ProjectID: defaultProject, Name: "base-v2", Doc: &doc2,
})
if err != nil {
t.Fatal(err)
}
if updated.Version != tpl.Version+1 || updated.Doc == nil || len(updated.Doc.Records) != 1 {
t.Fatalf("update mismatch: %+v", updated)
}
if err := s.Queries().DeleteTemplate(ctx, db.DeleteTemplateParams{ID: tpl.ID, ProjectID: defaultProject}); err != nil {
t.Fatal(err)
}
}
func TestImportDomains_CommitsAllOnSuccess(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatal(err)
}
if len(doms) != 2 {
t.Fatalf("expected 2 domains returned, got %d", len(doms))
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected 2 persisted domains, got %d", len(list))
}
}
// TestImportDomains_RollsBackAllOnError verifies the transactional contract:
// if any zone in the batch fails to insert (here, an FK violation because
// the account doesn't exist), none of the batch is left committed.
func TestImportDomains_RollsBackAllOnError(t *testing.T) {
s, ctx := newStore(t)
bogusAccountID := uuid.New() // no matching provider_accounts row
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
if _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
t.Fatal("expected FK violation error, got nil")
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Fatalf("expected 0 domains after rollback, got %d", len(list))
}
}
// TestImportDomains_IdempotentOnRepeat verifies the fix for the import
// idempotency gap: re-importing the same zones must not create duplicate
// domains (enforced by the domains_project_zone_uniq constraint + ON
// CONFLICT DO NOTHING in the ImportDomain query) and must not error.
func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
first, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatal(err)
}
if len(first) != 2 {
t.Fatalf("expected 2 domains on first import, got %d", len(first))
}
second, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
}
if len(second) != 0 {
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected still exactly 2 domains (no duplicates), got %d", len(list))
}
var count int
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM domains WHERE project_id = $1 AND zone_id = $2`, defaultProject, "z1")
if err := row.Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected COUNT=1 for zone z1 (UNIQUE constraint), got %d", count)
}
}
// TestSetDomainTemplate_ClosesImportCheckLoop verifies the fix for the
// second review gap: an imported domain (template_id=NULL) can be bound to
// a template via SetDomainTemplate, after which LoadDomain succeeds and
// returns that template — closing the import -> bind -> check cycle.
func TestSetDomainTemplate_ClosesImportCheckLoop(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
dom := doms[0]
// Before binding, the domain is not checkable.
if _, err := s.LoadDomain(ctx, dom.ID); err == nil {
t.Fatal("expected LoadDomain to fail before a template is bound")
}
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.a.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
}}
tpl, err := s.CreateTemplate(ctx, defaultProject, "base", doc)
if err != nil {
t.Fatal(err)
}
updated, err := s.SetDomainTemplate(ctx, dom.ID, defaultProject, &tpl.ID)
if err != nil {
t.Fatal(err)
}
if updated.TemplateID == nil || *updated.TemplateID != tpl.ID {
t.Fatalf("expected domain.TemplateID=%s, got %+v", tpl.ID, updated.TemplateID)
}
ref, err := s.LoadDomain(ctx, dom.ID)
if err != nil {
t.Fatalf("expected LoadDomain to succeed after binding template, got error: %v", err)
}
if len(ref.Template.Records) != 1 || ref.Template.Records[0].Type != "A" {
t.Fatalf("unexpected template loaded: %+v", ref.Template)
}
}
// TestSetDomainTemplate_RejectsForeignProjectTemplate verifies that binding
// a template belonging to a different project is rejected rather than
// silently succeeding (which would let one tenant's domain use another
// tenant's DNS template).
func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
dom := doms[0]
// A template that belongs to a different (foreign) project. The default
// user is the seed tenant from migrations/0001_init.sql.
defaultUser := uuid.MustParse("00000000-0000-0000-0000-000000000001")
foreignProject := uuid.New()
if _, err := s.pool.Exec(ctx, `INSERT INTO projects (id, user_id, name) VALUES ($1, $2, 'foreign')`, foreignProject, defaultUser); err != nil {
t.Fatal(err)
}
foreignTpl, err := s.CreateTemplate(ctx, foreignProject, "foreign", dto.TemplateDoc{})
if err != nil {
t.Fatal(err)
}
if _, err := s.SetDomainTemplate(ctx, dom.ID, defaultProject, &foreignTpl.ID); err == nil {
t.Fatal("expected error binding a template from a different project, got nil")
}
}
+224
View File
@@ -0,0 +1,224 @@
package store
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// Account/Template/Domain are provider-neutral domain structs returned by the
// thin wrappers below, so callers (internal/api) never need to import
// internal/store/db directly.
type Account struct {
ID uuid.UUID
ProjectID uuid.UUID
Provider string
SecretEnc string
Comment string
}
func accountFromDB(a db.ProviderAccount) Account {
return Account{ID: a.ID, ProjectID: a.ProjectID, Provider: a.Provider, SecretEnc: a.SecretEnc, Comment: a.Comment}
}
func (s *Store) CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (Account, error) {
a, err := s.q.CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: projectID, Provider: provider, SecretEnc: secretEnc, Comment: comment,
})
if err != nil {
return Account{}, err
}
return accountFromDB(a), nil
}
func (s *Store) ListAccounts(ctx context.Context, projectID uuid.UUID) ([]Account, error) {
rows, err := s.q.ListAccounts(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]Account, 0, len(rows))
for _, r := range rows {
out = append(out, accountFromDB(r))
}
return out, nil
}
func (s *Store) GetAccount(ctx context.Context, id, projectID uuid.UUID) (Account, error) {
a, err := s.q.GetAccount(ctx, db.GetAccountParams{ID: id, ProjectID: projectID})
if err != nil {
return Account{}, err
}
return accountFromDB(a), nil
}
func (s *Store) DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error {
return s.q.DeleteAccount(ctx, db.DeleteAccountParams{ID: id, ProjectID: projectID})
}
type Template struct {
ID uuid.UUID
ProjectID uuid.UUID
Name string
Doc dto.TemplateDoc
Version int32
}
func templateFromDB(t db.Template) Template {
var doc dto.TemplateDoc
if t.Doc != nil {
doc = *t.Doc
}
return Template{ID: t.ID, ProjectID: t.ProjectID, Name: t.Name, Doc: doc, Version: t.Version}
}
func (s *Store) CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) {
d := doc
t, err := s.q.CreateTemplate(ctx, db.CreateTemplateParams{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: &d})
if err != nil {
return Template{}, err
}
return templateFromDB(t), nil
}
func (s *Store) ListTemplates(ctx context.Context, projectID uuid.UUID) ([]Template, error) {
rows, err := s.q.ListTemplates(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]Template, 0, len(rows))
for _, r := range rows {
out = append(out, templateFromDB(r))
}
return out, nil
}
func (s *Store) UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) {
d := doc
t, err := s.q.UpdateTemplate(ctx, db.UpdateTemplateParams{ID: id, ProjectID: projectID, Name: name, Doc: &d})
if err != nil {
return Template{}, err
}
return templateFromDB(t), nil
}
func (s *Store) DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error {
return s.q.DeleteTemplate(ctx, db.DeleteTemplateParams{ID: id, ProjectID: projectID})
}
// GetTemplate is a scoped lookup used to verify a template belongs to
// projectID before it is referenced elsewhere (e.g. CreateDomain).
func (s *Store) GetTemplate(ctx context.Context, id, projectID uuid.UUID) (Template, error) {
t, err := s.q.GetTemplate(ctx, db.GetTemplateParams{ID: id, ProjectID: projectID})
if err != nil {
return Template{}, err
}
return templateFromDB(t), nil
}
type Domain struct {
ID uuid.UUID
ProjectID uuid.UUID
ProviderAccountID uuid.UUID
ZoneName string
ZoneID string
TemplateID *uuid.UUID
}
func domainFromDB(d db.Domain) Domain {
return Domain{
ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID,
ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID,
}
}
func (s *Store) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (Domain, error) {
d, err := s.q.CreateDomain(ctx, db.CreateDomainParams{
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID,
})
if err != nil {
return Domain{}, err
}
return domainFromDB(d), nil
}
func (s *Store) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) {
rows, err := s.q.ListDomains(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]Domain, 0, len(rows))
for _, r := range rows {
out = append(out, domainFromDB(r))
}
return out, nil
}
func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error {
return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID})
}
// ImportDomains creates one domain per zone inside a single transaction: if
// any zone fails to be created, the whole batch is rolled back so callers
// never observe a partially-imported set of domains.
//
// Import is idempotent: zones that already have a domain for this project
// (enforced by the domains_project_zone_uniq constraint) are silently
// skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating —
// so a repeated POST .../import never creates duplicate domains. Only the
// zones that were actually newly created are returned.
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx) // no-op once Commit has succeeded
q := s.q.WithTx(tx)
out := make([]Domain, 0, len(zones))
for _, z := range zones {
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// ON CONFLICT DO NOTHING: this zone was already imported
// for this project — skip it rather than fail the batch.
continue
}
return nil, err
}
out = append(out, domainFromDB(d))
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return out, nil
}
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
// template used to check/apply a domain. When templateID is non-nil it must
// belong to the same project — verified via scoped GetTemplate — otherwise
// a caller could bind a domain to another tenant's template.
func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (Domain, error) {
if templateID != nil {
if _, err := s.GetTemplate(ctx, *templateID, projectID); err != nil {
return Domain{}, err
}
}
d, err := s.q.UpdateDomainTemplate(ctx, db.UpdateDomainTemplateParams{
ID: domainID, ProjectID: projectID, TemplateID: templateID,
})
if err != nil {
return Domain{}, err
}
return domainFromDB(d), nil
}
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
// startPostgres spins up an ephemeral PostgreSQL, applies migrations,
// and returns its DSN. Container is terminated on test cleanup.
func startPostgres(t *testing.T) string {
t.Helper()
ctx := context.Background()
// BasicWaitStrategies включает ForLog(...)x2 И ForListeningPort — второй
// критичен на macOS/Windows Docker Desktop (иначе flaky first-run).
container, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("dns_ar_test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
if err != nil {
t.Fatalf("start postgres: %v", err)
}
t.Cleanup(func() { _ = testcontainers.TerminateContainer(container) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("dsn: %v", err)
}
if err := Migrate(ctx, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
return dsn
}
+29
View File
@@ -0,0 +1,29 @@
version: "2"
sql:
- engine: postgresql
schema: internal/store/migrations
queries: internal/store/queries
gen:
go:
package: db
out: internal/store/db
sql_package: pgx/v5
emit_json_tags: true
emit_pointers_for_null_types: true
overrides:
- column: templates.doc
go_type:
import: github.com/vasyakrg/dns-autoresolver/internal/store/dto
package: dto
type: TemplateDoc
pointer: true
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: "UUID"
pointer: true