feat(stackflow): add declarative StackFlow + DNS roles
This commit is contained in:
parent
4a31bc4075
commit
96cf0d7d8a
152
.github/workflows/stackflow.yaml
vendored
Normal file
152
.github/workflows/stackflow.yaml
vendored
Normal file
@ -0,0 +1,152 @@
|
||||
name: StackFlow (Plan/Validate)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
config:
|
||||
description: "Path to StackFlow config (e.g. StackFlow/svc-plus.yaml)"
|
||||
required: true
|
||||
type: string
|
||||
default: "StackFlow/svc-plus.yaml"
|
||||
phase:
|
||||
description: "Phase to run"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- validate
|
||||
- dns-plan
|
||||
pull_request:
|
||||
paths:
|
||||
- "StackFlow/**/*.yml"
|
||||
- "StackFlow/**/*.yaml"
|
||||
- "stackflow/**/*.yml"
|
||||
- "stackflow/**/*.yaml"
|
||||
- ".github/workflows/stackflow.yaml"
|
||||
- "scripts/stackflow/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "StackFlow/**/*.yml"
|
||||
- "StackFlow/**/*.yaml"
|
||||
- "stackflow/**/*.yml"
|
||||
- "stackflow/**/*.yaml"
|
||||
- ".github/workflows/stackflow.yaml"
|
||||
- "scripts/stackflow/**"
|
||||
|
||||
jobs:
|
||||
resolve-configs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
configs: ${{ steps.set.outputs.configs }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve config list
|
||||
id: set
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
python - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json
|
||||
print("configs=" + json.dumps(["${{ inputs.config }}"]))
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
git fetch origin "${{ github.base_ref }}" --depth=1
|
||||
files="$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD || true)"
|
||||
else
|
||||
# push
|
||||
files="$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" || true)"
|
||||
fi
|
||||
|
||||
configs="$(printf '%s\n' "$files" | grep -E '^(StackFlow|stackflow)/.*\.ya?ml$' || true)"
|
||||
if [[ -z "${configs}" ]]; then
|
||||
if [[ -f "stackflow/svc.plus.yaml" ]]; then
|
||||
configs="stackflow/svc.plus.yaml"
|
||||
else
|
||||
configs="StackFlow/svc-plus.yaml"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$configs" | python - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json, sys
|
||||
configs = [l.strip() for l in sys.stdin.read().splitlines() if l.strip()]
|
||||
print("configs=" + json.dumps(configs))
|
||||
PY
|
||||
|
||||
stackflow:
|
||||
runs-on: ubuntu-latest
|
||||
needs: resolve-configs
|
||||
concurrency:
|
||||
group: stackflow-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config: ${{ fromJson(needs.resolve-configs.outputs.configs) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r scripts/stackflow/requirements.txt
|
||||
|
||||
- name: Prepare output dir
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p out
|
||||
|
||||
- name: Run StackFlow (workflow_dispatch)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
python scripts/stackflow/runner.py \
|
||||
--config "${{ inputs.config }}" \
|
||||
--phase "${{ inputs.phase }}"
|
||||
|
||||
- name: Validate (CI)
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
python scripts/stackflow/runner.py \
|
||||
--config "${{ matrix.config }}" \
|
||||
--phase validate \
|
||||
> "out/$(basename "${{ matrix.config }}").validate.json"
|
||||
|
||||
- name: DNS Plan (CI)
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
python scripts/stackflow/runner.py \
|
||||
--config "${{ matrix.config }}" \
|
||||
--phase dns-plan \
|
||||
> "out/$(basename "${{ matrix.config }}").dns-plan.json"
|
||||
|
||||
- name: Compute artifact name (CI)
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="${{ matrix.config }}"
|
||||
name="${name//\//-}"
|
||||
echo "ARTIFACT_NAME=stackflow-${name}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload artifacts (CI)
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: out/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,3 +7,7 @@ playbooks/deepflow/*.tar.gz
|
||||
playbooks/deepflow/deepflow-agent-playbook/*.zip
|
||||
|
||||
remotes.before.txt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
116
StackFlow/svc-plus.yaml
Normal file
116
StackFlow/svc-plus.yaml
Normal file
@ -0,0 +1,116 @@
|
||||
apiVersion: gitops.svc.plus/v1alpha1
|
||||
kind: StackFlow
|
||||
metadata:
|
||||
# Stack identifier (used in plans/artifacts).
|
||||
name: svc-plus
|
||||
|
||||
global:
|
||||
# Root domain for this business stack.
|
||||
# Runner enforces: every targets[].domains[] must be under this root.
|
||||
domain: svc.plus
|
||||
|
||||
# Declarative provider selector for future dns-apply (no secrets here).
|
||||
dns_provider: cloudflare
|
||||
|
||||
# Default cloud for this stack (future iac-apply/deploy/observe phases).
|
||||
cloud: gcp
|
||||
gcp_project: xzerolab-480008
|
||||
|
||||
# Optional: multi-environment overrides (selected by runner --env).
|
||||
# Today CI only runs plan/validate; env selection is for future expansion.
|
||||
environments:
|
||||
prod:
|
||||
dns_provider: cloudflare
|
||||
cloud: gcp
|
||||
gcp_project: xzerolab-480008
|
||||
dev:
|
||||
dns_provider: cloudflare
|
||||
cloud: gcp
|
||||
gcp_project: xzerolab-480008
|
||||
|
||||
# Source-of-truth repos (informational).
|
||||
gitops: https://github.com/cloud-neutral-toolkit/gitops
|
||||
playbooks: https://github.com/cloud-neutral-toolkit/playbook
|
||||
iac_modules: https://github.com/cloud-neutral-toolkit/iac_modules
|
||||
|
||||
targets:
|
||||
# -----------------------------------------
|
||||
# Vercel: www + console
|
||||
# -----------------------------------------
|
||||
- id: vercel-console
|
||||
type: vercel
|
||||
vercel:
|
||||
project_url: https://vercel.com/svc-designs-projects/console-svc-plus
|
||||
team_slug: svc-designs-projects
|
||||
project_slug: console-svc-plus
|
||||
domains:
|
||||
- www.svc.plus
|
||||
- console.svc.plus
|
||||
# Optional env-specific intent (not used by runner yet).
|
||||
environments:
|
||||
dev:
|
||||
domains:
|
||||
- www.dev.svc.plus
|
||||
- console.dev.svc.plus
|
||||
dns:
|
||||
# Default policy: pure DNS. Proxy can be enabled per-record later.
|
||||
records:
|
||||
- name: www
|
||||
type: CNAME
|
||||
value: cname.vercel-dns.com.
|
||||
proxied: false
|
||||
- name: console
|
||||
type: CNAME
|
||||
value: cname.vercel-dns.com.
|
||||
proxied: false
|
||||
|
||||
# -----------------------------------------
|
||||
# GCE vhost: clawdbot
|
||||
# -----------------------------------------
|
||||
- id: clawdbot
|
||||
type: vhost
|
||||
cloud: gcp
|
||||
gcp:
|
||||
project: xzerolab-480008
|
||||
zone: asia-east1-b
|
||||
instance_name: clawdbot-svc-plus
|
||||
console_url: https://console.cloud.google.com/compute/instancesDetail/zones/asia-east1-b/instances/clawdbot-svc-plus?project=xzerolab-480008
|
||||
domains:
|
||||
- clawdbot.svc.plus
|
||||
resources:
|
||||
os: debian-13
|
||||
cpu: 2
|
||||
mem_mib: 4096
|
||||
disk_gb: 50
|
||||
endpoints:
|
||||
# Will be filled by future iac-apply output.
|
||||
public_ipv4: ""
|
||||
dns:
|
||||
records:
|
||||
- name: clawdbot
|
||||
type: A
|
||||
valueFrom: endpoints.public_ipv4
|
||||
proxied: false
|
||||
|
||||
# -----------------------------------------
|
||||
# GCP Cloud Run: accounts
|
||||
# -----------------------------------------
|
||||
- id: accounts
|
||||
type: cloud-run
|
||||
cloud: gcp
|
||||
repo: https://github.com/cloud-neutral-toolkit/accounts.svc.plus
|
||||
gcp:
|
||||
project: xzerolab-480008
|
||||
region: asia-northeast1
|
||||
service: accounts-svc-plus
|
||||
console_url: https://console.cloud.google.com/run/detail/asia-northeast1/accounts-svc-plus/observability/metrics?project=xzerolab-480008
|
||||
domains:
|
||||
- accounts.svc.plus
|
||||
deploy:
|
||||
mode: repo-dispatch
|
||||
repository: cloud-neutral-toolkit/accounts.svc.plus
|
||||
event_type: stackflow.deploy.cloudrun
|
||||
dns:
|
||||
# Cloud Run custom domain mapping needs provider-specific verification records.
|
||||
# Keep explicit records here once known; plan/validate won't apply them.
|
||||
records: []
|
||||
58
docs/stackflow/README.md
Normal file
58
docs/stackflow/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# StackFlow (GitOps YAML Flow)
|
||||
|
||||
StackFlow is a declarative YAML describing a full business stack deployment
|
||||
across DNS, cloud resources (IAC), and Ansible-based provisioning.
|
||||
|
||||
This repository already contains:
|
||||
- `playbooks/` (Ansible provisioning for vhosts/docker/k3s)
|
||||
- `iac-template/` (Terraform reference templates)
|
||||
- `.github/workflows/` (bootstrap workflows)
|
||||
|
||||
StackFlow adds a top-level config file that can drive those pieces in one place.
|
||||
|
||||
## Goals
|
||||
|
||||
- One YAML describes root domain + targets (Vercel, Cloud Run, vhosts, etc.)
|
||||
- CI can validate config, produce a DNS plan, then apply phases later
|
||||
- Never commit real secrets (tokens/keys); use GitHub Secrets / Secret Manager
|
||||
|
||||
## Config Example
|
||||
|
||||
See: `StackFlow/svc-plus.yaml`
|
||||
|
||||
## Schema (v1alpha1)
|
||||
|
||||
Top-level:
|
||||
- `apiVersion`: `gitops.svc.plus/v1alpha1`
|
||||
- `kind`: `StackFlow`
|
||||
- `metadata.name`: stack id
|
||||
- `global.domain`: root domain, e.g. `svc.plus`
|
||||
- `global.dns_provider`: `cloudflare` (planned), `alicloud` (existing playbooks)
|
||||
- `global.cloud`: `gcp`
|
||||
- `targets[]`: list of deployable targets
|
||||
|
||||
Target fields (common):
|
||||
- `id`: unique id
|
||||
- `type`: `vercel` | `cloud-run` | `vhost` | `kubernetes` (planned)
|
||||
- `domains[]`: FQDNs owned by this target
|
||||
- `dns.records[]`: explicit DNS record intents
|
||||
|
||||
DNS record intent:
|
||||
- `name`: record name relative to `global.domain` (e.g. `www`)
|
||||
- `type`: `A` | `AAAA` | `CNAME` | `TXT` | `MX`
|
||||
- `value`: literal value (string)
|
||||
- `valueFrom`: dotted path reference inside the target (e.g. `endpoints.public_ipv4`)
|
||||
- `ttl`: optional int seconds
|
||||
- `proxied`: optional bool (Cloudflare-specific)
|
||||
|
||||
## Workflows
|
||||
|
||||
Planned phases:
|
||||
- `validate`: validate YAML structure
|
||||
- `dns-plan`: output required DNS records (no apply)
|
||||
- `dns-apply`: apply DNS changes (provider-specific)
|
||||
- `iac-apply`: provision resources via Terraform
|
||||
- `deploy`: deploy apps via Ansible or repo-dispatch
|
||||
- `observe`: connect monitoring / alerts
|
||||
|
||||
Today we only ship `validate` + `dns-plan` as the first step.
|
||||
@ -0,0 +1,16 @@
|
||||
---
|
||||
# Required
|
||||
cloudflare_dns_domain: ""
|
||||
cloudflare_dns_rr: ""
|
||||
cloudflare_dns_type: ""
|
||||
cloudflare_dns_value: ""
|
||||
|
||||
# Optional
|
||||
# Cloudflare TTL supports 1 (automatic) or a provider-dependent range.
|
||||
cloudflare_dns_ttl: 1
|
||||
cloudflare_dns_proxied: false
|
||||
cloudflare_dns_priority: null
|
||||
|
||||
# Secret (pass via extra-vars / env / vault; never commit real values)
|
||||
cloudflare_api_token: ""
|
||||
|
||||
@ -0,0 +1,194 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
API_BASE = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
|
||||
def _request(method, path, api_token, payload=None, query=None):
|
||||
url = API_BASE + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url=url, data=body, method=method)
|
||||
req.add_header("Authorization", "Bearer " + api_token)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _api_ok(data):
|
||||
return isinstance(data, dict) and data.get("success") is True
|
||||
|
||||
|
||||
def _api_first_result(data):
|
||||
if not _api_ok(data):
|
||||
return None
|
||||
res = data.get("result")
|
||||
if isinstance(res, list) and res:
|
||||
return res[0]
|
||||
return None
|
||||
|
||||
|
||||
def _zone_id(api_token, zone_name):
|
||||
data = _request("GET", "/zones", api_token, query={"name": zone_name, "per_page": 50})
|
||||
z = _api_first_result(data)
|
||||
if not z:
|
||||
return None
|
||||
return z.get("id")
|
||||
|
||||
|
||||
def _record_fqdn(zone, rr):
|
||||
rr = rr.strip()
|
||||
if rr in ("@", zone):
|
||||
return zone
|
||||
return rr + "." + zone
|
||||
|
||||
|
||||
def _get_record(api_token, zone_id, record_type, fqdn):
|
||||
data = _request(
|
||||
"GET",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
api_token,
|
||||
query={"type": record_type, "name": fqdn, "per_page": 50},
|
||||
)
|
||||
return _api_first_result(data)
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type="str", choices=["present", "absent"], default="present"),
|
||||
zone=dict(type="str", required=True),
|
||||
rr=dict(type="str", required=True),
|
||||
type=dict(type="str", required=True),
|
||||
value=dict(type="str"),
|
||||
ttl=dict(type="int", default=1),
|
||||
proxied=dict(type="bool", default=False),
|
||||
priority=dict(type="int", required=False),
|
||||
api_token=dict(type="str", required=True, no_log=True),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
state = module.params["state"]
|
||||
zone = module.params["zone"]
|
||||
rr = module.params["rr"]
|
||||
record_type = module.params["type"].upper()
|
||||
value = module.params["value"]
|
||||
ttl = module.params["ttl"]
|
||||
proxied = module.params["proxied"]
|
||||
priority = module.params.get("priority", None)
|
||||
api_token = module.params["api_token"]
|
||||
|
||||
if state == "present" and not value:
|
||||
module.fail_json(msg="value is required when state=present")
|
||||
|
||||
try:
|
||||
zid = _zone_id(api_token, zone)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=f"Failed to query Cloudflare zone id: {e}")
|
||||
|
||||
if not zid:
|
||||
module.fail_json(msg=f"Cloudflare zone not found: {zone}")
|
||||
|
||||
fqdn = _record_fqdn(zone, rr)
|
||||
|
||||
try:
|
||||
existing = _get_record(api_token, zid, record_type, fqdn)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=f"Failed to query Cloudflare DNS record: {e}")
|
||||
|
||||
# ----------------------------
|
||||
# ABSENT
|
||||
# ----------------------------
|
||||
if state == "absent":
|
||||
if not existing:
|
||||
module.exit_json(changed=False, msg="Record already absent")
|
||||
if module.check_mode:
|
||||
module.exit_json(changed=True)
|
||||
rid = existing.get("id")
|
||||
try:
|
||||
data = _request("DELETE", f"/zones/{zid}/dns_records/{rid}", api_token)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=f"Failed to delete record: {e}")
|
||||
if not _api_ok(data):
|
||||
module.fail_json(msg="Cloudflare API error deleting record", details=data)
|
||||
module.exit_json(changed=True, msg="Record deleted", record_id=rid, fqdn=fqdn)
|
||||
|
||||
# ----------------------------
|
||||
# PRESENT (create/update)
|
||||
# ----------------------------
|
||||
desired = {
|
||||
"type": record_type,
|
||||
"name": fqdn,
|
||||
"content": value,
|
||||
"ttl": ttl,
|
||||
"proxied": proxied,
|
||||
}
|
||||
if priority is not None:
|
||||
desired["priority"] = priority
|
||||
|
||||
if existing:
|
||||
cur = {
|
||||
"type": existing.get("type"),
|
||||
"name": existing.get("name"),
|
||||
"content": existing.get("content"),
|
||||
"ttl": existing.get("ttl"),
|
||||
"proxied": existing.get("proxied"),
|
||||
}
|
||||
if priority is not None:
|
||||
cur["priority"] = existing.get("priority")
|
||||
|
||||
if cur == desired:
|
||||
module.exit_json(
|
||||
changed=False,
|
||||
msg="Record already up to date",
|
||||
record_id=existing.get("id"),
|
||||
fqdn=fqdn,
|
||||
)
|
||||
|
||||
if module.check_mode:
|
||||
module.exit_json(changed=True)
|
||||
|
||||
rid = existing.get("id")
|
||||
try:
|
||||
data = _request("PUT", f"/zones/{zid}/dns_records/{rid}", api_token, payload=desired)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=f"Failed to update record: {e}")
|
||||
if not _api_ok(data):
|
||||
module.fail_json(msg="Cloudflare API error updating record", details=data)
|
||||
module.exit_json(changed=True, msg="Record updated", record_id=rid, fqdn=fqdn)
|
||||
|
||||
# CREATE
|
||||
if module.check_mode:
|
||||
module.exit_json(changed=True)
|
||||
|
||||
try:
|
||||
data = _request("POST", f"/zones/{zid}/dns_records", api_token, payload=desired)
|
||||
except Exception as e:
|
||||
module.fail_json(msg=f"Failed to create record: {e}")
|
||||
if not _api_ok(data):
|
||||
module.fail_json(msg="Cloudflare API error creating record", details=data)
|
||||
|
||||
rec = data.get("result") or {}
|
||||
module.exit_json(changed=True, msg="Record created", record_id=rec.get("id"), fqdn=fqdn)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
17
playbooks/roles/vhosts/cloudflare_dns_record/tasks/main.yml
Normal file
17
playbooks/roles/vhosts/cloudflare_dns_record/tasks/main.yml
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
- name: Ensure Cloudflare DNS Record
|
||||
cloudflare_dns_record:
|
||||
state: present
|
||||
zone: "{{ cloudflare_dns_domain }}"
|
||||
rr: "{{ cloudflare_dns_rr }}"
|
||||
type: "{{ cloudflare_dns_type }}"
|
||||
value: "{{ cloudflare_dns_value }}"
|
||||
ttl: "{{ cloudflare_dns_ttl }}"
|
||||
proxied: "{{ cloudflare_dns_proxied }}"
|
||||
priority: "{{ cloudflare_dns_priority }}"
|
||||
api_token: "{{ cloudflare_api_token }}"
|
||||
register: dns_result
|
||||
|
||||
- debug:
|
||||
var: dns_result
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
cloudflare_dns_sync_domain: ""
|
||||
cloudflare_dns_sync_records: []
|
||||
cloudflare_dns_sync_output: "/tmp/dns_records.yaml"
|
||||
|
||||
# Secret: set via extra-vars / env / vault; do not commit real values.
|
||||
cloudflare_api_token: ""
|
||||
|
||||
118
playbooks/roles/vhosts/cloudflare_dns_sync/files/dns_sync.py
Normal file
118
playbooks/roles/vhosts/cloudflare_dns_sync/files/dns_sync.py
Normal file
@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
API_BASE = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
|
||||
def _req(method: str, path: str, token: str, payload=None, query=None):
|
||||
url = API_BASE + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url=url, data=body, method=method)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _ok(d):
|
||||
return isinstance(d, dict) and d.get("success") is True
|
||||
|
||||
|
||||
def zone_id(token: str, zone_name: str) -> str:
|
||||
d = _req("GET", "/zones", token, query={"name": zone_name, "per_page": 50})
|
||||
if not _ok(d) or not d.get("result"):
|
||||
raise RuntimeError(f"zone not found: {zone_name}")
|
||||
return d["result"][0]["id"]
|
||||
|
||||
|
||||
def fqdn(zone: str, rr: str) -> str:
|
||||
rr = rr.strip()
|
||||
if rr in ("@", zone):
|
||||
return zone
|
||||
return rr + "." + zone
|
||||
|
||||
|
||||
def get_record(token: str, zid: str, rtype: str, name: str):
|
||||
d = _req("GET", f"/zones/{zid}/dns_records", token, query={"type": rtype, "name": name, "per_page": 50})
|
||||
if not _ok(d):
|
||||
raise RuntimeError("query dns_records failed")
|
||||
res = d.get("result") or []
|
||||
return res[0] if res else None
|
||||
|
||||
|
||||
def ensure_record(token: str, zid: str, zone: str, rec: dict):
|
||||
rr = rec["rr"]
|
||||
rtype = rec["type"].upper()
|
||||
value = rec["value"]
|
||||
ttl = int(rec.get("ttl", 1))
|
||||
proxied = bool(rec.get("proxied", False))
|
||||
priority = rec.get("priority", None)
|
||||
|
||||
name = fqdn(zone, rr)
|
||||
desired = {"type": rtype, "name": name, "content": value, "ttl": ttl, "proxied": proxied}
|
||||
if priority is not None:
|
||||
desired["priority"] = int(priority)
|
||||
|
||||
cur = get_record(token, zid, rtype, name)
|
||||
if not cur:
|
||||
print("CREATE:", desired)
|
||||
d = _req("POST", f"/zones/{zid}/dns_records", token, payload=desired)
|
||||
if not _ok(d):
|
||||
raise RuntimeError("create failed: " + json.dumps(d))
|
||||
return
|
||||
|
||||
cur_slim = {
|
||||
"type": cur.get("type"),
|
||||
"name": cur.get("name"),
|
||||
"content": cur.get("content"),
|
||||
"ttl": cur.get("ttl"),
|
||||
"proxied": cur.get("proxied"),
|
||||
}
|
||||
if priority is not None:
|
||||
cur_slim["priority"] = cur.get("priority")
|
||||
|
||||
if cur_slim == desired:
|
||||
print("OK:", desired["name"], desired["type"])
|
||||
return
|
||||
|
||||
print("UPDATE:", desired)
|
||||
rid = cur["id"]
|
||||
d = _req("PUT", f"/zones/{zid}/dns_records/{rid}", token, payload=desired)
|
||||
if not _ok(d):
|
||||
raise RuntimeError("update failed: " + json.dumps(d))
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print(f"usage: {sys.argv[0]} <dns_records.yaml>", file=sys.stderr)
|
||||
return 2
|
||||
fn = argv[1]
|
||||
token = os.environ.get("CLOUDFLARE_API_TOKEN", "").strip()
|
||||
if not token:
|
||||
print("CLOUDFLARE_API_TOKEN is required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cfg = yaml.safe_load(open(fn, "r", encoding="utf-8")) or {}
|
||||
for zone, recs in cfg.items():
|
||||
zid = zone_id(token, zone)
|
||||
for rec in recs or []:
|
||||
ensure_record(token, zid, zone, rec)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
|
||||
19
playbooks/roles/vhosts/cloudflare_dns_sync/tasks/main.yaml
Normal file
19
playbooks/roles/vhosts/cloudflare_dns_sync/tasks/main.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
- name: Generate DNS records file from template
|
||||
template:
|
||||
src: dns_records.yaml.j2
|
||||
dest: "{{ cloudflare_dns_sync_output }}"
|
||||
|
||||
- name: Upload dns_sync.py
|
||||
copy:
|
||||
src: dns_sync.py
|
||||
dest: /tmp/dns_sync.py
|
||||
mode: "0755"
|
||||
|
||||
- name: Sync DNS records
|
||||
command: >
|
||||
python3 /tmp/dns_sync.py
|
||||
{{ cloudflare_dns_sync_output }}
|
||||
environment:
|
||||
CLOUDFLARE_API_TOKEN: "{{ cloudflare_api_token }}"
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
{{ cloudflare_dns_sync_domain }}:
|
||||
{% for rec in cloudflare_dns_sync_records %}
|
||||
- rr: "{{ rec.rr }}"
|
||||
type: "{{ rec.type }}"
|
||||
value: "{{ rec.value }}"
|
||||
ttl: {{ rec.ttl | default(1) }}
|
||||
proxied: {{ rec.proxied | default(false) }}
|
||||
{% endfor %}
|
||||
|
||||
2
scripts/stackflow/requirements.txt
Normal file
2
scripts/stackflow/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
PyYAML==6.0.2
|
||||
|
||||
258
scripts/stackflow/runner.py
Normal file
258
scripts/stackflow/runner.py
Normal file
@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fatal(msg: str, code: int = 2) -> None:
|
||||
print(f"stackflow: {msg}", file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def _load_yaml(path: str) -> dict[str, Any]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except Exception:
|
||||
_fatal(
|
||||
"missing dependency PyYAML. Install with: python3 -m pip install -r scripts/stackflow/requirements.txt"
|
||||
)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
_fatal(f"config must be a YAML mapping, got {type(data).__name__}")
|
||||
return data
|
||||
|
||||
|
||||
def _get(d: dict[str, Any], key: str, typ: type, *, required: bool = True) -> Any:
|
||||
v = d.get(key)
|
||||
if v is None:
|
||||
if required:
|
||||
_fatal(f"missing required field: {key}")
|
||||
return None
|
||||
if not isinstance(v, typ):
|
||||
_fatal(f"field {key} must be {typ.__name__}, got {type(v).__name__}")
|
||||
return v
|
||||
|
||||
|
||||
def _as_str(v: Any, ctx: str) -> str:
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
_fatal(f"{ctx} must be a non-empty string")
|
||||
return v
|
||||
|
||||
|
||||
def _normalize_record(rec: dict[str, Any]) -> dict[str, Any]:
|
||||
name = rec.get("name")
|
||||
rtype = rec.get("type")
|
||||
if name is None or rtype is None:
|
||||
_fatal("dns.records entries require name and type")
|
||||
name = _as_str(name, "dns.records[].name")
|
||||
rtype = _as_str(rtype, "dns.records[].type").upper()
|
||||
|
||||
out: dict[str, Any] = {"name": name, "type": rtype}
|
||||
|
||||
if "valueFrom" in rec:
|
||||
out["valueFrom"] = _as_str(rec["valueFrom"], "dns.records[].valueFrom")
|
||||
elif "value" in rec:
|
||||
out["value"] = _as_str(rec["value"], "dns.records[].value")
|
||||
else:
|
||||
_fatal("dns.records entries require either value or valueFrom")
|
||||
|
||||
ttl = rec.get("ttl")
|
||||
if ttl is not None:
|
||||
if not isinstance(ttl, int) or ttl <= 0:
|
||||
_fatal("dns.records[].ttl must be a positive int")
|
||||
out["ttl"] = ttl
|
||||
|
||||
proxied = rec.get("proxied")
|
||||
if proxied is not None:
|
||||
if not isinstance(proxied, bool):
|
||||
_fatal("dns.records[].proxied must be boolean")
|
||||
out["proxied"] = proxied
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Stack:
|
||||
name: str
|
||||
root_domain: str
|
||||
dns_provider: str
|
||||
cloud: str
|
||||
targets: list[dict[str, Any]]
|
||||
|
||||
|
||||
def validate(cfg: dict[str, Any]) -> Stack:
|
||||
kind = _get(cfg, "kind", str)
|
||||
if kind != "StackFlow":
|
||||
_fatal(f"kind must be StackFlow, got {kind!r}")
|
||||
|
||||
md = _get(cfg, "metadata", dict)
|
||||
name = _as_str(md.get("name"), "metadata.name")
|
||||
|
||||
g = _get(cfg, "global", dict)
|
||||
root_domain = _as_str(g.get("domain"), "global.domain")
|
||||
dns_provider = _as_str(g.get("dns_provider"), "global.dns_provider")
|
||||
cloud = _as_str(g.get("cloud"), "global.cloud")
|
||||
|
||||
# Optional multi-environment overrides.
|
||||
environments = g.get("environments")
|
||||
if environments is not None and not isinstance(environments, dict):
|
||||
_fatal("global.environments must be a mapping of env -> overrides")
|
||||
|
||||
targets = _get(cfg, "targets", list)
|
||||
for i, t in enumerate(targets):
|
||||
if not isinstance(t, dict):
|
||||
_fatal(f"targets[{i}] must be a mapping")
|
||||
_as_str(t.get("id"), f"targets[{i}].id")
|
||||
_as_str(t.get("type"), f"targets[{i}].type")
|
||||
domains = t.get("domains")
|
||||
if domains is None or not isinstance(domains, list) or not domains:
|
||||
_fatal(f"targets[{i}].domains must be a non-empty list")
|
||||
for j, d in enumerate(domains):
|
||||
fqdn = _as_str(d, f"targets[{i}].domains[{j}]")
|
||||
if not (fqdn == root_domain or fqdn.endswith("." + root_domain)):
|
||||
_fatal(
|
||||
f"targets[{i}].domains[{j}] must be under global.domain ({root_domain}), got {fqdn}"
|
||||
)
|
||||
|
||||
dns = t.get("dns", {})
|
||||
if dns is None:
|
||||
dns = {}
|
||||
if not isinstance(dns, dict):
|
||||
_fatal(f"targets[{i}].dns must be a mapping")
|
||||
recs = dns.get("records", [])
|
||||
if recs is None:
|
||||
recs = []
|
||||
if not isinstance(recs, list):
|
||||
_fatal(f"targets[{i}].dns.records must be a list")
|
||||
for k, r in enumerate(recs):
|
||||
if not isinstance(r, dict):
|
||||
_fatal(f"targets[{i}].dns.records[{k}] must be a mapping")
|
||||
_normalize_record(r)
|
||||
|
||||
return Stack(
|
||||
name=name,
|
||||
root_domain=root_domain,
|
||||
dns_provider=dns_provider,
|
||||
cloud=cloud,
|
||||
targets=targets,
|
||||
)
|
||||
|
||||
|
||||
def _apply_env_overrides(cfg: dict[str, Any], env_name: str | None) -> dict[str, Any]:
|
||||
if env_name is None:
|
||||
return cfg
|
||||
g = cfg.get("global")
|
||||
if not isinstance(g, dict):
|
||||
return cfg
|
||||
envs = g.get("environments")
|
||||
if envs is None:
|
||||
return cfg
|
||||
if not isinstance(envs, dict):
|
||||
_fatal("global.environments must be a mapping of env -> overrides")
|
||||
overrides = envs.get(env_name)
|
||||
if overrides is None:
|
||||
_fatal(f"env not found in global.environments: {env_name}")
|
||||
if not isinstance(overrides, dict):
|
||||
_fatal(f"global.environments.{env_name} must be a mapping")
|
||||
|
||||
# Shallow-merge global overrides into global.
|
||||
merged = dict(cfg)
|
||||
merged_global = dict(g)
|
||||
for k, v in overrides.items():
|
||||
merged_global[k] = v
|
||||
merged["global"] = merged_global
|
||||
return merged
|
||||
|
||||
|
||||
def dns_plan(cfg: dict[str, Any], env_name: str | None) -> dict[str, Any]:
|
||||
cfg2 = _apply_env_overrides(cfg, env_name)
|
||||
stack = validate(cfg2)
|
||||
out: dict[str, Any] = {
|
||||
"stack": stack.name,
|
||||
"env": env_name or "",
|
||||
"global": {
|
||||
"domain": stack.root_domain,
|
||||
"dns_provider": stack.dns_provider,
|
||||
},
|
||||
"records": [],
|
||||
}
|
||||
|
||||
# Flatten all explicit dns.records across targets.
|
||||
records: list[dict[str, Any]] = []
|
||||
for t in stack.targets:
|
||||
tid = t["id"]
|
||||
dns = t.get("dns") or {}
|
||||
recs = dns.get("records") or []
|
||||
for r in recs:
|
||||
nr = _normalize_record(r)
|
||||
nr["target"] = tid
|
||||
records.append(nr)
|
||||
out["records"] = records
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(prog="stackflow")
|
||||
ap.add_argument("--config", required=True, help="Path to StackFlow YAML")
|
||||
ap.add_argument(
|
||||
"--env",
|
||||
default="",
|
||||
help="Optional environment name under global.environments (e.g. dev/prod)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--phase",
|
||||
required=True,
|
||||
choices=["validate", "dns-plan"],
|
||||
help="Which phase to run",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--format",
|
||||
default="json",
|
||||
choices=["json"],
|
||||
help="Output format for plan phases",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not os.path.exists(args.config):
|
||||
_fatal(f"config not found: {args.config}")
|
||||
|
||||
cfg = _load_yaml(args.config)
|
||||
env_name = args.env.strip() or None
|
||||
|
||||
if args.phase == "validate":
|
||||
cfg2 = _apply_env_overrides(cfg, env_name)
|
||||
s = validate(cfg2)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"stack": s.name,
|
||||
"env": env_name or "",
|
||||
"domain": s.root_domain,
|
||||
"dns_provider": s.dns_provider,
|
||||
"cloud": s.cloud,
|
||||
"targets": len(s.targets),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.phase == "dns-plan":
|
||||
plan = dns_plan(cfg, env_name)
|
||||
print(json.dumps(plan, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
_fatal(f"unknown phase: {args.phase}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Loading…
Reference in New Issue
Block a user