Files
commerce/api/billing/dns_usage.go

329 lines
8.7 KiB
Go

package billing
import (
"fmt"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/meter"
"github.com/hanzoai/commerce/util/json/http"
. "github.com/hanzoai/commerce/types"
)
// dnsUsageRequest is the input for recording a DNS query usage batch.
type dnsUsageRequest struct {
Zone string `json:"zone"`
User string `json:"user"`
Queries int64 `json:"queries"`
Errors int64 `json:"errors"`
Timestamp string `json:"timestamp"`
}
// dnsUsageResponse is returned after recording DNS usage.
type dnsUsageResponse struct {
Recorded bool `json:"recorded"`
Remaining int64 `json:"remaining"`
Limit int64 `json:"limit"`
}
// dnsUsageSummaryResponse is returned for DNS usage summary queries.
type dnsUsageSummaryResponse struct {
Queries int64 `json:"queries"`
Zones int `json:"zones"`
Records int `json:"records"`
Limit int64 `json:"limit"`
Plan string `json:"plan"`
}
const (
dnsQueriesMeterEvent = "dns-queries"
dnsDefaultPlanSlug = "dns-free"
)
// RecordDNSUsage records a batch of DNS query usage for a zone owner.
// The zone's owner is looked up via the user field. Usage is checked against
// the plan's daily query limit. Every batch writes a meter event, an empty one
// included.
//
// POST /v1/dns/usage
func RecordDNSUsage(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req dnsUsageRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.Zone == "" {
return http.Fail(c, 400, "zone is required", nil)
}
if req.User == "" {
return http.Fail(c, 400, "user is required", nil)
}
if req.Queries < 0 || req.Errors < 0 {
return http.Fail(c, 400, "queries and errors must not be negative", nil)
}
// Resolve the user's DNS plan to determine limits.
_, dailyLimit := resolveDNSPlan(db, req.User)
// Calculate today's usage so far by querying meter events.
// A quota that cannot be read cannot be enforced, so the batch is refused.
todayStart := todayUTC()
todayUsage, err := aggregateDNSUsage(db, req.User, todayStart, todayStart.Add(24*time.Hour))
if err != nil {
return http.Fail(c, 503, "cannot read today's DNS usage", err)
}
// Check limit. -1 means unlimited. An empty batch serves nothing, so it is
// recorded rather than refused.
if req.Queries > 0 && dailyLimit > 0 && todayUsage+req.Queries > dailyLimit {
remaining := dailyLimit - todayUsage
if remaining < 0 {
remaining = 0
}
return http.Fail(c, 429, fmt.Sprintf("daily DNS query limit exceeded: %d/%d", todayUsage+req.Queries, dailyLimit), nil)
}
// Parse timestamp or default to now.
ts := time.Now()
if req.Timestamp != "" {
if parsed, err := time.Parse(time.RFC3339, req.Timestamp); err == nil {
ts = parsed
}
}
// Find or create the dns-queries meter.
meterId := ensureDNSMeter(db)
// Record the meter event.
evt := meter.NewEvent(db)
evt.MeterId = meterId
evt.UserId = req.User
evt.Value = req.Queries
evt.Timestamp = ts
evt.Idempotency = fmt.Sprintf("dns:%s:%s:%d", req.User, req.Zone, ts.UnixNano())
evt.Dimensions = Map{
"zone": req.Zone,
"errors": req.Errors,
}
if err := evt.Create(); err != nil {
log.Error("Failed to create DNS meter event: %v", err, c)
return http.Fail(c, 500, "failed to record dns usage", err)
}
remaining := int64(-1)
if dailyLimit > 0 {
remaining = dailyLimit - (todayUsage + req.Queries)
if remaining < 0 {
remaining = 0
}
}
return c.JSON(201, dnsUsageResponse{
Recorded: true,
Remaining: remaining,
Limit: dailyLimit,
})
}
// GetDNSUsageSummary returns a usage summary for DNS queries, zones, and records.
//
// GET /v1/dns/usage/summary?user={owner/name}&period=day|month
func GetDNSUsageSummary(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
user := strings.TrimSpace(c.Query("user"))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
period := strings.TrimSpace(c.Query("period"))
if period == "" {
period = "day"
}
var periodStart, periodEnd time.Time
now := time.Now().UTC()
switch period {
case "month":
periodStart = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
periodEnd = periodStart.AddDate(0, 1, 0)
default:
periodStart = todayUTC()
periodEnd = periodStart.Add(24 * time.Hour)
}
queries, err := aggregateDNSUsage(db, user, periodStart, periodEnd)
if err != nil {
return http.Fail(c, 503, "cannot read DNS usage", err)
}
zones, err := countDNSZones(db, user, periodStart, periodEnd)
if err != nil {
return http.Fail(c, 503, "cannot read DNS usage", err)
}
planSlug, dailyLimit := resolveDNSPlan(db, user)
return c.JSON(200, map[string]any{
"user": user,
"period": period,
"queries": queries,
"zones": zones,
"records": 0, // Records are tracked by the DNS service, not Commerce
"limit": dailyLimit,
"plan": planSlug,
})
}
// ListDNSPlans returns the available DNS plans.
//
// GET /v1/dns/plans
func ListDNSPlans(c *zip.Ctx) error {
return c.JSON(200, dnsPlans)
}
// resolveDNSPlan looks up the user's active DNS subscription to determine
// their plan slug and daily query limit. Falls back to dns-free if no
// subscription is found.
func resolveDNSPlan(db *datastore.Datastore, user string) (planSlug string, dailyLimit int64) {
// Look up active subscriptions with a dns-* plan.
rootKey := db.NewKey("synckey", "", 1, nil)
type subRecord struct {
PlanId string `datastore:"PlanId"`
Status string `datastore:"Status"`
}
subs := make([]*subRecord, 0)
q := db.Query("subscription").Ancestor(rootKey).
Filter("UserId=", user).
Filter("Status=", "active")
if _, err := q.GetAll(&subs); err == nil {
for _, s := range subs {
if strings.HasPrefix(s.PlanId, "dns-") {
planSlug = s.PlanId
break
}
}
}
if planSlug == "" {
planSlug = dnsDefaultPlanSlug
}
p := lookupPlan(planSlug)
if p != nil && p.Limits != nil && p.Limits.QueriesPerDay != nil {
limit := int64(*p.Limits.QueriesPerDay)
if limit < 0 {
return planSlug, -1 // unlimited
}
return planSlug, limit
}
// Fallback: dns-free defaults
return planSlug, 10000
}
// aggregateDNSUsage sums DNS query meter events for a user within a time range.
func aggregateDNSUsage(db *datastore.Datastore, user string, start, end time.Time) (int64, error) {
rootKey := db.NewKey("synckey", "", 1, nil)
events := make([]*meter.MeterEvent, 0)
q := meter.QueryEvents(db).Ancestor(rootKey).
Filter("UserId=", user).
Filter("Timestamp>=", start).
Filter("Timestamp<", end)
if _, err := q.GetAll(&events); err != nil {
return 0, err
}
// Only count events that belong to the dns-queries meter.
meterId := findDNSMeterId(db)
var total int64
for _, evt := range events {
if evt.MeterId == meterId {
total += evt.Value
}
}
return total, nil
}
// countDNSZones counts distinct zones from DNS meter events for a user within a period.
func countDNSZones(db *datastore.Datastore, user string, start, end time.Time) (int, error) {
rootKey := db.NewKey("synckey", "", 1, nil)
events := make([]*meter.MeterEvent, 0)
q := meter.QueryEvents(db).Ancestor(rootKey).
Filter("UserId=", user).
Filter("Timestamp>=", start).
Filter("Timestamp<", end)
if _, err := q.GetAll(&events); err != nil {
return 0, err
}
meterId := findDNSMeterId(db)
zones := make(map[interface{}]struct{})
for _, evt := range events {
if evt.MeterId == meterId && evt.Dimensions != nil {
if z, ok := evt.Dimensions["zone"]; ok {
zones[z] = struct{}{}
}
}
}
return len(zones), nil
}
// ensureDNSMeter finds or creates the dns-queries meter and returns its ID.
func ensureDNSMeter(db *datastore.Datastore) string {
if id := findDNSMeterId(db); id != "" {
return id
}
m := meter.New(db)
m.Name = "DNS Queries"
m.EventName = dnsQueriesMeterEvent
m.AggregationType = meter.AggSum
m.Currency = "usd"
m.Dimensions = []string{"zone", "errors"}
if err := m.Create(); err != nil {
log.Error("Failed to create dns-queries meter: %v", err)
return ""
}
return m.Id()
}
// findDNSMeterId looks up the dns-queries meter ID from the datastore.
func findDNSMeterId(db *datastore.Datastore) string {
rootKey := db.NewKey("synckey", "", 1, nil)
meters := make([]*meter.Meter, 0, 1)
q := meter.Query(db).Ancestor(rootKey).
Filter("EventName=", dnsQueriesMeterEvent).
Limit(1)
if _, err := q.GetAll(&meters); err == nil && len(meters) > 0 {
return meters[0].Id()
}
return ""
}
// todayUTC returns the start of the current UTC day.
func todayUTC() time.Time {
now := time.Now().UTC()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
}