From fc10451340a9861706b22cd32319e00e692d397e Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 13:35:47 +0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(config):=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20env-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0=20(DSN,=20ENC-=D0=BA=D0=BB=D1=8E=D1=87,=20listen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 66 +++++++++++++++ go.sum | 142 +++++++++++++++++++++++++++++++++ internal/config/config.go | 36 +++++++++ internal/config/config_test.go | 46 +++++++++++ 4 files changed, 290 insertions(+) create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index d340757..0f232a1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,69 @@ module github.com/vasyakrg/dns-autoresolver go 1.26.4 + +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-chi/chi/v5 v5.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // 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/pressly/goose/v3 v3.27.2 // 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/testcontainers/testcontainers-go v0.43.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 // 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..946043b --- /dev/null +++ b/go.sum @@ -0,0 +1,142 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +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/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/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/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/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/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/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/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/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/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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/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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e575617 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f568210 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} From 7c82bafaaaec5a603ea6ec67649222d1c8b002da Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 13:41:52 +0700 Subject: [PATCH 02/14] =?UTF-8?q?feat(crypto):=20AES-256-GCM=20=D1=88?= =?UTF-8?q?=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BE=D0=B2=20=D1=83=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/crypto/crypto.go | 54 ++++++++++++++++++++++++++++ internal/crypto/crypto_test.go | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/crypto_test.go diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..5418a3e --- /dev/null +++ b/internal/crypto/crypto.go @@ -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) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..c93b017 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -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") + } +} From 3b7ed8434b1bdbf128a8e06775431041996454ed Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 13:41:56 +0700 Subject: [PATCH 03/14] =?UTF-8?q?feat(registry):=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=B2=D0=B8=D0=BD=D0=B3=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0=20=D0=BF=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/registry/registry.go | 28 +++++++++++++++++ internal/provider/registry/registry_test.go | 35 +++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 internal/provider/registry/registry.go create mode 100644 internal/provider/registry/registry_test.go diff --git a/internal/provider/registry/registry.go b/internal/provider/registry/registry.go new file mode 100644 index 0000000..05fa8a2 --- /dev/null +++ b/internal/provider/registry/registry.go @@ -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 +} diff --git a/internal/provider/registry/registry_test.go b/internal/provider/registry/registry_test.go new file mode 100644 index 0000000..b720b3c --- /dev/null +++ b/internal/provider/registry/registry_test.go @@ -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") + } +} From 788f1db80e17f212c6be1e69f27f80ecf94f4c14 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 13:56:21 +0700 Subject: [PATCH 04/14] =?UTF-8?q?feat(store):=20goose-=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=20+=20seed=20default=20tenant,=20=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B0=20testcontainers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 12 +++-- go.sum | 52 +++++++++++++++++--- internal/store/migrate.go | 28 +++++++++++ internal/store/migrate_test.go | 34 +++++++++++++ internal/store/migrations/0001_init.sql | 63 +++++++++++++++++++++++++ internal/store/testhelper_test.go | 39 +++++++++++++++ 6 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 internal/store/migrate.go create mode 100644 internal/store/migrate_test.go create mode 100644 internal/store/migrations/0001_init.sql create mode 100644 internal/store/testhelper_test.go diff --git a/go.mod b/go.mod index 0f232a1..ab1c0e0 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,13 @@ module github.com/vasyakrg/dns-autoresolver go 1.26.4 +require ( + 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 @@ -19,14 +26,12 @@ require ( 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-chi/chi/v5 v5.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // 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 @@ -45,13 +50,10 @@ require ( 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/pressly/goose/v3 v3.27.2 // 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/testcontainers/testcontainers-go v0.43.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 // 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 diff --git a/go.sum b/go.sum index 946043b..c4c01fb 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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= @@ -18,6 +20,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS 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= @@ -27,12 +31,12 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf 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= @@ -41,6 +45,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre 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= @@ -53,10 +59,20 @@ 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= @@ -77,6 +93,8 @@ 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= @@ -87,6 +105,10 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt 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= @@ -94,6 +116,8 @@ github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUN 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= @@ -116,14 +140,16 @@ 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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -131,12 +157,26 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w 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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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= diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..6546f31 --- /dev/null +++ b/internal/store/migrate.go @@ -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") +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 0000000..512da75 --- /dev/null +++ b/internal/store/migrate_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..3f425fd --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -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; diff --git a/internal/store/testhelper_test.go b/internal/store/testhelper_test.go new file mode 100644 index 0000000..8e2adf8 --- /dev/null +++ b/internal/store/testhelper_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// 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() + container, err := postgres.Run(ctx, "postgres:16-alpine", + postgres.WithDatabase("dns_ar_test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2).WithStartupTimeout(60*time.Second)), + ) + 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 +} From 9c29d402698dc441557939b3aa4df9bcf119cdef Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:04:23 +0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(store):=20postgres.BasicWaitStrategie?= =?UTF-8?q?s()=20=E2=80=94=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20flaky=20first-run=20=D0=BD=D0=B0=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/store/testhelper_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/store/testhelper_test.go b/internal/store/testhelper_test.go index 8e2adf8..dda9cf9 100644 --- a/internal/store/testhelper_test.go +++ b/internal/store/testhelper_test.go @@ -3,11 +3,9 @@ package store import ( "context" "testing" - "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" - "github.com/testcontainers/testcontainers-go/wait" ) // startPostgres spins up an ephemeral PostgreSQL, applies migrations, @@ -15,13 +13,13 @@ import ( 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"), - testcontainers.WithWaitStrategy( - wait.ForLog("database system is ready to accept connections"). - WithOccurrence(2).WithStartupTimeout(60*time.Second)), + postgres.BasicWaitStrategies(), ) if err != nil { t.Fatalf("start postgres: %v", err) From 34bc49ee8cb04b012c1f00f8e306620ddcd09548 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:08:37 +0700 Subject: [PATCH 06/14] =?UTF-8?q?feat(store):=20sqlc-=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=8B,=20dto=20TemplateDoc,=20Repository,=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20CRU?= =?UTF-8?q?D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/db/accounts.sql.go | 114 ++++++++++++++++++ internal/store/db/check_runs.sql.go | 36 ++++++ internal/store/db/db.go | 32 +++++ internal/store/db/domains.sql.go | 119 +++++++++++++++++++ internal/store/db/models.go | 59 ++++++++++ internal/store/db/templates.sql.go | 150 ++++++++++++++++++++++++ internal/store/dto/template_doc.go | 36 ++++++ internal/store/dto/template_doc_test.go | 25 ++++ internal/store/queries/accounts.sql | 13 ++ internal/store/queries/check_runs.sql | 4 + internal/store/queries/domains.sql | 13 ++ internal/store/queries/templates.sql | 19 +++ internal/store/store.go | 20 ++++ internal/store/store_test.go | 98 ++++++++++++++++ sqlc.yaml | 19 +++ 15 files changed, 757 insertions(+) create mode 100644 internal/store/db/accounts.sql.go create mode 100644 internal/store/db/check_runs.sql.go create mode 100644 internal/store/db/db.go create mode 100644 internal/store/db/domains.sql.go create mode 100644 internal/store/db/models.go create mode 100644 internal/store/db/templates.sql.go create mode 100644 internal/store/dto/template_doc.go create mode 100644 internal/store/dto/template_doc_test.go create mode 100644 internal/store/queries/accounts.sql create mode 100644 internal/store/queries/check_runs.sql create mode 100644 internal/store/queries/domains.sql create mode 100644 internal/store/queries/templates.sql create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 sqlc.yaml diff --git a/internal/store/db/accounts.sql.go b/internal/store/db/accounts.sql.go new file mode 100644 index 0000000..c8e7aa3 --- /dev/null +++ b/internal/store/db/accounts.sql.go @@ -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/jackc/pgx/v5/pgtype" +) + +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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.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 +} diff --git a/internal/store/db/check_runs.sql.go b/internal/store/db/check_runs.sql.go new file mode 100644 index 0000000..14a6ab5 --- /dev/null +++ b/internal/store/db/check_runs.sql.go @@ -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/jackc/pgx/v5/pgtype" +) + +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 pgtype.UUID `json:"id"` + DomainID pgtype.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 +} diff --git a/internal/store/db/db.go b/internal/store/db/db.go new file mode 100644 index 0000000..468d1fa --- /dev/null +++ b/internal/store/db/db.go @@ -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, + } +} diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go new file mode 100644 index 0000000..92eb755 --- /dev/null +++ b/internal/store/db/domains.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: domains.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +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 pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + ProviderAccountID pgtype.UUID `json:"provider_account_id"` + ZoneName string `json:"zone_name"` + ZoneID string `json:"zone_id"` + TemplateID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 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 pgtype.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 +} diff --git a/internal/store/db/models.go b/internal/store/db/models.go new file mode 100644 index 0000000..8fff901 --- /dev/null +++ b/internal/store/db/models.go @@ -0,0 +1,59 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "github.com/jackc/pgx/v5/pgtype" + dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto" +) + +type CheckRun struct { + ID pgtype.UUID `json:"id"` + DomainID pgtype.UUID `json:"domain_id"` + Result []byte `json:"result"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Domain struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + ProviderAccountID pgtype.UUID `json:"provider_account_id"` + ZoneName string `json:"zone_name"` + ZoneID string `json:"zone_id"` + TemplateID pgtype.UUID `json:"template_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ProviderAccount struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + Email string `json:"email"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/store/db/templates.sql.go b/internal/store/db/templates.sql.go new file mode 100644 index 0000000..cd8f95d --- /dev/null +++ b/internal/store/db/templates.sql.go @@ -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/jackc/pgx/v5/pgtype" + 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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 pgtype.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 pgtype.UUID `json:"id"` + ProjectID pgtype.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 +} diff --git a/internal/store/dto/template_doc.go b/internal/store/dto/template_doc.go new file mode 100644 index 0000000..e0a5356 --- /dev/null +++ b/internal/store/dto/template_doc.go @@ -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 +} diff --git a/internal/store/dto/template_doc_test.go b/internal/store/dto/template_doc_test.go new file mode 100644 index 0000000..a9b6bec --- /dev/null +++ b/internal/store/dto/template_doc_test.go @@ -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) + } +} diff --git a/internal/store/queries/accounts.sql b/internal/store/queries/accounts.sql new file mode 100644 index 0000000..d407626 --- /dev/null +++ b/internal/store/queries/accounts.sql @@ -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; diff --git a/internal/store/queries/check_runs.sql b/internal/store/queries/check_runs.sql new file mode 100644 index 0000000..3c5ea29 --- /dev/null +++ b/internal/store/queries/check_runs.sql @@ -0,0 +1,4 @@ +-- name: CreateCheckRun :one +INSERT INTO check_runs (id, domain_id, result) +VALUES ($1, $2, $3) +RETURNING *; diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql new file mode 100644 index 0000000..4e134ea --- /dev/null +++ b/internal/store/queries/domains.sql @@ -0,0 +1,13 @@ +-- 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: 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; diff --git a/internal/store/queries/templates.sql b/internal/store/queries/templates.sql new file mode 100644 index 0000000..f08d170 --- /dev/null +++ b/internal/store/queries/templates.sql @@ -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; diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..9758f0c --- /dev/null +++ b/internal/store/store.go @@ -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 } diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1e51df9 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "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). +// +// NOTE: sqlc generated UUID columns as pgtype.UUID (not google/uuid.UUID) — +// pgUUID bridges the two so tests can still use uuid.New()/uuid.MustParse. +var defaultProject = pgUUID(uuid.MustParse("00000000-0000-0000-0000-000000000002")) + +func pgUUID(id uuid.UUID) pgtype.UUID { + return pgtype.UUID{Bytes: id, Valid: true} +} + +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: pgUUID(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: pgUUID(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) + } +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..068a34b --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,19 @@ +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 From 635b05361f54189800f51ec8c841241121c0fe68 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:20:03 +0700 Subject: [PATCH 07/14] =?UTF-8?q?refactor(store):=20sqlc=20override=20uuid?= =?UTF-8?q?=E2=86=92google/uuid.UUID=20(=D1=83=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20pgtype=20boilerplate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- internal/store/db/accounts.sql.go | 22 +++++++++++----------- internal/store/db/check_runs.sql.go | 8 ++++---- internal/store/db/domains.sql.go | 24 ++++++++++++------------ internal/store/db/models.go | 27 ++++++++++++++------------- internal/store/db/templates.sql.go | 20 ++++++++++---------- internal/store/store_test.go | 14 +++----------- sqlc.yaml | 10 ++++++++++ 8 files changed, 65 insertions(+), 62 deletions(-) diff --git a/go.mod b/go.mod index ab1c0e0..ac55f81 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/vasyakrg/dns-autoresolver go 1.26.4 require ( + 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 @@ -29,7 +30,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/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 diff --git a/internal/store/db/accounts.sql.go b/internal/store/db/accounts.sql.go index c8e7aa3..3afbeef 100644 --- a/internal/store/db/accounts.sql.go +++ b/internal/store/db/accounts.sql.go @@ -8,7 +8,7 @@ package db import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) const createAccount = `-- name: CreateAccount :one @@ -18,11 +18,11 @@ RETURNING id, project_id, provider, secret_enc, comment, created_at ` type CreateAccountParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - Provider string `json:"provider"` - SecretEnc string `json:"secret_enc"` - Comment string `json:"comment"` + 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) { @@ -50,8 +50,8 @@ DELETE FROM provider_accounts WHERE id = $1 AND project_id = $2 ` type DeleteAccountParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error { @@ -64,8 +64,8 @@ SELECT id, project_id, provider, secret_enc, comment, created_at FROM provider_a ` type GetAccountParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) GetAccount(ctx context.Context, arg GetAccountParams) (ProviderAccount, error) { @@ -86,7 +86,7 @@ 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 pgtype.UUID) ([]ProviderAccount, error) { +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 diff --git a/internal/store/db/check_runs.sql.go b/internal/store/db/check_runs.sql.go index 14a6ab5..3279246 100644 --- a/internal/store/db/check_runs.sql.go +++ b/internal/store/db/check_runs.sql.go @@ -8,7 +8,7 @@ package db import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) const createCheckRun = `-- name: CreateCheckRun :one @@ -18,9 +18,9 @@ RETURNING id, domain_id, result, created_at ` type CreateCheckRunParams struct { - ID pgtype.UUID `json:"id"` - DomainID pgtype.UUID `json:"domain_id"` - Result []byte `json:"result"` + 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) { diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 92eb755..0257e6e 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -8,7 +8,7 @@ package db import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) const createDomain = `-- name: CreateDomain :one @@ -18,12 +18,12 @@ RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, ` type CreateDomainParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - ProviderAccountID pgtype.UUID `json:"provider_account_id"` - ZoneName string `json:"zone_name"` - ZoneID string `json:"zone_id"` - TemplateID pgtype.UUID `json:"template_id"` + 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) { @@ -53,8 +53,8 @@ DELETE FROM domains WHERE id = $1 AND project_id = $2 ` type DeleteDomainParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) error { @@ -67,8 +67,8 @@ SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, cre ` type GetDomainParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) GetDomain(ctx context.Context, arg GetDomainParams) (Domain, error) { @@ -90,7 +90,7 @@ 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 pgtype.UUID) ([]Domain, error) { +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 diff --git a/internal/store/db/models.go b/internal/store/db/models.go index 8fff901..d3b98ab 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -5,37 +5,38 @@ 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 pgtype.UUID `json:"id"` - DomainID pgtype.UUID `json:"domain_id"` + 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 pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` - ProviderAccountID pgtype.UUID `json:"provider_account_id"` + 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 pgtype.UUID `json:"template_id"` + TemplateID *uuid.UUID `json:"template_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` } type Project struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` + 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 pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` Provider string `json:"provider"` SecretEnc string `json:"secret_enc"` Comment string `json:"comment"` @@ -43,8 +44,8 @@ type ProviderAccount struct { } type Template struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` Name string `json:"name"` Doc *dto.TemplateDoc `json:"doc"` Version int32 `json:"version"` @@ -53,7 +54,7 @@ type Template struct { } type User struct { - ID pgtype.UUID `json:"id"` + ID uuid.UUID `json:"id"` Email string `json:"email"` CreatedAt pgtype.Timestamptz `json:"created_at"` } diff --git a/internal/store/db/templates.sql.go b/internal/store/db/templates.sql.go index cd8f95d..0977908 100644 --- a/internal/store/db/templates.sql.go +++ b/internal/store/db/templates.sql.go @@ -8,7 +8,7 @@ package db import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) @@ -19,8 +19,8 @@ RETURNING id, project_id, name, doc, version, created_at, updated_at ` type CreateTemplateParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` Name string `json:"name"` Doc *dto.TemplateDoc `json:"doc"` } @@ -50,8 +50,8 @@ DELETE FROM templates WHERE id = $1 AND project_id = $2 ` type DeleteTemplateParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) error { @@ -64,8 +64,8 @@ SELECT id, project_id, name, doc, version, created_at, updated_at FROM templates ` type GetTemplateParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) GetTemplate(ctx context.Context, arg GetTemplateParams) (Template, error) { @@ -87,7 +87,7 @@ 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 pgtype.UUID) ([]Template, error) { +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 @@ -123,8 +123,8 @@ RETURNING id, project_id, name, doc, version, created_at, updated_at ` type UpdateTemplateParams struct { - ID pgtype.UUID `json:"id"` - ProjectID pgtype.UUID `json:"project_id"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` Name string `json:"name"` Doc *dto.TemplateDoc `json:"doc"` } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1e51df9..fb89309 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/vasyakrg/dns-autoresolver/internal/store/db" @@ -13,14 +12,7 @@ import ( ) // defaultProject is the seed default tenant project (see migrations/0001_init.sql). -// -// NOTE: sqlc generated UUID columns as pgtype.UUID (not google/uuid.UUID) — -// pgUUID bridges the two so tests can still use uuid.New()/uuid.MustParse. -var defaultProject = pgUUID(uuid.MustParse("00000000-0000-0000-0000-000000000002")) - -func pgUUID(id uuid.UUID) pgtype.UUID { - return pgtype.UUID{Bytes: id, Valid: true} -} +var defaultProject = uuid.MustParse("00000000-0000-0000-0000-000000000002") func newStore(t *testing.T) (*Store, context.Context) { dsn := startPostgres(t) @@ -35,7 +27,7 @@ func newStore(t *testing.T) (*Store, context.Context) { func TestAccountCRUD(t *testing.T) { s, ctx := newStore(t) acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{ - ID: pgUUID(uuid.New()), ProjectID: defaultProject, + ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod", }) if err != nil { @@ -66,7 +58,7 @@ func TestTemplateJSONBRoundTrip(t *testing.T) { {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: pgUUID(uuid.New()), ProjectID: defaultProject, Name: "base", Doc: &doc, + ID: uuid.New(), ProjectID: defaultProject, Name: "base", Doc: &doc, }) if err != nil { t.Fatal(err) diff --git a/sqlc.yaml b/sqlc.yaml index 068a34b..b10d882 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -17,3 +17,13 @@ sql: 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 From 8a2d985197bb03bd33f0771f919f8e18cc53b6d2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:22:59 +0700 Subject: [PATCH 08/14] =?UTF-8?q?feat(service):=20Check/Apply=20=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=20guard=20=D0=BD=D0=B0=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/service.go | 102 ++++++++++++++++++++++++++++ internal/service/service_test.go | 113 +++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..2dec925 --- /dev/null +++ b/internal/service/service.go @@ -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 +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..e799306 --- /dev/null +++ b/internal/service/service_test.go @@ -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) + } +} From fdf90a7c23e5784f602b41076ab4e8576962d7f3 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:28:06 +0700 Subject: [PATCH 09/14] =?UTF-8?q?feat(api):=20chi-=D1=80=D0=BE=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D1=80,=20check/apply=20=D1=85=D0=B5=D0=BD=D0=B4=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=D1=8B,=20changeset=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 2 + internal/api/api.go | 40 ++++++++++++++++++ internal/api/api_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ internal/api/dto.go | 54 ++++++++++++++++++++++++ internal/api/handlers.go | 56 +++++++++++++++++++++++++ 6 files changed, 243 insertions(+) create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/dto.go create mode 100644 internal/api/handlers.go diff --git a/go.mod b/go.mod index ac55f81..986f736 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 diff --git a/go.sum b/go.sum index c4c01fb..23b3e13 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ 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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..d856cf5 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,40 @@ +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/service" +) + +// 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) +} + +// API holds handler dependencies. Store/Cipher are used by CRUD handlers +// (added by the implementer following the accounts pattern). +type API struct { + Svc CheckApplier +} + +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/{did}", func(r chi.Router) { + r.Get("/check", a.handleCheck) + r.Post("/apply", a.handleApply) + }) + // accounts/templates/domains CRUD маунтятся тем же паттерном (Task 4 sqlc-методы) + }) + return r +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..c7bf992 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,90 @@ +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 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) + } +} diff --git a/internal/api/dto.go b/internal/api/dto.go new file mode 100644 index 0000000..fdc5a13 --- /dev/null +++ b/internal/api/dto.go @@ -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 +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..4890618 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "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 { + writeErr(w, http.StatusInternalServerError, err.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 { + // пустое тело допустимо → значения по умолчанию (prune=false) + _ = json.NewDecoder(r.Body).Decode(&req) + } + cs, err := a.Svc.Apply(r.Context(), did, service.ApplyRequest{ + ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes, + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, toChangesetResponse(cs)) +} From 05dc586646bdce76cb2eba3ed0ae034a54f79f03 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:35:43 +0700 Subject: [PATCH 10/14] =?UTF-8?q?fix(api):=20400=20=D0=BD=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B5=20=D1=82=D0=B5=D0=BB=D0=BE=20apply,=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20internal-=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA,?= =?UTF-8?q?=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=20=D1=82=D0=B5=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/api/handlers.go | 18 ++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c7bf992..0a48ee0 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -76,6 +76,41 @@ func TestApplyDefaultsPruneFalse(t *testing.T) { } } +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) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 4890618..2815dec 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -2,6 +2,9 @@ package api import ( "encoding/json" + "errors" + "io" + "log" "net/http" "github.com/go-chi/chi/v5" @@ -28,7 +31,8 @@ func (a *API) handleCheck(w http.ResponseWriter, r *http.Request) { } cs, err := a.Svc.Check(r.Context(), did) if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + log.Printf("api: check failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, toChangesetResponse(cs)) @@ -42,14 +46,20 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) { } var req applyRequest if r.Body != nil { - // пустое тело допустимо → значения по умолчанию (prune=false) - _ = json.NewDecoder(r.Body).Decode(&req) + 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 { - writeErr(w, http.StatusInternalServerError, err.Error()) + log.Printf("api: apply failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, toChangesetResponse(cs)) From 763919d23fbca58808e064ebd6f0d0c2a98581f6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:41:09 +0700 Subject: [PATCH 11/14] =?UTF-8?q?feat(server):=20Loader/Recorder=20=D0=BD?= =?UTF-8?q?=D0=B0=20Store,=20wiring=20cmd/server=20(config=E2=86=92migrate?= =?UTF-8?q?=E2=86=92pool=E2=86=92api)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 50 ++++++++++++++++ internal/store/db/domains.sql.go | 28 +++++++++ internal/store/loader.go | 54 +++++++++++++++++ internal/store/loader_test.go | 93 ++++++++++++++++++++++++++++++ internal/store/queries/domains.sql | 7 +++ 5 files changed, 232 insertions(+) create mode 100644 cmd/server/main.go create mode 100644 internal/store/loader.go create mode 100644 internal/store/loader_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e826f67 --- /dev/null +++ b/cmd/server/main.go @@ -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} + + log.Printf("listening on %s", cfg.ListenAddr) + if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { + log.Fatal(err) + } +} diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 0257e6e..24baed9 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) const createDomain = `-- name: CreateDomain :one @@ -117,3 +118,30 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai } 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 +} diff --git a/internal/store/loader.go b/internal/store/loader.go new file mode 100644 index 0000000..984c85c --- /dev/null +++ b/internal/store/loader.go @@ -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) diff --git a/internal/store/loader_test.go b/internal/store/loader_test.go new file mode 100644 index 0000000..33f210f --- /dev/null +++ b/internal/store/loader_test.go @@ -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") + } +} diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 4e134ea..4a85d59 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -11,3 +11,10 @@ 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; From ae6a4d7f4c35292202927fa308e06e04ed71dd27 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:53:29 +0700 Subject: [PATCH 12/14] =?UTF-8?q?feat(api):=20CRUD=20accounts/templates/do?= =?UTF-8?q?mains=20+=20import=20=D0=B7=D0=BE=D0=BD=20(=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB),=20secret=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B2=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9 Фазы 1B: узкий интерфейс TenantStore (внутри store.Account/Template/Domain, без db.* в api) реализован тонкими обёртками в internal/store/tenant.go; API.Store/ Cipher/Reg добавлены к существующему Svc. Роуты POST/GET/DELETE для accounts/ templates/domains + POST /accounts/{aid}/import (ListZones -> CreateDomain на зону). accountResponse не содержит секрет ни в каком виде. --- cmd/server/main.go | 2 +- internal/api/api.go | 71 +++++++- internal/api/tenant_dto.go | 80 ++++++++ internal/api/tenant_handlers.go | 307 +++++++++++++++++++++++++++++++ internal/api/tenant_test.go | 313 ++++++++++++++++++++++++++++++++ internal/store/tenant.go | 153 ++++++++++++++++ 6 files changed, 918 insertions(+), 8 deletions(-) create mode 100644 internal/api/tenant_dto.go create mode 100644 internal/api/tenant_handlers.go create mode 100644 internal/api/tenant_test.go create mode 100644 internal/store/tenant.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e826f67..9ba3d00 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,7 +41,7 @@ func main() { reg.Register(selectel.New()) svc := service.New(st, st, reg, cipher) - a := &api.API{Svc: svc} + 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 { diff --git a/internal/api/api.go b/internal/api/api.go index d856cf5..a4c53eb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,7 +9,10 @@ import ( "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. @@ -18,10 +21,42 @@ type CheckApplier interface { Apply(ctx context.Context, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) } -// API holds handler dependencies. Store/Cipher are used by CRUD handlers -// (added by the implementer following the accounts pattern). +// 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 + + 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 +} + +// 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 + Svc CheckApplier + Store TenantStore + Cipher Cipher + Reg ProviderRegistry } func NewRouter(a *API) http.Handler { @@ -30,11 +65,33 @@ func NewRouter(a *API) http.Handler { r.Use(middleware.Recoverer) r.Route("/api/v1/projects/{pid}", func(r chi.Router) { - r.Route("/domains/{did}", func(r chi.Router) { - r.Get("/check", a.handleCheck) - r.Post("/apply", a.handleApply) + 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.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) + }) }) - // accounts/templates/domains CRUD маунтятся тем же паттерном (Task 4 sqlc-методы) }) return r } diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go new file mode 100644 index 0000000..58baddb --- /dev/null +++ b/internal/api/tenant_dto.go @@ -0,0 +1,80 @@ +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"` +} + +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 +} diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go new file mode 100644 index 0000000..0e79188 --- /dev/null +++ b/internal/api/tenant_handlers.go @@ -0,0 +1,307 @@ +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 + } + created := make([]domainResponse, 0, len(zones)) + for _, z := range zones { + d, err := a.Store.CreateDomain(r.Context(), pid, aid, z.Name, z.ID, nil) + if err != nil { + log.Printf("api: import: create domain failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + 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 + } + 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) +} + +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) +} diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go new file mode 100644 index 0000000..4e24b01 --- /dev/null +++ b/internal/api/tenant_test.go @@ -0,0 +1,313 @@ +package api + +import ( + "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/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 +} + +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 m.accounts[0], nil +} + +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) 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 } + +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.createDomains != 2 { + t.Fatalf("expected 2 CreateDomain calls, got %d", ts.createDomains) + } + 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)) + } +} + +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) + } +} + +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) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go new file mode 100644 index 0000000..5bfc460 --- /dev/null +++ b/internal/store/tenant.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + + "github.com/google/uuid" + + "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}) +} + +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}) +} From 2aca92d070e23cc6e797d55258dfe1a0e54797d2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 15:08:16 +0700 Subject: [PATCH 13/14] =?UTF-8?q?fix(api):=20tenant-=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20account/template=20=D0=B2=20Cr?= =?UTF-8?q?eateDomain=20(HIGH),=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20import=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?(MEDIUM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.go | 2 + internal/api/tenant_handlers.go | 33 +++++-- internal/api/tenant_test.go | 170 +++++++++++++++++++++++++++++++- internal/store/store_test.go | 55 +++++++++++ internal/store/tenant.go | 39 ++++++++ 5 files changed, 288 insertions(+), 11 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index a4c53eb..249d72b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,10 +34,12 @@ type TenantStore interface { 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) } // Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it. diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go index 0e79188..e952d79 100644 --- a/internal/api/tenant_handlers.go +++ b/internal/api/tenant_handlers.go @@ -127,14 +127,16 @@ func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "internal error") return } - created := make([]domainResponse, 0, len(zones)) - for _, z := range zones { - d, err := a.Store.CreateDomain(r.Context(), pid, aid, z.Name, z.ID, nil) - if err != nil { - log.Printf("api: import: create domain 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) @@ -259,6 +261,21 @@ func (a *API) handleCreateDomain(w http.ResponseWriter, r *http.Request) { 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) diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 4e24b01..afc91f3 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -30,6 +31,10 @@ type mockTenantStore struct { domains []store.Domain createDomains int + + importDomains []store.Domain + importDomainsErr error + importCalled bool } func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) { @@ -49,7 +54,7 @@ func (m *mockTenantStore) GetAccount(_ context.Context, id, _ uuid.UUID) (store. return a, nil } } - return m.accounts[0], nil + return store.Account{}, errors.New("account not found") } func (m *mockTenantStore) DeleteAccount(context.Context, uuid.UUID, uuid.UUID) error { return nil } @@ -71,6 +76,15 @@ func (m *mockTenantStore) UpdateTemplate(_ context.Context, id, projectID uuid.U 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} @@ -84,6 +98,21 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return 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 } @@ -260,8 +289,11 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) { if w.Code != http.StatusCreated { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } - if ts.createDomains != 2 { - t.Fatalf("expected 2 CreateDomain calls, got %d", ts.createDomains) + 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 { @@ -272,6 +304,37 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) { } } +// 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) @@ -299,6 +362,107 @@ func TestCreateDomain_BadProjectUUID(t *testing.T) { } } +// 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) + } +} + func TestDeleteDomain_BadUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index fb89309..0f08f2c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -88,3 +89,57 @@ func TestTemplateJSONBRoundTrip(t *testing.T) { 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)) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 5bfc460..dc8908e 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -5,6 +5,7 @@ import ( "github.com/google/uuid" + "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/store/db" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) @@ -109,6 +110,16 @@ func (s *Store) DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) err 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 @@ -151,3 +162,31 @@ func (s *Store) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, 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. +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.CreateDomain(ctx, db.CreateDomainParams{ + ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, + ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil, + }) + if err != nil { + return nil, err + } + out = append(out, domainFromDB(d)) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return out, nil +} From ddab6e21628f62ca4a2412c1df50f846f2ab85e6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 15:24:08 +0700 Subject: [PATCH 14/14] =?UTF-8?q?fix(store,api):=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?import=20(UNIQUE+ON=20CONFLICT)=20+=20PATCH=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B8=20=D1=88=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=D0=BA=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.go | 2 + internal/api/tenant_dto.go | 6 + internal/api/tenant_handlers.go | 34 +++++ internal/api/tenant_test.go | 76 ++++++++++ internal/store/db/domains.sql.go | 64 +++++++++ .../store/migrations/0002_domains_unique.sql | 5 + internal/store/queries/domains.sql | 10 ++ internal/store/store_test.go | 134 ++++++++++++++++++ internal/store/tenant.go | 34 ++++- 9 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 internal/store/migrations/0002_domains_unique.sql diff --git a/internal/api/api.go b/internal/api/api.go index 249d72b..c3cff8f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -40,6 +40,7 @@ type TenantStore interface { 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. @@ -73,6 +74,7 @@ func NewRouter(a *API) http.Handler { 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) }) }) diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go index 58baddb..b3dd1a2 100644 --- a/internal/api/tenant_dto.go +++ b/internal/api/tenant_dto.go @@ -47,6 +47,12 @@ type domainRequest struct { 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"` diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go index e952d79..02f3dcc 100644 --- a/internal/api/tenant_handlers.go +++ b/internal/api/tenant_handlers.go @@ -304,6 +304,40 @@ func (a *API) handleListDomains(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index afc91f3..2b807a8 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -35,6 +35,8 @@ type mockTenantStore struct { 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) { @@ -98,6 +100,21 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai 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 { @@ -463,6 +480,65 @@ func TestCreateDomain_ValidTemplateInProject(t *testing.T) { } } +// --- 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) diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 24baed9..3b5aa32 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -87,6 +87,44 @@ func (q *Queries) GetDomain(ctx context.Context, arg GetDomainParams) (Domain, e 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 ` @@ -145,3 +183,29 @@ func (q *Queries) LoadDomainFull(ctx context.Context, id uuid.UUID) (LoadDomainF ) 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 +} diff --git a/internal/store/migrations/0002_domains_unique.sql b/internal/store/migrations/0002_domains_unique.sql new file mode 100644 index 0000000..78c82d5 --- /dev/null +++ b/internal/store/migrations/0002_domains_unique.sql @@ -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; diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 4a85d59..1f05fda 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -3,6 +3,16 @@ INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, te 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; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0f08f2c..bcbdc12 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -143,3 +143,137 @@ func TestImportDomains_RollsBackAllOnError(t *testing.T) { 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") + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index dc8908e..724f811 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -2,8 +2,10 @@ 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" @@ -166,6 +168,12 @@ func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error // 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 { @@ -176,11 +184,16 @@ func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUI q := s.q.WithTx(tx) out := make([]Domain, 0, len(zones)) for _, z := range zones { - d, err := q.CreateDomain(ctx, db.CreateDomainParams{ + 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)) @@ -190,3 +203,22 @@ func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUI } 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 +}