added state-driven jsons

This commit is contained in:
Prox 2026-03-06 17:48:16 +02:00
parent 28eabc270e
commit 6704ecf74e
7 changed files with 132 additions and 34 deletions

View File

@ -3,36 +3,79 @@ name: Dry Run
on: on:
pull_request: pull_request:
paths: paths:
- "netbird.json" - "state/*.json"
jobs: jobs:
dry-run: detect:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
envs: ${{ steps.changed.outputs.envs }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed environments
id: changed
run: |
FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- 'state/*.json')
ENVS="[]"
for f in $FILES; do
ENV=$(basename "$f" .json)
ENVS=$(echo "$ENVS" | jq -c ". + [\"$ENV\"]")
done
echo "envs=$ENVS" >> "$GITHUB_OUTPUT"
echo "Changed environments: $ENVS"
dry-run:
needs: detect
runs-on: ubuntu-latest
if: needs.detect.outputs.envs != '[]'
strategy:
matrix:
env: ${{ fromJson(needs.detect.outputs.envs) }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Resolve environment secrets
id: env
run: |
ENV_UPPER=$(echo "${{ matrix.env }}" | tr '[:lower:]-' '[:upper:]_')
echo "token_key=${ENV_UPPER}_RECONCILER_TOKEN" >> "$GITHUB_OUTPUT"
echo "url_key=${ENV_UPPER}_RECONCILER_URL" >> "$GITHUB_OUTPUT"
- name: Run dry-run reconcile - name: Run dry-run reconcile
id: plan id: plan
env:
RECONCILER_TOKEN: ${{ secrets[steps.env.outputs.token_key] }}
RECONCILER_URL: ${{ secrets[steps.env.outputs.url_key] }}
run: | run: |
if [ -z "$RECONCILER_URL" ] || [ -z "$RECONCILER_TOKEN" ]; then
echo "No secrets configured for environment '${{ matrix.env }}' — skipping"
echo "response={}" >> "$GITHUB_OUTPUT"
exit 0
fi
RESPONSE=$(curl -sf \ RESPONSE=$(curl -sf \
-X POST \ -X POST \
-H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \ -H "Authorization: Bearer ${RECONCILER_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @netbird.json \ -d @state/${{ matrix.env }}.json \
"${{ secrets.RECONCILER_URL }}/reconcile?dry_run=true") "${RECONCILER_URL}/reconcile?dry_run=true")
echo "response<<EOF" >> "$GITHUB_OUTPUT" echo "response<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESPONSE" >> "$GITHUB_OUTPUT" echo "$RESPONSE" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Format plan as markdown - name: Format plan as markdown
id: format id: format
if: steps.plan.outputs.response != '{}'
run: | run: |
cat <<'SCRIPT' > format.py cat <<'SCRIPT' > format.py
import json, sys import json, sys
data = json.loads(sys.stdin.read()) data = json.loads(sys.stdin.read())
ops = data.get("operations", []) ops = data.get("operations", [])
summary = data.get("summary", {}) summary = data.get("summary", {})
lines = ["## NetBird Reconciliation Plan\n"] env = sys.argv[1]
lines = [f"## Reconciliation Plan: `{env}`\n"]
if not ops: if not ops:
lines.append("No changes detected.\n") lines.append("No changes detected.\n")
else: else:
@ -45,12 +88,13 @@ jobs:
lines.append(f"**Summary:** {s.get('created',0)} create, {s.get('updated',0)} update, {s.get('deleted',0)} delete") lines.append(f"**Summary:** {s.get('created',0)} create, {s.get('updated',0)} update, {s.get('deleted',0)} delete")
print("\n".join(lines)) print("\n".join(lines))
SCRIPT SCRIPT
COMMENT=$(echo '${{ steps.plan.outputs.response }}' | python3 format.py) COMMENT=$(echo '${{ steps.plan.outputs.response }}' | python3 format.py "${{ matrix.env }}")
echo "comment<<EOF" >> "$GITHUB_OUTPUT" echo "comment<<EOF" >> "$GITHUB_OUTPUT"
echo "$COMMENT" >> "$GITHUB_OUTPUT" echo "$COMMENT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
- name: Post PR comment - name: Post PR comment
if: steps.plan.outputs.response != '{}'
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |

View File

@ -5,53 +5,99 @@ on:
branches: branches:
- main - main
paths: paths:
- "netbird.json" - "state/*.json"
jobs: jobs:
reconcile: detect:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
envs: ${{ steps.changed.outputs.envs }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed environments
id: changed
run: |
FILES=$(git diff --name-only HEAD~1 HEAD -- 'state/*.json')
ENVS="[]"
for f in $FILES; do
ENV=$(basename "$f" .json)
ENVS=$(echo "$ENVS" | jq -c ". + [\"$ENV\"]")
done
echo "envs=$ENVS" >> "$GITHUB_OUTPUT"
echo "Changed environments: $ENVS"
reconcile:
needs: detect
runs-on: ubuntu-latest
if: needs.detect.outputs.envs != '[]'
strategy:
matrix:
env: ${{ fromJson(needs.detect.outputs.envs) }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Sync events - name: Resolve environment secrets
id: env
run: | run: |
ENV_UPPER=$(echo "${{ matrix.env }}" | tr '[:lower:]-' '[:upper:]_')
echo "token_key=${ENV_UPPER}_RECONCILER_TOKEN" >> "$GITHUB_OUTPUT"
echo "url_key=${ENV_UPPER}_RECONCILER_URL" >> "$GITHUB_OUTPUT"
echo "age_key=${ENV_UPPER}_AGE_PUBLIC_KEY" >> "$GITHUB_OUTPUT"
- name: Sync events
env:
RECONCILER_TOKEN: ${{ secrets[steps.env.outputs.token_key] }}
RECONCILER_URL: ${{ secrets[steps.env.outputs.url_key] }}
run: |
if [ -z "$RECONCILER_URL" ] || [ -z "$RECONCILER_TOKEN" ]; then
echo "No secrets configured for environment '${{ matrix.env }}' — skipping"
exit 0
fi
curl -sf \ curl -sf \
-X POST \ -X POST \
-H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \ -H "Authorization: Bearer ${RECONCILER_TOKEN}" \
"${{ secrets.RECONCILER_URL }}/sync-events" "${RECONCILER_URL}/sync-events"
- name: Pull latest (poller may have committed) - name: Pull latest (poller may have committed)
run: git pull --rebase run: git pull --rebase
- name: Apply reconcile - name: Apply reconcile
id: reconcile id: reconcile
env:
RECONCILER_TOKEN: ${{ secrets[steps.env.outputs.token_key] }}
RECONCILER_URL: ${{ secrets[steps.env.outputs.url_key] }}
run: | run: |
RESPONSE=$(curl -sf \ RESPONSE=$(curl -sf \
-X POST \ -X POST \
-H "Authorization: Bearer ${{ secrets.RECONCILER_TOKEN }}" \ -H "Authorization: Bearer ${RECONCILER_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @netbird.json \ -d @state/${{ matrix.env }}.json \
"${{ secrets.RECONCILER_URL }}/reconcile") "${RECONCILER_URL}/reconcile")
echo "response<<EOF" >> "$GITHUB_OUTPUT" echo "response<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESPONSE" >> "$GITHUB_OUTPUT" echo "$RESPONSE" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"
STATUS=$(echo "$RESPONSE" | jq -r '.status') STATUS=$(echo "$RESPONSE" | jq -r '.status')
if [ "$STATUS" = "error" ]; then if [ "$STATUS" = "error" ]; then
echo "Reconcile failed" echo "Reconcile failed for ${{ matrix.env }}"
echo "$RESPONSE" | jq . echo "$RESPONSE" | jq .
exit 1 exit 1
fi fi
- name: Encrypt and upload setup keys - name: Encrypt and upload setup keys
if: success() if: success()
env:
AGE_PUBLIC_KEY: ${{ secrets[steps.env.outputs.age_key] }}
run: | run: |
KEYS=$(echo '${{ steps.reconcile.outputs.response }}' | jq -r '.created_keys // empty') KEYS=$(echo '${{ steps.reconcile.outputs.response }}' | jq -r '.created_keys // empty')
if [ -n "$KEYS" ] && [ "$KEYS" != "{}" ] && [ "$KEYS" != "null" ]; then if [ -n "$KEYS" ] && [ "$KEYS" != "{}" ] && [ "$KEYS" != "null" ] && [ -n "$AGE_PUBLIC_KEY" ]; then
echo "$KEYS" | age -r "${{ secrets.AGE_PUBLIC_KEY }}" -o setup-keys.age echo "$KEYS" | age -r "$AGE_PUBLIC_KEY" -o setup-keys-${{ matrix.env }}.age
echo "Setup keys encrypted to setup-keys.age" echo "Setup keys for ${{ matrix.env }} encrypted"
else else
echo "No new keys created" echo "No new keys created for ${{ matrix.env }}"
exit 0 exit 0
fi fi
@ -59,6 +105,6 @@ jobs:
if: success() if: success()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: setup-keys name: setup-keys-${{ matrix.env }}
path: setup-keys.age path: setup-keys-${{ matrix.env }}.age
if-no-files-found: ignore if-no-files-found: ignore

View File

@ -5,13 +5,13 @@ testing of the NetBird GitOps reconciler.
## Stack overview ## Stack overview
| Component | Purpose | | Component | Purpose |
| --------------- | ------------------------------------------- | | ------------ | ------------------------------------------- |
| Caddy | TLS termination, reverse proxy | | Caddy | TLS termination, reverse proxy |
| NetBird | Management, Signal, Relay, Dashboard, TURN | | NetBird | Management, Signal, Relay, Dashboard, TURN |
| Reconciler | Declarative config → NetBird API reconciler | | Reconciler | Declarative config → NetBird API reconciler |
| Gitea | Git server for GitOps source-of-truth | | Gitea | Git server for GitOps source-of-truth |
| Gitea Runner | Executes CI workflows (Actions) | | Gitea Runner | Executes CI workflows (Actions) |
All services run as Docker containers on a single VPS, connected via a `netbird` All services run as Docker containers on a single VPS, connected via a `netbird`
Docker bridge network. Caddy handles ACME certificates automatically. Docker bridge network. Caddy handles ACME certificates automatically.
@ -69,7 +69,8 @@ idempotent.
1. Open `https://gitea.vps-a.networkmonitor.cc` and complete the install wizard 1. Open `https://gitea.vps-a.networkmonitor.cc` and complete the install wizard
2. Create an admin account (user: `blastpilot`) 2. Create an admin account (user: `blastpilot`)
3. Create org `BlastPilot` and repo `netbird-gitops` 3. Create org `BlastPilot` and repo `netbird-gitops`
4. Generate a Gitea API token (**Settings → Applications**) → `vault_gitea_token` 4. Generate a Gitea API token (**Settings → Applications**) →
`vault_gitea_token`
5. Go to **Site Administration → Actions → Runners** → copy runner registration 5. Go to **Site Administration → Actions → Runners** → copy runner registration
token → `vault_gitea_admin_password` and `vault_gitea_runner_token` token → `vault_gitea_admin_password` and `vault_gitea_runner_token`
@ -80,6 +81,7 @@ ansible-playbook -i inventory.yml playbook.yml
``` ```
This run will: This run will:
- Start the reconciler with a valid NetBird API token - Start the reconciler with a valid NetBird API token
- Register and start the Gitea Actions runner - Register and start the Gitea Actions runner
- Wire the reconciler to poll Gitea for `netbird.json` changes - Wire the reconciler to poll Gitea for `netbird.json` changes
@ -93,6 +95,7 @@ git push poc main
``` ```
Then configure Gitea repo secrets (Settings → Actions → Secrets): Then configure Gitea repo secrets (Settings → Actions → Secrets):
- `RECONCILER_TOKEN` — the reconciler bearer token - `RECONCILER_TOKEN` — the reconciler bearer token
- `RECONCILER_URL``https://vps-a.networkmonitor.cc/reconciler` - `RECONCILER_URL``https://vps-a.networkmonitor.cc/reconciler`
- `GITEA_TOKEN` — same Gitea API token - `GITEA_TOKEN` — same Gitea API token

View File

@ -5,6 +5,7 @@ GITEA_ENABLED={{ gitea_enabled }}
GITEA_URL=http://gitea:{{ gitea_http_port }} GITEA_URL=http://gitea:{{ gitea_http_port }}
GITEA_TOKEN={{ vault_gitea_token }} GITEA_TOKEN={{ vault_gitea_token }}
GITEA_REPO={{ gitea_org_name }}/{{ gitea_repo_name }} GITEA_REPO={{ gitea_org_name }}/{{ gitea_repo_name }}
STATE_FILE=state/test.json
POLL_INTERVAL_SECONDS=30 POLL_INTERVAL_SECONDS=30
PORT={{ reconciler_port }} PORT={{ reconciler_port }}
DATA_DIR=/data DATA_DIR=/data

View File

@ -11,6 +11,7 @@ const BaseSchema = z.object({
pollIntervalSeconds: z.coerce.number().int().positive().default(30), pollIntervalSeconds: z.coerce.number().int().positive().default(30),
port: z.coerce.number().int().positive().default(8080), port: z.coerce.number().int().positive().default(8080),
dataDir: z.string().default("/data"), dataDir: z.string().default("/data"),
stateFile: z.string().default("netbird.json"),
}); });
const GiteaFieldsSchema = z.object({ const GiteaFieldsSchema = z.object({
@ -67,5 +68,6 @@ export function loadConfig(): Config {
pollIntervalSeconds: Deno.env.get("POLL_INTERVAL_SECONDS"), pollIntervalSeconds: Deno.env.get("POLL_INTERVAL_SECONDS"),
port: Deno.env.get("PORT"), port: Deno.env.get("PORT"),
dataDir: Deno.env.get("DATA_DIR"), dataDir: Deno.env.get("DATA_DIR"),
stateFile: Deno.env.get("STATE_FILE"),
}); });
} }

View File

@ -100,6 +100,7 @@ const MOCK_CONFIG: Config = {
pollIntervalSeconds: 30, pollIntervalSeconds: 30,
port: 8080, port: 8080,
dataDir: "/data", dataDir: "/data",
stateFile: "netbird.json",
}; };
/** Desired state with one group and one setup key referencing it. */ /** Desired state with one group and one setup key referencing it. */
@ -156,6 +157,7 @@ const STANDALONE_CONFIG: Config = {
pollIntervalSeconds: 30, pollIntervalSeconds: 30,
port: 8080, port: 8080,
dataDir: "/data", dataDir: "/data",
stateFile: "netbird.json",
}; };
function buildStandaloneHandler(calls: ApiCall[]) { function buildStandaloneHandler(calls: ApiCall[]) {

View File

@ -63,7 +63,7 @@ async function pollOnceGitea(
const pollerState = await loadPollerState(config.dataDir); const pollerState = await loadPollerState(config.dataDir);
// Fetch current desired state from Gitea (main branch) // Fetch current desired state from Gitea (main branch)
const file = await gitea.getFileContent("netbird.json", "main"); const file = await gitea.getFileContent(config.stateFile, "main");
const desired: DesiredState = DesiredStateSchema.parse( const desired: DesiredState = DesiredStateSchema.parse(
JSON.parse(file.content), JSON.parse(file.content),
); );
@ -255,7 +255,7 @@ async function processEnrollment(
fileSha: string, fileSha: string,
onCommit: (newSha: string, newDesired: DesiredState) => void, onCommit: (newSha: string, newDesired: DesiredState) => void,
): Promise<void> { ): Promise<void> {
const { netbird } = ctx; const { config, netbird } = ctx;
const { setupKeyName, peerId, peerHostname } = enrollment; const { setupKeyName, peerId, peerHostname } = enrollment;
// Rename the peer to match the setup key name // Rename the peer to match the setup key name
@ -283,7 +283,7 @@ async function processEnrollment(
try { try {
await gitea.updateFile( await gitea.updateFile(
"netbird.json", config.stateFile,
content, content,
fileSha, fileSha,
`chore: mark ${setupKeyName} as enrolled`, `chore: mark ${setupKeyName} as enrolled`,
@ -293,7 +293,7 @@ async function processEnrollment(
// Fetch the new SHA for subsequent commits in this poll cycle. // Fetch the new SHA for subsequent commits in this poll cycle.
// The updateFile response from Gitea doesn't return the new blob SHA // The updateFile response from Gitea doesn't return the new blob SHA
// in a convenient form, so we re-read it. // in a convenient form, so we re-read it.
const freshFile = await gitea.getFileContent("netbird.json", "main"); const freshFile = await gitea.getFileContent(config.stateFile, "main");
onCommit(freshFile.sha, updated); onCommit(freshFile.sha, updated);
console.log(JSON.stringify({ console.log(JSON.stringify({