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
- Idempotency —
CreateandUpdateboth UPSERT, so re-runs are safe. Renaming a record changes itsPhysicalResourceId; CloudFormation then sends aDeletefor the old record automatically. - Always respond — the handler responds
FAILEDon any exception so a stack never hangs for the full hour waiting on the custom resource. - Least privilege — scope the token to what the stack needs:
zone-admin(one zone's records) unless the stack also creates zones (tenant-admin). - Publishing — record writes are committed and picked up by the next publish;
the API's ACME endpoint publishes immediately, record UPSERTs do not. If you
need the change live synchronously, add a
Custom::DNSCovePublishresource that callsPOST /api/publish(tenant-admin) with aDependsOnthe records.