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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,72 @@
|
||||
module github.com/vasyakrg/dns-autoresolver
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- name: CreateCheckRun :one
|
||||
INSERT INTO check_runs (id, domain_id, result)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user