DNSCove

DNSCove guide

DNSCove as a CloudFormation custom resource

DNSCove isn't a native CloudFormation resource type, but you can manage DNSCove zones and records inside a CloudFormation stack with a Lambda-backed custom resource. CloudFormation calls your Lambda on Create/Update/Delete; the Lambda calls the DNSCove API. Records then follow the stack lifecycle — created with the stack, updated on change, and deleted on teardown.

CloudFormation stack ──lifecycle event──▶ Lambda (DNSCoveProvider)
                                             │  reads token from Secrets Manager
                                             ▼
                       POST /api/zones/{id}/rrsets   (Create/Update: UPSERT)
                       POST /api/zones/{id}/rrsets   (Delete: DELETE)
                       POST /api/zones               (zone provisioning)

1. Store a token in Secrets Manager

Use a tenant-admin token if the stack provisions zones; a zone-admin token is enough if it only manages records on one existing zone. Mint it once (see machine tokens in the API reference) and store the secret — never put the token literal in a template.

TOKEN=$(curl -sX POST https://api.dnscove.com/api/tokens \
  -H "Authorization: Bearer $SESSION" \
  -d '{"scope":"tenant-admin","name":"cloudformation","expires_in_days":90}' | jq -r .token)

aws secretsmanager create-secret --name dnscove/cfn-token --secret-string "$TOKEN"

2. The provider Lambda

A single Python 3.12 function (no external dependencies — urllib only) handles the custom-resource protocol for both records and zones. It reads the token from the DNSCOVE_TOKEN environment variable, which CloudFormation resolves from Secrets Manager via a dynamic reference.

# provider.py — DNSCove CloudFormation custom-resource provider
import json, os, urllib.request, urllib.error

API = os.environ.get("DNSCOVE_API", "https://api.dnscove.com")
TOKEN = os.environ["DNSCOVE_TOKEN"]

def _call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(API + path, data=data, method=method,
        headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r:
            raw = r.read()
            return r.status, (json.loads(raw) if raw else {})
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"{method} {path} -> {e.code}: {e.read().decode()}")

def _respond(event, context, status, physical_id, data=None, reason=""):
    body = json.dumps({
        "Status": status, "Reason": reason or f"see log stream {context.log_stream_name}",
        "PhysicalResourceId": physical_id, "StackId": event["StackId"],
        "RequestId": event["RequestId"], "LogicalResourceId": event["LogicalResourceId"],
        "Data": data or {},
    }).encode()
    req = urllib.request.Request(event["ResponseURL"], data=body, method="PUT",
        headers={"Content-Type": "", "Content-Length": str(len(body))})
    urllib.request.urlopen(req)

def handler(event, context):
    rtype = event["ResourceType"]           # Custom::DNSCoveRecord | Custom::DNSCoveZone
    req = event["RequestType"]               # Create | Update | Delete
    props = event.get("ResourceProperties", {})
    try:
        if rtype == "Custom::DNSCoveZone":
            physical, data = _zone(req, event, props)
        else:
            physical, data = _record(req, props)
        _respond(event, context, "SUCCESS", physical, data)
    except Exception as e:  # never leave a stack hanging — always respond
        physical = event.get("PhysicalResourceId", "dnscove-failed")
        _respond(event, context, "FAILED", physical, reason=str(e))

def _record(req, p):
    zone_id, name, rtype = p["ZoneId"], p["Name"], p["Type"]
    physical = f"{zone_id}/{name}/{rtype}"
    if req == "Delete":
        _call("POST", f"/api/zones/{zone_id}/rrsets",
              {"action": "DELETE", "name": name, "type": rtype,
               "records": p.get("Records", []), "ttl": int(p.get("TTL", 300))})
        return physical, {}
    # Create + Update are both UPSERT (idempotent). A changed Name/Type yields a
    # new PhysicalResourceId, so CloudFormation deletes the old record for us.
    _call("POST", f"/api/zones/{zone_id}/rrsets",
          {"action": "UPSERT", "name": name, "type": rtype,
           "ttl": int(p.get("TTL", 300)), "records": p["Records"]})
    return physical, {"ZoneId": zone_id}

def _zone(req, event, p):
    origin = p["Origin"]
    if req == "Create":
        _, z = _call("POST", "/api/zones", {"origin": origin})
        return z["id"], {"ZoneId": z["id"], "NS": ",".join(z.get("ns", []))}
    if req == "Delete":
        zone_id = event["PhysicalResourceId"]
        if zone_id.startswith("Z"):
            _call("DELETE", f"/api/zones/{zone_id}")
        return zone_id, {}
    # Update: origin is immutable; keep the existing zone.
    return event["PhysicalResourceId"], {"ZoneId": event["PhysicalResourceId"]}

Package and deploy it like any Lambda (inline ZipFile for small code, or an S3 artifact). Grant it secretsmanager:GetSecretValue only if you read the secret at runtime; the template below injects it at deploy time instead.

3. Use it in a template

Resources:
  DNSCoveProvider:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: provider.handler
      Timeout: 30
      Role: !GetAtt ProviderRole.Arn
      Environment:
        Variables:
          # Resolve the secret at deploy time (never store the literal token).
          DNSCOVE_TOKEN: "{{resolve:secretsmanager:dnscove/cfn-token:SecretString}}"
      Code:
        S3Bucket: my-lambda-artifacts
        S3Key: dnscove/provider.zip

  # A record managed by the stack:
  WwwRecord:
    Type: Custom::DNSCoveRecord
    Properties:
      ServiceToken: !GetAtt DNSCoveProvider.Arn
      ZoneId: Z1a2b3c4d5e6f70
      Name: www.example.com.
      Type: A
      TTL: 300
      Records:
        - 192.0.2.10
        - 192.0.2.11

  # A zone provisioned by the stack (requires a tenant-admin token):
  AppZone:
    Type: Custom::DNSCoveZone
    Properties:
      ServiceToken: !GetAtt DNSCoveProvider.Arn
      Origin: app.example.com

Outputs:
  AppZoneNameservers:
    Value: !GetAtt AppZone.NS   # delegate the parent domain to these

Notes