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)
Can CloudFormation manage DNSCove records?
Yes. Model DNSCove zones and records as Lambda-backed custom resources. The provider Lambda receives CloudFormation lifecycle events, calls the DNSCove JSON API, and reports success or failure back to the stack so DNS changes follow the same create, update, and delete workflow as the rest of your infrastructure.
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. Minting itself needs a
credential too: either a signed-in console session (cookie + X-CSRF-Token,
never a bearer header — see
API.md's authentication section) or, as below, an
existing tenant-admin scoped token used to bootstrap this one:
TOKEN=$(curl -sX POST https://api.dnscove.com/api/tokens \
-H "Authorization: Bearer $BOOTSTRAP_TENANT_ADMIN_TOKEN" \
-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/publishwith aDependsOnthe records. Bothzone-adminandtenant-admintokens may publish. A zone-admin publish returns409 PublishBlockedif another zone is already pending when it starts; publish those changes with a session or tenant-admin and retry.