From 763919d23fbca58808e064ebd6f0d0c2a98581f6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:41:09 +0700 Subject: [PATCH] =?UTF-8?q?feat(server):=20Loader/Recorder=20=D0=BD=D0=B0?= =?UTF-8?q?=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;