Skip to content

Commit c47a79e

Browse files
feat(playground): add automated CF DNS + SSL workflow (DAK-6756) (#204)
One-shot `playground-dns-and-ssl.yml` workflow that: 1. Creates playground.dakera.ai A record via Cloudflare API 2. Waits for DNS propagation (up to 5 min via 1.1.1.1) 3. Issues Let's Encrypt cert via certbot webroot method 4. Updates nginx config to use new cert + server_name 5. Verifies HTTPS /health returns 200 6. Sends Telegram success/failure notification Requires `CLOUDFLARE_DNS_TOKEN` secret in dakera-ai/dakera-deploy (Zone.DNS.Edit permission for dakera.ai zone). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8eef512 commit c47a79e

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
name: Playground - Create DNS Record + Enable SSL
2+
3+
# DAK-6756: One-shot workflow that creates the Cloudflare A record for
4+
# playground.dakera.ai → 5.75.177.31, waits for propagation, then issues
5+
# a Let's Encrypt cert and reloads nginx.
6+
#
7+
# Prerequisites:
8+
# 1. Add CLOUDFLARE_DNS_TOKEN secret to dakera-ai/dakera-deploy:
9+
# CF dashboard → My Profile → API Tokens → Create Token
10+
# Template: Edit zone DNS → Zone: dakera.ai → Permission: Zone.DNS.Edit
11+
# gh secret set CLOUDFLARE_DNS_TOKEN -R dakera-ai/dakera-deploy --body '<token>'
12+
# 2. Run this workflow via: gh workflow run playground-dns-and-ssl.yml -R dakera-ai/dakera-deploy
13+
14+
on:
15+
workflow_dispatch:
16+
inputs:
17+
domain:
18+
description: "Subdomain to create (A record name)"
19+
required: false
20+
default: "playground"
21+
target_ip:
22+
description: "IPv4 address the A record should point to"
23+
required: false
24+
default: "5.75.177.31"
25+
zone_name:
26+
description: "Cloudflare zone (root domain)"
27+
required: false
28+
default: "dakera.ai"
29+
skip_dns:
30+
description: "Skip DNS creation (DNS already exists)"
31+
required: false
32+
default: "false"
33+
type: choice
34+
options:
35+
- "false"
36+
- "true"
37+
38+
jobs:
39+
dns-and-ssl:
40+
name: "CF DNS + Let's Encrypt for ${{ inputs.domain || 'playground' }}.${{ inputs.zone_name || 'dakera.ai' }}"
41+
runs-on: ubuntu-latest
42+
env:
43+
SUBDOMAIN: ${{ inputs.domain || 'playground' }}
44+
TARGET_IP: ${{ inputs.target_ip || '5.75.177.31' }}
45+
ZONE_NAME: ${{ inputs.zone_name || 'dakera.ai' }}
46+
SKIP_DNS: ${{ inputs.skip_dns || 'false' }}
47+
48+
steps:
49+
# -------------------------------------------------------------------------
50+
# Step 1: Derive the full domain from inputs
51+
# -------------------------------------------------------------------------
52+
- name: Set derived vars
53+
id: vars
54+
run: |
55+
FULL_DOMAIN="${SUBDOMAIN}.${ZONE_NAME}"
56+
echo "full_domain=${FULL_DOMAIN}" >> "$GITHUB_OUTPUT"
57+
echo "Full domain: ${FULL_DOMAIN} → ${TARGET_IP}"
58+
59+
# -------------------------------------------------------------------------
60+
# Step 2: Create Cloudflare A record (skip if already exists)
61+
# -------------------------------------------------------------------------
62+
- name: Create Cloudflare DNS A record
63+
if: env.SKIP_DNS == 'false'
64+
env:
65+
CF_TOKEN: ${{ secrets.CLOUDFLARE_DNS_TOKEN }}
66+
FULL_DOMAIN: ${{ steps.vars.outputs.full_domain }}
67+
run: |
68+
if [ -z "$CF_TOKEN" ]; then
69+
echo "::error::CLOUDFLARE_DNS_TOKEN secret is not set."
70+
echo "Add it: gh secret set CLOUDFLARE_DNS_TOKEN -R dakera-ai/dakera-deploy --body '<token>'"
71+
echo "Token needs Zone.DNS.Edit permission for ${ZONE_NAME}."
72+
exit 1
73+
fi
74+
75+
echo "Looking up Zone ID for ${ZONE_NAME}..."
76+
ZONE_ID=$(curl -sf "https://api.cloudflare.com/client/v4/zones?name=${ZONE_NAME}&status=active" \
77+
-H "Authorization: Bearer ${CF_TOKEN}" \
78+
-H "Content-Type: application/json" \
79+
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'] if d.get('result') else '')")
80+
81+
if [ -z "$ZONE_ID" ]; then
82+
echo "::error::Could not find Cloudflare zone for ${ZONE_NAME}. Check token permissions."
83+
exit 1
84+
fi
85+
echo "Zone ID: ${ZONE_ID}"
86+
87+
# Check if record already exists
88+
EXISTING=$(curl -sf "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=A&name=${FULL_DOMAIN}" \
89+
-H "Authorization: Bearer ${CF_TOKEN}" \
90+
-H "Content-Type: application/json" \
91+
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'] if d.get('result') else '')" 2>/dev/null || echo "")
92+
93+
if [ -n "$EXISTING" ]; then
94+
echo "A record for ${FULL_DOMAIN} already exists (id: ${EXISTING}). Skipping creation."
95+
else
96+
echo "Creating A record: ${FULL_DOMAIN} → ${TARGET_IP} (DNS only, TTL=auto)"
97+
RESULT=$(curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
98+
-H "Authorization: Bearer ${CF_TOKEN}" \
99+
-H "Content-Type: application/json" \
100+
-d "{
101+
\"type\": \"A\",
102+
\"name\": \"${SUBDOMAIN}\",
103+
\"content\": \"${TARGET_IP}\",
104+
\"ttl\": 1,
105+
\"proxied\": false
106+
}")
107+
echo "$RESULT" | python3 -c "
108+
import sys, json
109+
d = json.load(sys.stdin)
110+
if d.get('success'):
111+
rec = d['result']
112+
print(f'Created DNS record: {rec[\"name\"]} → {rec[\"content\"]} (id: {rec[\"id\"]})')
113+
else:
114+
errors = d.get('errors', [])
115+
print('::error::DNS creation failed:', errors)
116+
sys.exit(1)
117+
"
118+
fi
119+
120+
# -------------------------------------------------------------------------
121+
# Step 3: Wait for DNS propagation (up to 5 min via 1.1.1.1)
122+
# -------------------------------------------------------------------------
123+
- name: Wait for DNS propagation
124+
env:
125+
FULL_DOMAIN: ${{ steps.vars.outputs.full_domain }}
126+
run: |
127+
echo "Waiting for ${FULL_DOMAIN} to resolve to ${TARGET_IP} via 1.1.1.1..."
128+
for i in $(seq 1 30); do
129+
RESOLVED=$(dig +short "${FULL_DOMAIN}" @1.1.1.1 A | head -1)
130+
echo "Attempt $i/30: ${FULL_DOMAIN} → '${RESOLVED}' (want: ${TARGET_IP})"
131+
if [ "$RESOLVED" = "$TARGET_IP" ]; then
132+
echo "DNS propagated."
133+
break
134+
fi
135+
if [ "$i" -eq 30 ]; then
136+
echo "::error::DNS did not propagate to ${TARGET_IP} within 5 minutes."
137+
echo "Current value: '${RESOLVED}'"
138+
exit 1
139+
fi
140+
sleep 10
141+
done
142+
143+
# -------------------------------------------------------------------------
144+
# Step 4: SSH to server + issue Let's Encrypt cert via webroot
145+
# -------------------------------------------------------------------------
146+
- name: Setup SSH
147+
run: |
148+
mkdir -p ~/.ssh
149+
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
150+
chmod 600 ~/.ssh/deploy_key
151+
ssh-keyscan -H "$TARGET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
152+
153+
- name: Issue Let's Encrypt cert + update nginx
154+
env:
155+
FULL_DOMAIN: ${{ steps.vars.outputs.full_domain }}
156+
run: |
157+
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "root@${TARGET_IP}" bash << REMOTE
158+
set -euo pipefail
159+
160+
DOMAIN="${FULL_DOMAIN}"
161+
echo "=== Issuing cert for \$DOMAIN ==="
162+
163+
# Webroot method: works with existing nginx config
164+
certbot certonly --webroot \
165+
-w /var/www/certbot \
166+
-d "\$DOMAIN" \
167+
--agree-tos \
168+
--non-interactive \
169+
--email ops@dakera.ai
170+
171+
echo "Cert issued. Updating nginx config..."
172+
173+
# Swap cert paths: sslip.io → playground.dakera.ai
174+
SITE=/etc/nginx/sites-enabled/playground
175+
sed -i "s|/etc/letsencrypt/live/5-75-177-31.sslip.io/fullchain.pem|/etc/letsencrypt/live/\$DOMAIN/fullchain.pem|g" "\$SITE"
176+
sed -i "s|/etc/letsencrypt/live/5-75-177-31.sslip.io/privkey.pem|/etc/letsencrypt/live/\$DOMAIN/privkey.pem|g" "\$SITE"
177+
178+
# Add playground.dakera.ai to server_name
179+
sed -i "s|server_name _;|server_name \$DOMAIN _;|g" "\$SITE"
180+
181+
echo "Testing nginx config..."
182+
nginx -t
183+
184+
echo "Reloading nginx..."
185+
nginx -s reload
186+
187+
echo "nginx updated and reloaded."
188+
REMOTE
189+
190+
# -------------------------------------------------------------------------
191+
# Step 5: Verify HTTPS
192+
# -------------------------------------------------------------------------
193+
- name: Verify HTTPS health endpoint
194+
env:
195+
FULL_DOMAIN: ${{ steps.vars.outputs.full_domain }}
196+
run: |
197+
echo "Verifying https://${FULL_DOMAIN}/health ..."
198+
sleep 5
199+
for i in $(seq 1 6); do
200+
HTTP_CODE=$(curl -sf --max-time 15 -o /dev/null -w "%{http_code}" "https://${FULL_DOMAIN}/health" 2>/dev/null || echo "000")
201+
echo "Attempt $i/6: HTTP $HTTP_CODE"
202+
if [ "$HTTP_CODE" = "200" ]; then
203+
echo "HTTPS health check PASSED — https://${FULL_DOMAIN} is live!"
204+
break
205+
fi
206+
if [ "$i" -eq 6 ]; then
207+
echo "::error::HTTPS health check failed after 30s (got HTTP $HTTP_CODE)"
208+
exit 1
209+
fi
210+
sleep 5
211+
done
212+
213+
# -------------------------------------------------------------------------
214+
# Step 6: Telegram notification
215+
# -------------------------------------------------------------------------
216+
- name: Notify Telegram
217+
if: always()
218+
env:
219+
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
220+
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
221+
FULL_DOMAIN: ${{ steps.vars.outputs.full_domain }}
222+
STATUS: ${{ job.status }}
223+
RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
224+
run: |
225+
if [ "$STATUS" = "success" ]; then
226+
ICON="✅"
227+
MSG="[Platform] playground.dakera.ai DNS + SSL LIVE\n\nURL: https://${FULL_DOMAIN}\nCert: Let's Encrypt (90 days, auto-renew)\nRun: ${RUN_URL}"
228+
else
229+
ICON="❌"
230+
MSG="[Platform] playground.dakera.ai DNS/SSL setup FAILED\n\nRun: ${RUN_URL}\nCheck logs above for error details."
231+
fi
232+
curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
233+
-H "Content-Type: application/json" \
234+
-d "{\"chat_id\":\"${TELEGRAM_CHAT_ID}\",\"text\":\"${ICON} ${MSG}\"}" \
235+
> /dev/null || true

0 commit comments

Comments
 (0)