feat(store): persist per-domain custom record marks
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: customs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addCustomRecord = `-- name: AddCustomRecord :exec
|
||||
INSERT INTO domain_custom_records (domain_id, record_key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (domain_id, record_key) DO NOTHING
|
||||
`
|
||||
|
||||
type AddCustomRecordParams struct {
|
||||
DomainID uuid.UUID `json:"domain_id"`
|
||||
RecordKey string `json:"record_key"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddCustomRecord(ctx context.Context, arg AddCustomRecordParams) error {
|
||||
_, err := q.db.Exec(ctx, addCustomRecord, arg.DomainID, arg.RecordKey)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteCustomRecord = `-- name: DeleteCustomRecord :exec
|
||||
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2
|
||||
`
|
||||
|
||||
type DeleteCustomRecordParams struct {
|
||||
DomainID uuid.UUID `json:"domain_id"`
|
||||
RecordKey string `json:"record_key"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteCustomRecord(ctx context.Context, arg DeleteCustomRecordParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteCustomRecord, arg.DomainID, arg.RecordKey)
|
||||
return err
|
||||
}
|
||||
|
||||
const listCustomKeys = `-- name: ListCustomKeys :many
|
||||
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key
|
||||
`
|
||||
|
||||
func (q *Queries) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listCustomKeys, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var record_key string
|
||||
if err := rows.Scan(&record_key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, record_key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -17,6 +17,13 @@ type CheckRun struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type DomainCustomRecord struct {
|
||||
DomainID uuid.UUID `json:"domain_id"`
|
||||
RecordKey string `json:"record_key"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE domain_custom_records (
|
||||
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
record_key text NOT NULL,
|
||||
note text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (domain_id, record_key)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE domain_custom_records;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- name: AddCustomRecord :exec
|
||||
INSERT INTO domain_custom_records (domain_id, record_key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (domain_id, record_key) DO NOTHING;
|
||||
|
||||
-- name: DeleteCustomRecord :exec
|
||||
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2;
|
||||
|
||||
-- name: ListCustomKeys :many
|
||||
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key;
|
||||
@@ -277,3 +277,89 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
|
||||
t.Fatal("expected error binding a template from a different project, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// seedDomain creates a provider account and imports a single domain for
|
||||
// defaultProject, returning the resulting Domain — the shared setup used by
|
||||
// the custom-records tests below.
|
||||
func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
return doms[0]
|
||||
}
|
||||
|
||||
// TestCustomRecordsAddIsIdempotentAndScoped verifies that AddCustomRecord
|
||||
// is idempotent (ON CONFLICT DO NOTHING — no PK-conflict error on repeat)
|
||||
// and that it is scoped by projectID, closing the same IDOR-on-write gap
|
||||
// SetDomainTemplate guards against.
|
||||
func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
dom := seedDomain(t, s, ctx)
|
||||
|
||||
const key = "CNAME admin.example.com."
|
||||
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Повторное добавление того же ключа не должно падать на PK-конфликте.
|
||||
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||
t.Fatalf("second add must be a no-op, got %v", err)
|
||||
}
|
||||
keys, err := s.ListCustomKeys(ctx, dom.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 || keys[0] != key {
|
||||
t.Fatalf("expected exactly [%q], got %+v", key, keys)
|
||||
}
|
||||
|
||||
// Чужой проект не может пометить этот домен.
|
||||
if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil {
|
||||
t.Fatal("expected an error when marking a domain from another project")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomRecordsDeleteAndCascade verifies DeleteCustomRecord removes the
|
||||
// mark, and that deleting the domain cascades to remove any remaining marks
|
||||
// (domain_custom_records.domain_id REFERENCES domains(id) ON DELETE CASCADE).
|
||||
func TestCustomRecordsDeleteAndCascade(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
dom := seedDomain(t, s, ctx)
|
||||
|
||||
const key = "CNAME admin.example.com."
|
||||
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err := s.ListCustomKeys(ctx, dom.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("expected no keys after delete, got %+v", keys)
|
||||
}
|
||||
|
||||
// Каскад: удаление домена уносит его пометки.
|
||||
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err = s.ListCustomKeys(ctx, dom.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,34 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.
|
||||
return domainFromDB(d), nil
|
||||
}
|
||||
|
||||
// AddCustomRecord marks an RRset (identified by its diff key, "TYPE name.")
|
||||
// as deliberately kept outside the template for this domain. Idempotent.
|
||||
// The scoped GetDomain first ensures the domain belongs to projectID —
|
||||
// otherwise a foreign domain could be marked (IDOR-on-write), same guard
|
||||
// SetDomainTemplate uses for templates.
|
||||
func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.q.AddCustomRecord(ctx, db.AddCustomRecordParams{DomainID: domainID, RecordKey: key})
|
||||
}
|
||||
|
||||
// DeleteCustomRecord removes the mark, returning the record to the regular
|
||||
// diff. Scoped by projectID for the same reason as AddCustomRecord.
|
||||
func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.q.DeleteCustomRecord(ctx, db.DeleteCustomRecordParams{DomainID: domainID, RecordKey: key})
|
||||
}
|
||||
|
||||
// ListCustomKeys returns the marked record keys of a domain. Not scoped by
|
||||
// project on purpose: the only caller is LoadDomain, which has already
|
||||
// resolved the domain by (id, projectID).
|
||||
func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
|
||||
return s.q.ListCustomKeys(ctx, domainID)
|
||||
}
|
||||
|
||||
// GetDomainStatus returns the last known check status for a domain (Фаза 3
|
||||
// scheduler/checker). Callers scope access to the domain themselves (e.g.
|
||||
// via a prior GetDomain) — this lookup is by primary key alone.
|
||||
|
||||
Reference in New Issue
Block a user