From 8a2d985197bb03bd33f0771f919f8e18cc53b6d2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:22:59 +0700 Subject: [PATCH] =?UTF-8?q?feat(service):=20Check/Apply=20=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=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) + } +}