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"`
|
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 {
|
type Domain struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_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")
|
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
|
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
|
// GetDomainStatus returns the last known check status for a domain (Фаза 3
|
||||||
// scheduler/checker). Callers scope access to the domain themselves (e.g.
|
// scheduler/checker). Callers scope access to the domain themselves (e.g.
|
||||||
// via a prior GetDomain) — this lookup is by primary key alone.
|
// via a prior GetDomain) — this lookup is by primary key alone.
|
||||||
|
|||||||
Reference in New Issue
Block a user