From 595cdec41ecc12eb7ec24e8bd9347bb4cbff2881 Mon Sep 17 00:00:00 2001 From: IceDBorn Date: Mon, 31 Aug 2026 06:00:50 +0300 Subject: [PATCH 1/4] feat: build-then-merge gate + S3/CloudFront binary cache --- .github/workflows/auto-update.yml | 220 +++++++------ .github/workflows/heal-cache.yml | 53 +++ .github/workflows/module-update.yml | 217 +++++++++++++ .github/workflows/nix-build.yml | 482 ++++++++++++++++++++++++++-- .gitignore | 2 + DEPLOY.md | 146 ++------- build.sh | 181 +++++++++-- config/05-common.toml | 6 + flake.nix | 59 +--- nix-public.pem | 2 +- scripts/tracked-revs.py | 105 ++++++ stack/conf/Caddyfile | 21 -- stack/conf/nginx.conf | 82 ----- stack/conf/server.toml | 36 --- stack/supervisor.sh | 45 --- state/tracked-inputs.json | 2 +- 16 files changed, 1150 insertions(+), 509 deletions(-) create mode 100644 .github/workflows/heal-cache.yml create mode 100644 .github/workflows/module-update.yml create mode 100644 scripts/tracked-revs.py delete mode 100644 stack/conf/Caddyfile delete mode 100644 stack/conf/nginx.conf delete mode 100644 stack/conf/server.toml delete mode 100644 stack/supervisor.sh diff --git a/.github/workflows/auto-update.yml b/.github/workflows/auto-update.yml index 6c039db..ef697a1 100644 --- a/.github/workflows/auto-update.yml +++ b/.github/workflows/auto-update.yml @@ -1,5 +1,12 @@ name: Auto Update flake.lock +# PRs, never direct commits: each change source (nixpkgs bump, each tracked input) gets +# its own branch + PR. nix-build.yml builds the PR, pushes to the cache, and merges into +# main only on success, so main always describes a fully cached state. +# +# Branch handling is idempotent: identical content on top of the same main commit is a +# no-op, so a 30-min run with nothing new never re-queues a build. + on: schedule: # GitHub may delay or skip scheduled runs, and they only fire on the default branch. @@ -45,6 +52,7 @@ jobs: run: | changed_input_names=() changed_input_details=() + revs_json='{}' while IFS= read -r name; do configs_json=$(TRACKED_NAME="$name" python3 -c " @@ -72,37 +80,14 @@ jobs: ) || { rm -rf "$work"; continue; } if [ -f "$work/build/.state/flake.lock" ]; then - new_rev="" - # A tracked input is the bare LEAF node (`nodes.`) inside its module's - # sub-flake. Exact match wins; the endswith fallback handles legacy locks. - # A `follows` input is an array we cannot resolve to a revision, so skip it - # instead of reading the sub-root's rev. `select(. != null)` keeps a miss - # from leaking the literal "null". - lookup=' - [ .nodes | to_entries[] | select(.key == $name) | .key ][0] as $exact - | [ .nodes | to_entries[] | select(.key | endswith("-" + $name)) | .key ][0] as $sub - | (if $exact != null then $exact else $sub end) as $key - | if $key == null then null - else (.nodes[$key].inputs[$name]?) as $entry - | if ($entry | type) == "string" then $entry - elif ($entry | type) != "array" then $key - else null - end - end - | select(. != null) - ' - input_key=$(jq -r --arg name "$name" "$lookup" "$work/build/.state/flake.lock") - - if [ -n "$input_key" ]; then - new_rev=$(jq -r --arg k "$input_key" '.nodes[$k].locked.rev // .nodes[$k].original.rev // ""' "$work/build/.state/flake.lock") - if [ -z "$new_rev" ]; then - echo "warning: found input '$name' at node '$input_key' but it has no revision" >&2 - fi - else - echo "warning: could not locate tracked input '$name' in build/.state/flake.lock" >&2 - fi + # Shared resolution logic (leaf-node lookup, follows skip) lives in + # scripts/tracked-revs.py — nix-build.yml reuses it to pin built revs. + resolved=$(python3 scripts/tracked-revs.py extract --lock "$work/build/.state/flake.lock" --names "$name") + input_key=$(jq -r --arg n "$name" '.[$n].key // ""' <<<"$resolved") + new_rev=$(jq -r --arg n "$name" '.[$n].rev // ""' <<<"$resolved") if [ -n "$new_rev" ]; then + # The working file stays pristine; it is read, never mutated. old_rev=$(jq -r --arg k "$input_key" '.[$k] // ""' state/tracked-inputs.json 2>/dev/null) if [ "$new_rev" != "$old_rev" ]; then @@ -112,9 +97,10 @@ jobs: else changed_input_details+=("$name: ${new_rev:0:12}") fi - jq --arg k "$input_key" --arg v "$new_rev" '.[$k] = $v' \ - state/tracked-inputs.json > state/tracked-inputs.tmp && \ - mv state/tracked-inputs.tmp state/tracked-inputs.json + # Keyed by name: an input with several configs keeps only its latest rev. + if [ -n "$old_rev" ]; then old_json="\"$old_rev\""; else old_json=null; fi + revs_json=$(jq -c --arg n "$name" --arg k "$input_key" --arg r "$new_rev" --argjson o "$old_json" \ + '. + {($n): {name:$n, key:$k, rev:$r, old:$o}}' <<<"$revs_json") fi fi fi @@ -131,12 +117,16 @@ jobs: if [ ${#changed_input_names[@]} -gt 0 ]; then echo "changed=true" >>"$GITHUB_OUTPUT" - echo "names=$(IFS=,; echo "${changed_input_names[*]}")" >>"$GITHUB_OUTPUT" { echo "details<>"$GITHUB_OUTPUT" + { + echo "revs<>"$GITHUB_OUTPUT" else echo "changed=false" >>"$GITHUB_OUTPUT" fi @@ -151,81 +141,111 @@ jobs: owner: IceDOS repositories: cache-server - - name: Commit and push via API - id: commit + - name: Upsert update branches and pull requests if: steps.update.outputs.changed == 'true' || steps.inputs.outputs.changed == 'true' uses: actions/github-script@v7 + env: + REVS: ${{ steps.inputs.outputs.revs }} with: github-token: ${{ steps.app-token.outputs.token }} script: | - const fs = require('fs'); const { owner, repo } = context.repo; + const fs = require('fs'); - function truncate(s) { return s.length <= 72 ? s : s.slice(0, 69) + '...'; } + const mainSha = (await github.rest.git.getRef({ owner, repo, ref: 'heads/main' })).data.object.sha; - const nixChanged = '${{ steps.update.outputs.changed }}' === 'true'; - const inputsChanged = '${{ steps.inputs.outputs.changed }}' === 'true'; - let subject, body = ''; + async function blobShaOf(content) { + return (await github.rest.git.createBlob({ owner, repo, content, encoding: 'utf-8' })).data.sha; + } - if (nixChanged && !inputsChanged) { - subject = `update(nixpkgs): ${{ steps.update.outputs.old }} -> ${{ steps.update.outputs.short }}`; - } else if (inputsChanged && !nixChanged) { - subject = truncate(`update(inputs): ${{ steps.inputs.outputs.names }}`); - body = `${{ steps.inputs.outputs.details }}`; - } else { - subject = 'update: nixpkgs, inputs'; - body = `nixpkgs: ${{ steps.update.outputs.old }} -> ${{ steps.update.outputs.short }}\n${{ steps.inputs.outputs.details }}`; + // Branch head/parent plus the blob sha of `path` at head, or null when absent. + async function branchState(branch, path) { + try { + const ref = await github.rest.git.getRef({ owner, repo, ref: `heads/${branch}` }); + const commit = await github.rest.git.getCommit({ owner, repo, commit_sha: ref.data.object.sha }); + let blob = null; + try { + const file = await github.rest.repos.getContent({ owner, repo, path, ref: `heads/${branch}` }); + if (!Array.isArray(file.data)) blob = file.data.sha; + } catch (e) { + if (e.status !== 404) throw e; + } + return { head: ref.data.object.sha, parent: commit.data.parents[0]?.sha, blob }; + } catch (e) { + if (e.status === 404) return null; + throw e; + } } - const commitMessage = body ? `${subject}\n\n${body}` : subject; - - const ref = await github.rest.git.getRef({ owner, repo, ref: 'heads/main' }); - const currentSha = ref.data.object.sha; - - const flakeLock = fs.readFileSync('flake.lock', 'utf8'); - const trackedInputs = fs.readFileSync('state/tracked-inputs.json', 'utf8'); - - const [b1, b2] = await Promise.all([ - github.rest.git.createBlob({ owner, repo, content: flakeLock, encoding: 'utf-8' }), - github.rest.git.createBlob({ owner, repo, content: trackedInputs, encoding: 'utf-8' }), - ]); - - const tree = await github.rest.git.createTree({ - owner, repo, - base_tree: currentSha, - tree: [ - { path: 'flake.lock', mode: '100644', type: 'blob', sha: b1.data.sha }, - { path: 'state/tracked-inputs.json', mode: '100644', type: 'blob', sha: b2.data.sha }, - ], - }); - - const commit = await github.rest.git.createCommit({ - owner, repo, - message: commitMessage, - tree: tree.data.sha, - parents: [currentSha], - }); - - // No force: only ever fast-forward, so a commit that lands between the - // getRef above and this push is never clobbered (the step fails instead). - await github.rest.git.updateRef({ - owner, repo, - ref: 'heads/main', - sha: commit.data.sha, - }); - - core.info(`Committed: ${subject}`); - core.setOutput('sha', commit.data.sha); - - - name: Trigger build - if: steps.commit.outcome == 'success' - uses: actions/github-script@v7 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'nix-build.yml', - ref: 'main', - }); + async function upsert({ branch, path, content, message, title, body }) { + const blobSha = await blobShaOf(content); + const cur = await branchState(branch, path); + + // Identical content on top of the same main commit: the open PR already + // triggers a build, and another force-push would re-queue it for nothing. + if (cur && cur.blob === blobSha && cur.parent === mainSha) { + core.info(`${branch}: already up to date`); + return; + } + + const tree = await github.rest.git.createTree({ + owner, repo, base_tree: mainSha, + tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }], + }); + const commit = await github.rest.git.createCommit({ + owner, repo, message, tree: tree.data.sha, parents: [mainSha], + }); + + if (cur) { + await github.rest.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, force: true }); + } else { + await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: commit.data.sha }); + } + + const head = `${owner}:${branch}`; + const prs = (await github.rest.pulls.list({ owner, repo, head, state: 'all', per_page: 20 })).data; + const open = prs.find(p => p.state === 'open'); + if (open) { + if (open.title !== title || open.body !== body) { + await github.rest.pulls.update({ owner, repo, pull_number: open.number, title, body }); + } + core.info(`${branch}: PR #${open.number} refreshed`); + } else { + // Reopen a closed-but-unmerged PR; a merged one can never reopen. + const closed = prs.find(p => p.state === 'closed' && !p.merged_at); + if (closed) { + await github.rest.pulls.update({ owner, repo, pull_number: closed.number, state: 'open', title, body }); + core.info(`${branch}: reopened PR #${closed.number}`); + } else { + const pr = await github.rest.pulls.create({ owner, repo, head, base: 'main', title, body }); + core.info(`${branch}: opened PR #${pr.data.number}`); + } + } + } + + if ('${{ steps.update.outputs.changed }}' === 'true') { + const old = '${{ steps.update.outputs.old }}'; + const short = '${{ steps.update.outputs.short }}'; + await upsert({ + branch: 'update/nixpkgs', + path: 'flake.lock', + content: fs.readFileSync('flake.lock', 'utf8'), + message: `update(nixpkgs): ${old} -> ${short}`, + title: `update(nixpkgs): ${old} -> ${short}`, + body: `nixpkgs: ${old} -> ${short}`, + }); + } + + const base = JSON.parse(fs.readFileSync('state/tracked-inputs.json', 'utf8')); + for (const r of Object.values(JSON.parse(process.env.REVS || '{}'))) { + // Spread-copy per input: each PR must carry only its own rev change. + const detail = r.old ? `${r.name}: ${r.old.slice(0, 12)} -> ${r.rev.slice(0, 12)}` : `${r.name}: ${r.rev.slice(0, 12)}`; + await upsert({ + branch: `update/input-${r.name}`, + path: 'state/tracked-inputs.json', + content: JSON.stringify({ ...base, [r.key]: r.rev }, null, 2) + '\n', + message: `update(inputs): ${r.name}\n\n${detail}`, + title: `update(inputs): ${r.name}`, + body: detail, + }); + } diff --git a/.github/workflows/heal-cache.yml b/.github/workflows/heal-cache.yml new file mode 100644 index 0000000..646fa20 --- /dev/null +++ b/.github/workflows/heal-cache.yml @@ -0,0 +1,53 @@ +name: Heal Nix Cache + +# The S3 lifecycle rule expires NARs after 35 days; current closures must keep +# working. This job re-pushes every config's closure from its last-resolved +# lock — nix copy only uploads paths the lifecycle actually expired. + +on: + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: heal-cache + cancel-in-progress: false + +jobs: + heal: + runs-on: ubuntu-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + - uses: wimpysworld/nothing-but-nix@v10 + - uses: nixbuild/nix-quick-install-action@v30 + with: + nix_conf: | + experimental-features = nix-command flakes + nix-path = nixpkgs=channel:nixos-unstable + - name: Install AWS CLI + run: nix profile install nixpkgs#awscli2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Download config locks + run: aws s3 sync "s3://icedos-nix-cache-fyi/locks/" build/locks/ --region eu-central-1 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Re-push current closures + run: nix run .#build + env: + ICEDOS_HEAL: 1 + ICEDOS_SUBSTITUTER: ${{ vars.ICEDOS_SUBSTITUTER }} + ICEDOS_SIGNING_KEY: ${{ secrets.ICEDOS_SIGNING_KEY }} + ICEDOS_S3_URL: "s3://icedos-nix-cache-fyi?region=eu-central-1" + ICEDOS_S3_BUCKET: icedos-nix-cache-fyi + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: eu-central-1 + AWS_DEFAULT_REGION: eu-central-1 + ICEDOS_WORKBASE: /mnt/icedos-work-heal diff --git a/.github/workflows/module-update.yml b/.github/workflows/module-update.yml new file mode 100644 index 0000000..4ed3680 --- /dev/null +++ b/.github/workflows/module-update.yml @@ -0,0 +1,217 @@ +name: Module Update + +# Shared updater for module repos: detects an upstream pin change, opens/refreshes +# one PR per module (never a direct push to main), and dispatches cache-server's +# nix-build in external mode. cache-server builds every config against the PR head +# and rebase-merges the PR natively on success; on failure the PR stays open with +# one status comment. An open PR is re-dispatched on every cycle even without a +# new pin: a green rebuild is cheap (cache-hit skip) and completes a merge that a +# moved main had deferred. + +on: + workflow_call: + inputs: + module: + description: "Module name: branch update/, title update(): ..." + type: string + required: true + paths: + description: "Space-separated paths this module owns (diff detection + commit)" + type: string + required: true + command: + description: "Update command (sources core/lib/update-lib.sh)" + type: string + required: true + version-file: + description: "Optional json file whose version field feeds the old -> new title" + type: string + default: '' + version-jq: + description: "jq filter for the version field" + type: string + default: '.version' + title-command: + description: "Optional shell snippet printing a custom title suffix" + type: string + default: '' + +permissions: {} + +jobs: + update: + runs-on: ubuntu-latest + concurrency: + group: module-update-${{ inputs.module }} + cancel-in-progress: false + steps: + # main is ruleset-protected, and github-actions[bot] holds no repository role, + # so the App is the bypass actor: checkout credentials, branch push, PR upsert + # and the cache-server dispatch all run on its token. + - name: Mint app token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.DISPATCH_APP_ID }} + private-key: ${{ secrets.DISPATCH_APP_PRIVATE_KEY }} + owner: IceDOS + repositories: | + ${{ github.event.repository.name }} + cache-server + permission-contents: write + permission-pull-requests: write + permission-actions: write + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + # update.sh sources core/lib/update-lib.sh. CI only checks out this repo, so core + # is fetched alongside it; the script falls back to a sibling checkout when run + # from a local IceDOS tree, so .icedos-core never exists outside CI. + - uses: actions/checkout@v4 + with: + repository: IceDOS/core + path: .icedos-core + + - uses: nixbuild/nix-quick-install-action@v30 + with: + nix_conf: | + experimental-features = nix-command flakes + nix-path = nixpkgs=channel:nixos-unstable + + - name: Read current version + id: before + if: inputs.version-file != '' + run: echo "version=$(jq -r '${{ inputs.version-jq }}' '${{ inputs.version-file }}' 2>/dev/null || true)" >>"$GITHUB_OUTPUT" + + - name: Run update script + env: + GITHUB_TOKEN: ${{ github.token }} + run: ${{ inputs.command }} + + - name: Commit update on top of latest main + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git status --porcelain -- ${{ inputs.paths }} | grep -q .; then + echo "changed=true" >>"$GITHUB_OUTPUT" + else + echo "changed=false" >>"$GITHUB_OUTPUT" + echo "nothing to do" + exit 0 + fi + + # Title: custom snippet wins, else old -> new from version-file. + suffix="" + if [ -n "$TITLE_CMD" ]; then + suffix=$(eval "$TITLE_CMD") + elif [ -n "$VERSION_FILE" ]; then + old="${{ steps.before.outputs.version }}" + new=$(jq -r "$VERSION_JQ" "$VERSION_FILE") + suffix="${old:+$old -> }$new" + fi + msg="update(${{ inputs.module }})${suffix:+: $suffix}" + echo "title=$msg" >>"$GITHUB_OUTPUT" + + # The event checkout may be behind (a sibling updater landed after the + # schedule fired). Each module owns disjoint paths, so this rebase cannot + # conflict; a conflict means something unexpected and must stop the run. + git fetch --unshallow origin main 2>/dev/null || git fetch origin main + git add -- ${{ inputs.paths }} + git commit -m "$msg" + git rebase origin/main + + # Identical content on top of the same main commit: the open PR already + # covers it, and another force-push would re-queue a build for nothing. + if git fetch origin "refs/heads/update/${{ inputs.module }}" 2>/dev/null; then + existing_tree=$(git rev-parse "FETCH_HEAD^{tree}") + existing_parent=$(git rev-parse "FETCH_HEAD^") + if [ "$existing_tree" = "$(git rev-parse HEAD^{tree})" ] && [ "$existing_parent" = "$(git rev-parse origin/main)" ]; then + echo "update/${{ inputs.module }}: already up to date" + echo "head=$(git rev-parse FETCH_HEAD)" >>"$GITHUB_OUTPUT" + echo "title=$(git log -1 --format=%s FETCH_HEAD)" >>"$GITHUB_OUTPUT" + exit 0 + fi + fi + + git push --force origin "HEAD:refs/heads/update/${{ inputs.module }}" + echo "head=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + env: + TITLE_CMD: ${{ inputs.title-command }} + VERSION_FILE: ${{ inputs.version-file }} + VERSION_JQ: ${{ inputs.version-jq }} + + # Runs even when nothing changed: an open PR from a previously failed or + # deferred build must be re-dispatched, otherwise it would never retry. + - name: Ensure module PR + id: pr + if: success() + uses: actions/github-script@v7 + # Script reads CHANGED/PR_TITLE/HEAD via process.env; without this block + # they are undefined and every run looks like "nothing changed". + env: + CHANGED: ${{ steps.commit.outputs.changed }} + PR_TITLE: ${{ steps.commit.outputs.title }} + HEAD: ${{ steps.commit.outputs.head }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const branch = 'update/${{ inputs.module }}'; + const headRef = `${owner}:${branch}`; + const changed = process.env.CHANGED === 'true'; + + const prs = (await github.rest.pulls.list({ owner, repo, head: headRef, state: 'all', per_page: 20 })).data; + let pr = prs.find(p => p.state === 'open'); + + if (changed) { + const title = process.env.PR_TITLE; + const body = title; + if (pr) { + if (pr.title !== title) { + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, title, body }); + } + core.info(`update/${branch}: PR #${pr.number} refreshed`); + } else { + // Reopen a closed-but-unmerged PR; a merged one can never reopen. + const closed = prs.find(p => p.state === 'closed' && !p.merged_at); + if (closed) { + await github.rest.pulls.update({ owner, repo, pull_number: closed.number, state: 'open', title, body }); + pr = (await github.rest.pulls.get({ owner, repo, pull_number: closed.number })).data; + core.info(`update/${branch}: reopened PR #${pr.number}`); + } else { + pr = (await github.rest.pulls.create({ owner, repo, head: headRef, base: 'main', title, body })).data; + core.info(`update/${branch}: opened PR #${pr.number}`); + } + } + core.setOutput('number', String(pr.number)); + core.setOutput('head', process.env.HEAD); + } else if (pr) { + // No new pin: re-dispatch the open PR so a failed/deferred build retries. + core.setOutput('number', String(pr.number)); + core.setOutput('head', pr.head.sha); + } + + - name: Dispatch cache-server external build + if: steps.pr.outputs.number != '' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'IceDOS', + repo: 'cache-server', + workflow_id: 'nix-build.yml', + ref: 'main', + inputs: { + 'source-repo': context.repo.repo, + 'source-pr': process.env.PR_NUMBER, + 'source-head': process.env.PR_HEAD, + }, + }); + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + PR_HEAD: ${{ steps.pr.outputs.head }} diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index c9f8884..4672178 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -1,10 +1,21 @@ name: Nix Build -# Dispatch-only: auto-update.yml after a lock bump, and the mesa / shadps4 updaters in -# hardware / apps. A push trigger would double-build on top of those dispatches. +# PRs are the only way updates reach main: auto-update.yml opens one PR per change +# source, this workflow builds and pushes it, and the merge step below runs only after +# every config is in the cache. A push trigger would double-build on top of that. # -# `force` repairs the cache: a config with a present toplevel but an incomplete closure -# would otherwise be skipped forever. +# The merge carries the input revs the build ACTUALLY resolved (detection and build +# resolve at different times): the branch is rewritten as one commit holding them, then +# rebase-merged — one run, one commit, native Merged state. workflow_dispatch on main is +# the repair path: `force` rebuilds a config whose toplevel is present but whose closure +# is incomplete (it would otherwise be skipped forever). +# +# External mode (source-repo/source-pr/source-head): validates a module repo's PR by +# pinning its head in every config, then rebase-merges that PR on success. This is what +# gates module repo mains (apps/hardware/tweaks) behind a green build. +# +# Concurrency is a single global queue: builds share the runner store cache, and a +# cancel would kill an in-progress kernel compile. on: workflow_dispatch: inputs: @@ -12,20 +23,49 @@ on: description: "Rebuild and re-push every config even if its closure is already cached" type: boolean default: false + # External mode: a module repo's updater opened a PR; this run validates its + # head against every config and merges the PR natively on success. + source-repo: + description: "Module repo whose PR this run validates (e.g. apps)" + type: string + default: '' + source-pr: + description: "PR number in the source repo" + type: string + default: '' + source-head: + description: "PR head sha being validated" + type: string + default: '' + pull_request: + types: [opened, synchronize, reopened] + paths: + - flake.lock + - config/** + - state/tracked-inputs.json permissions: - actions: write + contents: write + pull-requests: write concurrency: - # Queue instead of cancelling — a cancel kills an in-progress kernel compile. - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }} cancel-in-progress: false jobs: build: runs-on: ubuntu-latest + # A wedged runner<->server transfer can hang the build step with no output; + # bound the whole job so the queue self-heals instead of waiting on a human. + # Multi-GB closures substitute from the cache at ~600-700 KB/s uplink — + # big configs legitimately need hours; the bound only catches true hangs. + timeout-minutes: 360 steps: - uses: actions/checkout@v4 + with: + # Build exactly the PR head: a rebase merge then makes main identical to what + # was built. Falls back to the default ref for workflow_dispatch. + ref: ${{ github.event.pull_request.head.sha || github.ref }} # Reclaim /mnt for a large /nix store (runner / is ~17 GB). MUST precede the Nix install. # nix-permission-edict hands the runner user /nix so the cache restore can write into it. - uses: wimpysworld/nothing-but-nix@v10 @@ -56,27 +96,33 @@ jobs: purge-prefixes: nix-${{ runner.os }}- purge-created: 0 purge-primary-key: never - - name: Attic login + - name: Install AWS CLI + run: nix profile install nixpkgs#awscli2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # Uploaded before any build so the substituter answers from the very + # first config instead of only after the first push. + - name: Publish nix-cache-info run: | - nix profile install nixpkgs#attic-client - attic login icedos "$ICEDOS_SUBSTITUTER" "$ATTIC_TOKEN" - attic cache info icedos + printf 'StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 100\n' \ + | aws s3 cp - "s3://icedos-nix-cache-fyi/nix-cache-info" --region eu-central-1 env: - ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} - ICEDOS_SUBSTITUTER: ${{ vars.ICEDOS_SUBSTITUTER }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: eu-central-1 + AWS_DEFAULT_REGION: eu-central-1 - name: Test cache server run: | - # A POST bypasses the nginx GET cache and validates the token (401 on bad). - echo "Probing atticd API via $ICEDOS_SUBSTITUTER ..." - curl --fail --silent --show-error --retry 3 --retry-delay 5 \ - -X POST \ - -H "Authorization: Bearer $ATTIC_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"cache":"icedos","store_path_hashes":["00000000000000000000000000000000"]}' \ - -w "\nHTTP %{http_code}\n" \ - "$ICEDOS_SUBSTITUTER/_api/v1/get-missing-paths" + # A missing path must answer 403/404 through CloudFront + Cloudflare. + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \ + "$ICEDOS_SUBSTITUTER/00000000000000000000000000000000.narinfo") || code=000 + echo "cache probe: HTTP $code" + case "$code" in + 200|403|404) ;; + *) echo "::error::cache unhealthy (HTTP $code)"; exit 1 ;; + esac env: - ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} ICEDOS_SUBSTITUTER: ${{ vars.ICEDOS_SUBSTITUTER }} # Stable, NOT mktemp: the work dir is baked into the closure, so a random path # re-hashes ~38 paths per config. On /mnt because builds inherit TMPDIR from here. @@ -84,10 +130,398 @@ jobs: run: | sudo mkdir -p /mnt/icedos-work sudo chmod 1777 /mnt/icedos-work + # Minted before the build (not after) so the failure step still has a token. + - name: Mint app token + id: app-token + if: always() && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.DISPATCH_APP_ID }} + private-key: ${{ secrets.DISPATCH_APP_PRIVATE_KEY }} + owner: IceDOS + repositories: cache-server + # Seed every build from the cache branch's state.lock: the pin set the cache + # was last built with. Ungated branch refs (nixpkgs, home-manager) therefore + # resolve at their cached revs instead of drifting between runs, and cache + # hits stay stable. What this run is meant to advance gets unpinned. + - name: Download build seed from the cache branch + if: always() && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) + env: + HEAD_REF: ${{ github.head_ref }} + run: | + if curl -fsSL --retry 3 -o seed.lock \ + 'https://raw.githubusercontent.com/IceDOS/cache-server/cache/state.lock'; then + echo "ICEDOS_SEED_LOCK=$PWD/seed.lock" >>"$GITHUB_ENV" + else + echo 'no state.lock on the cache branch yet; building unseeded' >&2 + fi + + unpin='' + case '${{ github.event_name }}' in + workflow_dispatch) + # External mode: only the source repo's PR head may move. + if [ -n '${{ inputs.source-repo }}' ]; then + unpin='icedos-github_icedos_${{ inputs.source-repo }}' + fi + ;; + pull_request) + # Internal PRs advance exactly what they are about. + case "$HEAD_REF" in + update/nixpkgs) unpin='nixpkgs home-manager' ;; + update/input-*) unpin="$HEAD_REF" && unpin="${unpin#update/input-}" ;; + esac + ;; + esac + # a frozen channel node pins the stale pem into every future seed + unpin="$unpin cache-server" + echo "ICEDOS_UNPIN=$unpin" >>"$GITHUB_ENV" + # External mode needs write access to the module repo whose PR is validated. + - name: Mint source repo token + id: source-token + if: always() && inputs.source-repo != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.DISPATCH_APP_ID }} + private-key: ${{ secrets.DISPATCH_APP_PRIVATE_KEY }} + owner: IceDOS + repositories: ${{ inputs.source-repo }} + permission-contents: write + permission-pull-requests: write + # External mode: pin the module repo to the PR head in every config. The + # 00-base warm-up has no state lock yet, so core's inline-ref fallback pins + # the head rev, and the seeded lock then carries it to every other config. + # Repo-level granularity on purpose: module-level narrowing could miss + # configs that pull the module in transitively via dependencies. + - name: Pin source repo PR head in configs + if: inputs.source-repo != '' + run: | + match='url = "github:icedos/${{ inputs.source-repo }}"' + grep -rlF "$match" config/ >build-pin-files.txt || true + [ -s build-pin-files.txt ] || { echo "source repo not referenced by any config" >&2; exit 1; } + while read -r f; do + sed -i "s|$match|url = \"github:icedos/${{ inputs.source-repo }}/${{ inputs.source-head }}\"|" "$f" + done < build-pin-files.txt + # Only the affected configs need (re)building; derived here so the + # mapping can never drift from the configs themselves. + echo "ICEDOS_BUILD_CONFIGS=$(xargs -n1 basename < build-pin-files.txt | tr '\n' ' ')" >>"$GITHUB_ENV" + rm build-pin-files.txt + git diff --stat -- config/ - name: Run Nix Build run: nix run .#build env: ICEDOS_SUBSTITUTER: ${{ vars.ICEDOS_SUBSTITUTER }} - ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + ICEDOS_SIGNING_KEY: ${{ secrets.ICEDOS_SIGNING_KEY }} + ICEDOS_S3_URL: "s3://icedos-nix-cache-fyi?region=eu-central-1" + ICEDOS_S3_BUCKET: icedos-nix-cache-fyi + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: eu-central-1 + AWS_DEFAULT_REGION: eu-central-1 ICEDOS_WORKBASE: /mnt/icedos-work ICEDOS_FORCE_BUILD: ${{ inputs.force && '1' || '' }} + + # App installation tokens expire after 1h; long builds outlive them, so + # every post-build step re-mints here. + - name: Mint post-build app token + id: post-app-token + if: always() && steps.app-token.conclusion == 'success' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.DISPATCH_APP_ID }} + private-key: ${{ secrets.DISPATCH_APP_PRIVATE_KEY }} + owner: IceDOS + repositories: cache-server + - name: Mint post-build source repo token + id: post-source-token + if: always() && inputs.source-repo != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.DISPATCH_APP_ID }} + private-key: ${{ secrets.DISPATCH_APP_PRIVATE_KEY }} + owner: IceDOS + repositories: ${{ inputs.source-repo }} + permission-contents: write + permission-pull-requests: write + - name: Extract built input hashes + id: hashes + if: github.event_name == 'pull_request' + run: | + python3 scripts/tracked-revs.py diff \ + --toml tracked-inputs.toml \ + --locks-dir build/locks \ + --base state/tracked-inputs.json >build/built-revs.json + cat build/built-revs.json + + # pulls.merge commits the PR head as-is, so the branch is first rewritten as a + # single commit on top of main with the built revs folded in; rebase merge then + # lands exactly that commit. Needs "allow rebase merges" on the repo. + - name: Merge PR after successful build + id: merge + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.post-app-token.outputs.token }} + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const pr = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data; + + if (pr.state !== 'open') { + core.info(`PR #${prNumber} is ${pr.state}; nothing to merge`); + return; + } + + // Head moved while this run sat in the build queue; the newer run owns it. + if (pr.head.sha !== context.payload.pull_request.head.sha) { + core.warning('head moved since this build started; skipping merge'); + return; + } + + // Base moved: this build validated an outdated main. auto-update re-forks + // the branch onto the new main and the build re-runs there. + const mainSha = (await github.rest.git.getRef({ owner, repo, ref: 'heads/main' })).data.object.sha; + if (pr.base.sha !== mainSha) { + core.warning(`main moved (${pr.base.sha.slice(0, 12)} -> ${mainSha.slice(0, 12)}); skipping merge`); + return; + } + + // Fold the revs the build actually resolved into the bookkeeping file; a + // build-only change, so the cached closures still match main exactly. + const built = JSON.parse(fs.readFileSync('build/built-revs.json', 'utf8')); + let treeEntries = []; + if (Object.keys(built).length > 0) { + const file = JSON.parse(fs.readFileSync('state/tracked-inputs.json', 'utf8')); + // {rev, repo} lets consumers guard the name match against the host repo. + for (const r of Object.values(built)) file[r.key] = { rev: r.rev, repo: r.repo || '' }; + const content = JSON.stringify(file, null, 2) + '\n'; + const blob = (await github.rest.git.createBlob({ owner, repo, content, encoding: 'utf-8' })).data.sha; + treeEntries = [{ path: 'state/tracked-inputs.json', mode: '100644', type: 'blob', sha: blob }]; + // The publish step reads the workspace copy after this step. + fs.writeFileSync('state/tracked-inputs.json', content); + } + + // Nothing to fold: the branch already matches what was built — merge it as-is. + let shaToMerge = pr.head.sha; + if (treeEntries.length > 0) { + const tree = await github.rest.git.createTree({ owner, repo, base_tree: pr.head.sha, tree: treeEntries }); + const headTree = (await github.rest.repos.getCommit({ owner, repo, ref: pr.head.sha })).data.commit.tree.sha; + if (tree.data.sha === headTree) { + // Identical content: pushing would fire a synchronize event and loop + // the build queue forever — merge the branch as-is. + core.info('fold matches the branch tree; nothing to push'); + } else { + const message = `${pr.title}${pr.body ? `\n\n${pr.body}` : ''}`; + const commit = await github.rest.git.createCommit({ + owner, repo, message, tree: tree.data.sha, parents: [mainSha], + }); + await github.rest.git.updateRef({ owner, repo, ref: `heads/${pr.head.ref}`, sha: commit.data.sha, force: true }); + shaToMerge = commit.data.sha; + } + } + + try { + await github.rest.pulls.merge({ + owner, repo, pull_number: prNumber, + sha: shaToMerge, + merge_method: 'rebase', + }); + } catch (e) { + // main moved or rebase merges disabled; auto-update re-forks and rebuilds. + core.warning(`merge failed: ${e.message}`); + return; + } + core.setOutput('merged', 'true'); + core.info(`merged PR #${prNumber} into main (${shaToMerge.slice(0, 12)})`); + + // A previously failed build's status comment is stale once we merge. + const comments = (await github.rest.issues.listComments({ owner, repo, issue_number: prNumber })).data; + const stale = comments.find(c => c.body?.includes('')); + if (stale) await github.rest.issues.deleteComment({ owner, repo, comment_id: stale.id }); + + # Publishes the consumer channel: key + the exact input revs the cache was built + # against. PR path publishes only after a real merge; repair dispatches sync main. + - name: Publish cache channel + if: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || steps.merge.outputs.merged == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.post-app-token.outputs.token }} + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const branch = 'cache'; + + const files = ['nix-public.pem', 'flake.lock', 'state/tracked-inputs.json']; + // The built state lock seeds the next run's build (cache-branch pins). + if (fs.existsSync('state.lock')) files.push('state.lock'); + const blobs = []; + for (const path of files) { + const sha = (await github.rest.git.createBlob({ + owner, repo, content: fs.readFileSync(path, 'utf8'), encoding: 'utf-8', + })).data.sha; + // Branch layout is flat; only the tracked-inputs file changes name. + blobs.push({ path: path === 'state/tracked-inputs.json' ? 'tracked-inputs.json' : path, mode: '100644', type: 'blob', sha }); + } + + let headSha = null; + try { + headSha = (await github.rest.git.getRef({ owner, repo, ref: `heads/${branch}` })).data.object.sha; + } catch (e) { + if (e.status !== 404) throw e; + } + + if (headSha) { + const headCommit = (await github.rest.git.getCommit({ owner, repo, commit_sha: headSha })).data; + const tree = (await github.rest.git.getTree({ owner, repo, tree_sha: headCommit.tree.sha, recursive: '1' })).data; + const same = tree.tree.length === blobs.length && + blobs.every(b => tree.tree.some(t => t.path === b.path && t.sha === b.sha)); + if (same) { + core.info(`${branch}: already up to date`); + return; + } + } + + // No base_tree + no parent: the branch is always ONE orphan commit holding + // exactly these files, so flake consumers never clone any history. + const tree = await github.rest.git.createTree({ owner, repo, tree: blobs }); + const message = context.payload.pull_request + ? `publish: ${context.payload.pull_request.title}` + : 'publish: sync from main'; + const commit = await github.rest.git.createCommit({ + owner, repo, message, tree: tree.data.sha, parents: [], + }); + + if (headSha) { + await github.rest.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, force: true }); + } else { + await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: commit.data.sha }); + } + core.info(`${branch}: published ${commit.data.sha.slice(0, 12)}`); + + - name: Report build failure on the PR + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.post-app-token.outputs.token }} + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const marker = ''; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + + let failed = []; + if (fs.existsSync('build/status')) { + for (const f of fs.readdirSync('build/status')) { + if (fs.readFileSync(`build/status/${f}`, 'utf8').trim() === 'fail') failed.push(f); + } + } + // Build passed but a later step (merge/publish) crashed: no comment needed. + if (failed.length === 0) { + core.warning('no config failed; skipping failure comment'); + return; + } + + // One upserted comment per PR, never a wall of retries. + const body = [ + marker, + '❌ Build failed' + (failed.length ? ` for: ${failed.join(', ')}` : '.') + '.', + `Run: ${runUrl}`, + '', + 'main is untouched — the PR stays open and rebuilds on the next auto-update cycle.', + ].join('\n'); + + const comments = (await github.rest.issues.listComments({ owner, repo, issue_number: prNumber })).data; + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + } + + # External mode twin of the step above: the PR lives in the module repo, and + # its "next cycle" is the updater's next schedule run (it re-dispatches any + # open PR, so a transient failure retries automatically). + - name: Report build failure on the source PR + if: failure() && inputs.source-repo != '' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.post-source-token.outputs.token }} + script: | + const fs = require('fs'); + const owner = context.repo.owner; + const repo = '${{ inputs.source-repo }}'; + const prNumber = Number('${{ inputs.source-pr }}'); + const marker = ''; + const runUrl = `https://github.com/${context.repo.owner}/cache-server/actions/runs/${context.runId}`; + + let failed = []; + if (fs.existsSync('build/status')) { + for (const f of fs.readdirSync('build/status')) { + if (fs.readFileSync(`build/status/${f}`, 'utf8').trim() === 'fail') failed.push(f); + } + } + // Build passed but a later step (merge/publish) crashed: no comment needed. + if (failed.length === 0) { + core.warning('no config failed; skipping failure comment'); + return; + } + + const body = [ + marker, + '❌ Build failed' + (failed.length ? ` for: ${failed.join(', ')}` : '.') + '.', + `Run: ${runUrl}`, + '', + 'main is untouched — the PR stays open and the updater re-dispatches it on its next cycle.', + ].join('\n'); + + const comments = (await github.rest.issues.listComments({ owner, repo, issue_number: prNumber })).data; + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + } + + # External mode twin of the internal merge: the build above pinned the PR + # head, so a green run means the module repo's main can advance. Same guards: + # a moved head/base defers to the updater's next cycle, which re-forks. + - name: Merge source PR after successful build + if: inputs.source-repo != '' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.post-source-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = '${{ inputs.source-repo }}'; + const prNumber = Number('${{ inputs.source-pr }}'); + const headSha = '${{ inputs.source-head }}'; + + const pr = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data; + if (pr.state !== 'open') { + core.info(`PR #${prNumber} is ${pr.state}; nothing to merge`); + return; + } + if (pr.head.sha !== headSha) { + core.warning('head moved since dispatch; skipping merge'); + return; + } + const mainSha = (await github.rest.git.getRef({ owner, repo, ref: 'heads/main' })).data.object.sha; + if (pr.base.sha !== mainSha) { + core.warning(`main moved (${pr.base.sha.slice(0, 12)} -> ${mainSha.slice(0, 12)}); skipping merge`); + return; + } + + try { + await github.rest.pulls.merge({ owner, repo, pull_number: prNumber, sha: headSha, merge_method: 'rebase' }); + } catch (e) { + // main moved or rebase merges disabled; the updater re-forks next cycle. + core.warning(`merge failed: ${e.message}`); + return; + } + core.info(`merged ${repo} PR #${prNumber} into main (${headSha.slice(0, 12)})`); + + // A previously failed build's status comment is stale once we merge. + const comments = (await github.rest.issues.listComments({ owner, repo, issue_number: prNumber })).data; + const stale = comments.find(c => c.body?.includes('')); + if (stale) await github.rest.issues.deleteComment({ owner, repo, comment_id: stale.id }); diff --git a/.gitignore b/.gitignore index ab8a010..c5cb0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ build keys nix-private.pem +__pycache__/ +state.lock diff --git a/DEPLOY.md b/DEPLOY.md index c82afdf..0ed6834 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,120 +1,26 @@ -# Cache-server deployment - -The cache-server is a self-hosted [Attic](https://github.com/zhaofengli/attic) Nix binary cache. -CI (`.github/workflows/nix-build.yml`) builds the configs under `config/` and pushes **only -icedos-custom paths** with `attic push` — paths already on `cache.nixos.org` are skipped, and -content is globally deduplicated. Clients fetch generic paths from `cache.nixos.org` and custom -paths from this cache. - -## Stack - -The whole stack is a **foreground supervisor** (`stack/supervisor.sh`) running three nix binaries — -no Docker, no containers, no daemon: - -- **atticd** — the binary cache (localhost `127.0.0.1:8080`). SQLite + storage under `/nix/attic`. -- **nginx** — disk cache (`/nix/nar-cache`, 16 GB, 7-day eviction) on `127.0.0.1:8081`, shielding atticd's single - core from repeat NAR reassembly. -- **caddy** — the public TLS edge (`:80`/`:443`) with **fully automatic HTTPS** (issue + renew, - zero intervention). Uploads stream straight to atticd; everything else goes through the nginx cache. - -`nix run .#stack` brings it up in the foreground; any signal, or any child dying, drops the **whole** -stack atomically. It does not daemonise — wrap it for keep-alive (below). - -Host state: `/nix/attic` (atticd), `/nix/nar-cache` (nginx), `/var/lib/icedos-caddy` (Caddy's ACME -account + certs — **persist this** so Caddy never re-issues on restart). - -## Keep-alive (Debian 13 / systemd) - -```bash -cd /path/to/cache-server && git pull - -# 1. JWT secret as a KEY=VALUE env file (reuse the existing secret value) -echo "ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64=$(sudo cat /etc/icedos-attic-secret)" \ - | sudo tee /etc/icedos-attic-secret-env >/dev/null - -# 2. stable symlink to the current built stack (rebuild on updates) -sudo nix build .#supervisor --out-link /var/lib/icedos-stack # or path:.#supervisor before committing - -# 3. systemd wrapper — keeps the foreground supervisor alive + restarts it -sudo tee /etc/systemd/system/icedos-stack.service >/dev/null <<'EOF' -[Unit] -Description=IceDOS cache stack (atticd + nginx + caddy) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -EnvironmentFile=/etc/icedos-attic-secret-env -ExecStart=/var/lib/icedos-stack/bin/icedos-cache -Restart=always -RestartSec=5 -# optional hardening: -# ProtectSystem=strict -# ReadWritePaths=/nix/attic /nix/nar-cache /var/lib/icedos-caddy /run -EOF - -sudo systemctl daemon-reload -sudo systemctl enable --now icedos-stack.service -``` - -`systemctl stop` → SIGTERM → the supervisor drops the stack. The unit runs the supervisor in the -foreground (no detach); systemd just keeps it alive across reboots/crashes. - -> On Alpine/OpenRC instead of systemd, run the same `/var/lib/icedos-stack/bin/icedos-cache` under -> `supervise-daemon` with the secret exported — the supervisor itself is init-agnostic. - -## Migrating from the old Docker stack - -```bash -docker compose -f stack/compose.yml down # the old compose lives in git history -sudo systemctl disable --now docker # optional: reclaim dockerd/containerd/shim/proxy RAM -``` - -The new atticd reads the **same `/nix/attic`**, so the cache contents + public key carry over — -**no re-bootstrap**. nginx reuses `/nix/nar-cache` too. With `:80`/`:443` now free, Caddy issues a -fresh cert on first start. - -## Updating the stack - -```bash -cd /path/to/cache-server && git pull -sudo nix build .#supervisor --out-link /var/lib/icedos-stack -sudo systemctl restart icedos-stack.service -``` - -Config/binary store paths are baked into the supervisor at build time, so an update = rebuild the -symlink + restart. - -## TLS — zero intervention - -Caddy issues and renews the `icedos.mirrors.knp.one` certificate automatically (ACME), persisting -state in `/var/lib/icedos-caddy`. Nothing to schedule, no timer, no cron. Set the ACME account email -in `stack/conf/Caddyfile` (currently `support@dtek.gr`). Requirements: ports 80 + 443 open, DNS for -`icedos.mirrors.knp.one` pointing at the box. - -## Tokens (if ever needed) - -`nix develop .#stack` exposes `generate_attic_admin_token` / `generate_attic_builder_token` — they run -`atticadm` directly (atticd is on the host now). Export the secret first: -`export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="$(sudo cat /etc/icedos-attic-secret)"`. The CI -`ATTIC_TOKEN` (1y, pull+push `icedos`) is unchanged. - -## Verify - -```bash -free -m # dockerd/containerd/shims gone -systemctl status icedos-stack -curl -sI https://icedos.mirrors.knp.one/icedos/nix-cache-info # 200, valid (Caddy) TLS -curl -sI https://icedos.mirrors.knp.one/.narinfo | grep -i x-cache-status # MISS then HIT -nix path-info --store https://icedos.mirrors.knp.one/icedos # a pushed custom path resolves -``` - -## Secrets - -| Secret | Where | Purpose | -| --- | --- | --- | -| `ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64` | `/etc/icedos-attic-secret-env` | atticd JWT signing | -| `ATTIC_TOKEN` | GitHub repo secret | CI `attic push` auth (pull+push `icedos`) | -| `nix-public.pem` | repo file | cache public key clients trust | - -Caddy manages the TLS certificate itself — no secret to handle. +# Deploy + +The cache is fully managed: GitHub Actions builds IceDOS configs on every +change source and pushes to S3; CloudFront serves them behind Cloudflare at +`https://icedos.fyi`. There is no self-hosted server. + +## Architecture + +- **AWS S3** — private bucket `icedos-nix-cache-fyi` (eu-central-1): NARs, + narinfos, per-config locks (`locks/`), `state.lock`. 35-day lifecycle + expiry; the weekly `heal-cache.yml` run re-pushes expired-but-current + paths so live closures never 404. +- **CloudFront** — distribution `E1EEMYNS1YFLPR`, origin access control + (signed reads only, no public bucket access), CachingOptimized policy. +- **Cloudflare** — proxied CNAME `@` → CloudFront; edge TTL: 200–299 → 30d, + 404/403 → 10s. The zone hosts only the cache hostname. +- **CI** — `nix-build.yml` gates `main` behind green builds (one PR per + change source, native-rebase merge); pushes sign with `ICEDOS_SIGNING_KEY`. + IAM user `icedos-ci` has S3 read/write on the bucket and nothing else. + +## Consumers + +Add `https://icedos.fyi` as a substituter with the public key from the +`cache` branch (`nix-public.pem`), served with priority below +cache.nixos.org. The upstream filter keeps the bucket to IceDOS-built +paths only. diff --git a/build.sh b/build.sh index 16fbbd9..8364348 100644 --- a/build.sh +++ b/build.sh @@ -5,28 +5,40 @@ set -o pipefail root="$PWD" -# Returns 0 if the store path is already in the Attic cache. +# The CDN serves the S3 bucket; narinfo/NAR paths have no cache-name prefix. +ICEDOS_CACHE_URL="${ICEDOS_SUBSTITUTER:-https://icedos.fyi}" +# nix copy signs pushed paths with the icedos keypair, so consumers that trust +# the public key need no change. +if [ -n "${ICEDOS_SIGNING_KEY:-}" ]; then + printf '%s\n' "$ICEDOS_SIGNING_KEY" > "$root/nix-secret.pem" + chmod 600 "$root/nix-secret.pem" + export NIX_CONFIG="secret-key-files = $root/nix-secret.pem" +fi + +# Returns 0 if the store path is already in the S3 cache (probed via the CDN). is_path_cached() { local store_path="$1" - # get-missing-paths wants only the 32-char hash. local hash="${store_path#/nix/store/}" hash="${hash:0:32}" - local resp - resp=$(curl -sf -X POST \ - -H "Authorization: Bearer $ATTIC_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"cache\":\"icedos\",\"store_path_hashes\":[\"$hash\"]}" \ - "$ICEDOS_SUBSTITUTER/_api/v1/get-missing-paths" 2>/dev/null) || return 1 - - local missing_count - missing_count=$(echo "$resp" | jq '.missing_paths | length') - [ "$missing_count" -eq 0 ] + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \ + "$ICEDOS_CACHE_URL/$hash.narinfo") || code=000 + [ "$code" = 200 ] } [ -d build ] && rm -rf build mkdir -p build/status +# Health gate: a broken origin makes nix treat CI-built paths as unavailable and +# recompile the world. Abort; the next cycle retries. 403 = missing via CloudFront+OAC. +probe_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \ + "$ICEDOS_CACHE_URL/00000000000000000000000000000000.narinfo") || probe_code=000 +case "$probe_code" in + 200|403|404) ;; + *) echo "::error::cache server unhealthy (HTTP $probe_code) — aborting build so the next cycle retries against a healthy server" >&2; exit 1 ;; +esac + # The work dir is baked into the closure via `icedos.configurationLocation`, so a mktemp base # gives every run fresh hashes for ~38 paths per config. CI pins ICEDOS_WORKBASE instead. workbase="${ICEDOS_WORKBASE:-}" @@ -44,6 +56,66 @@ trap clean_workbase EXIT max_parallel="${ICEDOS_MAX_PARALLEL:-6}" +# ICEDOS_BUILD_CONFIGS (external mode): space-separated basenames of the configs +# affected by the source repo, derived by nix-build.yml. The base warm-up always +# runs: it seeds BASE_LOCK with the pinned rev and realizes the shared closure. +# force overrides the subset; without it everything outside the subset is +# untouched and stays covered by the cache. +selected=() +if [ -n "${ICEDOS_BUILD_CONFIGS:-}" ] && [ -z "${ICEDOS_FORCE_BUILD:-}" ]; then + for name in $ICEDOS_BUILD_CONFIGS; do + [ -f "config/$name" ] || { echo "ICEDOS_BUILD_CONFIGS: no such config: $name" >&2; exit 1; } + selected+=("$name") + done +else + for cfg in config/*.toml; do + selected+=("$(basename "$cfg")") + done +fi +in_selected() { + local name="$1" s + for s in "${selected[@]}"; do [ "$s" = "$name" ] && return 0; done + return 1 +} + +# Apply the unpin list to the seed: nodes this run is meant to advance are +# removed so they resolve fresh (external: the pinned source repo; internal: +# nixpkgs/home-manager on the nixpkgs PR, the PR's leaf otherwise). Everything +# else keeps the rev the cache was last built with. +if [ -n "${ICEDOS_SEED_LOCK:-}" ] && [ -f "${ICEDOS_SEED_LOCK:-}" ] && [ -n "${ICEDOS_UNPIN:-}" ]; then + python3 - "$ICEDOS_SEED_LOCK" $ICEDOS_UNPIN <<'PYEOF' +import json, sys + +path, patterns = sys.argv[1], sys.argv[2:] +lock = json.load(open(path)) +nodes = lock.get("nodes", {}) + +# Same key lookup as cache-server's tracked-revs.py: exact match, else a +# "-" suffixed node key. +targets = set() +for name in patterns: + if name in nodes: + targets.add(name) + targets.update(k for k in nodes if k.endswith("-" + name)) +for k in targets: + nodes.pop(k, None) + +def strip_refs(inputs): + if not isinstance(inputs, dict): + return + for name, ref in list(inputs.items()): + if isinstance(ref, str) and ref in targets: + del inputs[name] + +for node in nodes.values(): + strip_refs(node.get("inputs")) +strip_refs(nodes.get("root", {}).get("inputs")) + +json.dump(lock, open(path, "w"), indent=2) +print(f"unpinned {len(targets)} node(s) from the seed: {sorted(targets)}") +PYEOF +fi + # Build one config in an isolated, git-less work dir so the flake eval sees the untracked # config.toml. Pushes go behind a flock: the 1-core server only chunks one closure at a time. build_and_push() { @@ -59,12 +131,26 @@ build_and_push() { rsync -a --exclude=build --exclude=.git "$root/" "$work/" cp "$cfg" "$work/config.toml" + # Seed from the cache branch's state lock so every input this run does not + # advance resolves at the rev the cache was last built with. Placed before + # the base-lock seed below: the warm-up's evolved lock wins for the rest. + if [ -n "${ICEDOS_SEED_LOCK:-}" ] && [ -f "${ICEDOS_SEED_LOCK:-}" ]; then + mkdir -p "$work/build/.state" + cp "$ICEDOS_SEED_LOCK" "$work/build/.state/flake.lock" + fi + # Seed the base build's resolved lock so this build only resolves its OWN repos; nix # adds the missing ones in-memory (--no-update-lock-file doesn't block additions). if [ -n "${BASE_LOCK:-}" ] && [ -f "${BASE_LOCK:-}" ] && [ "$cfg" != "$base" ]; then mkdir -p "$work/build/.state" cp "$BASE_LOCK" "$work/build/.state/flake.lock" fi + # Heal mode: rebuild from each config's own last-resolved lock so the copied + # closure matches what that config actually serves its users. + if [ -n "${ICEDOS_HEAL:-}" ] && [ -f "$root/build/locks/$name.lock" ]; then + mkdir -p "$work/build/.state" + cp "$root/build/locks/$name.lock" "$work/build/.state/flake.lock" + fi mkdir -p "$out" @@ -73,10 +159,16 @@ build_and_push() { # Skip when the top-level closure is already cached; genflake runs first for the pure outPath eval. # pipe-operators is REQUIRED — core/lib/icedos.nix uses `|>`; stderr kept so failed evals aren't silent. top_path="" - if [ -n "${ATTIC_TOKEN:-}" ] && [ -n "${ICEDOS_SUBSTITUTER:-}" ] && [ -z "${ICEDOS_FORCE_BUILD:-}" ]; then - if TMPDIR="$out" nix run path:.#icedos -- --genflake-only; then + if [ -z "${ICEDOS_HEAL:-}" ] && [ -n "${ICEDOS_CACHE_URL:-}" ] && [ -z "${ICEDOS_FORCE_BUILD:-}" ]; then + # Evals are silent — a stalled fetch here hangs the whole fan-out, so bound them. + if timeout 15m env TMPDIR="$out" nix run path:.#icedos -- --genflake-only; then + # Write the resolved lock: the skip check eval reads it, and CI pins the built + # input hashes from build/locks (gitignored). + timeout 10m nix --extra-experimental-features "nix-command flakes" flake lock "$work/build/.state" + mkdir -p "$root/build/locks" + cp "$work/build/.state/flake.lock" "$root/build/locks/$name.lock" eval_err="$root/build/$name.eval.err" - top_path=$(nix eval --raw --no-write-lock-file \ + top_path=$(timeout 10m nix eval --raw --no-write-lock-file \ --extra-experimental-features "nix-command flakes pipe-operators" \ "path:$work/build/.state#nixosConfigurations.icedos.config.system.build.toplevel.outPath" \ 2>"$eval_err") || { @@ -99,7 +191,7 @@ build_and_push() { --nh-args --no-nom \ --build-args \ -L \ - --extra-substituters "$ICEDOS_SUBSTITUTER/icedos?priority=100" \ + --extra-substituters "$ICEDOS_CACHE_URL?priority=100" \ --extra-trusted-public-keys "$(cat nix-public.pem)" \ --extra-substituters "https://attic.xuyh0120.win/lantian?priority=90" \ --extra-trusted-public-keys "lantian:EeAUQ+W+6r7EtwnmYjeVwx5kOGEBpjlBfPlzGlTNvHc=" @@ -113,22 +205,59 @@ build_and_push() { } result="$(readlink "${results[0]}")" + # Refresh the persisted lock: the build may have resolved newer revs. + timeout 10m nix --extra-experimental-features "nix-command flakes" flake lock "$work/build/.state" + cp "$work/build/.state/flake.lock" "$root/build/locks/$name.lock" + echo "pushing $cfg..." - # `attic push` exits 0 even when individual paths fail, and the skip check above would - # then make the gap permanent. Retry on attic's own ❌ verdict, not get-missing-paths. + # Upstream filter: skip paths cache.nixos.org already serves — users prefer + # it (priority 40 < 100), so pushing them is pure waste of upload + storage. + mapfile -t closure_paths < <(nix path-info -r "$result") + missing_paths=() + printf '%s\n' "${closure_paths[@]}" | xargs -P 8 -I{} bash -c ' + h=$(basename "{}" | cut -c1-32) + code=000 + for i in 1 2 3; do + code=$(curl -s -o /dev/null -w "%{http_code}" --head --max-time 20 "https://cache.nixos.org/$h.narinfo") + [ "$code" = 000 ] || break + sleep $((i * 2)) + done + # a failed probe is not proof of absence; pushing beats permanent skip + [ "$code" = 200 ] || printf "%s\n" "{}" + ' >"$out/missing-paths" || true + mapfile -t missing_paths < "$out/missing-paths" + echo "$cfg: $((${#closure_paths[@]} - ${#missing_paths[@]})) paths in upstream, pushing ${#missing_paths[@]}" + + # `nix copy` exits non-zero on any path failure, which would make the skip + # check above permanent. Retry on failure. pushed=0 for attempt in 1 2 3; do push_rc=0 - push_out="$(flock "$root/build/push.lock" attic push icedos "$result" 2>&1)" || push_rc=$? + if [ "${#missing_paths[@]}" -eq 0 ]; then + push_out="$cfg: nothing to push — entire closure already in upstream caches" + printf '%s\n' "$push_out" + pushed=1 + break + fi + push_out="$(flock "$root/build/push.lock" timeout 30m \ + nix copy --to "$ICEDOS_S3_URL" ${missing_paths[@]+"${missing_paths[@]}"} 2>&1)" || push_rc=$? printf '%s\n' "$push_out" - if [ "$push_rc" -eq 0 ] && ! printf '%s' "$push_out" | grep -q '❌'; then + if [ "$push_rc" -eq 0 ]; then pushed=1 + # Persist the config's resolved lock so the weekly heal job can restore + # exactly this closure if the lifecycle rule expires any of its paths. + # nix copy never writes nix-cache-info to S3; nix drops the substituter without it + printf 'StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 100\n' \ + | aws s3 cp - "s3://$ICEDOS_S3_BUCKET/nix-cache-info" --region "$AWS_REGION" >/dev/null 2>&1 || \ + echo "warning: failed to upload nix-cache-info" >&2 + aws s3 cp "$root/build/locks/$name.lock" "s3://$ICEDOS_S3_BUCKET/locks/$name.lock" --region "$AWS_REGION" >/dev/null 2>&1 || \ + echo "$cfg: warning — could not upload the config lock" >&2 break fi - echo "$cfg: push attempt $attempt reported failed paths (rc=$push_rc), retrying" >&2 + echo "$cfg: push attempt $attempt failed (rc=$push_rc), retrying" >&2 sleep $((attempt * 15)) done [ "$pushed" -eq 1 ] || { @@ -155,8 +284,11 @@ if [ -f "$base" ]; then fi # Fan out the remaining configs in parallel against the now-warm store, throttled. +# Subset mode skips configs outside the selection (including the base itself, +# which the warm-up above already handled). for cfg in config/*.toml; do [ "$cfg" = "$base" ] && continue + in_selected "$(basename "$cfg")" || continue while [ "$(jobs -r | wc -l)" -ge "$max_parallel" ]; do wait -n || true; done build_and_push "$cfg" & done @@ -165,6 +297,7 @@ wait failed=() for cfg in config/*.toml; do [ "$cfg" = "$base" ] && continue + in_selected "$(basename "$cfg")" || continue name="$(basename "$cfg" .toml)" [ "$(cat "$root/build/status/$name" 2>/dev/null)" = "ok" ] || failed+=("$cfg") done @@ -175,4 +308,10 @@ if [ "${#failed[@]}" -gt 0 ]; then exit 1 fi +# Expose the built state lock for the publish step: it becomes the next run's +# seed, so the cache branch always describes what the cache was built with. +if [ -n "${BASE_LOCK:-}" ] && [ -f "${BASE_LOCK:-}" ]; then + cp "$BASE_LOCK" "$root/state.lock" +fi + echo "All configs built successfully!" diff --git a/config/05-common.toml b/config/05-common.toml index 7bd1b36..ced3440 100644 --- a/config/05-common.toml +++ b/config/05-common.toml @@ -29,6 +29,12 @@ modules = [ "jovian", ] +[[icedos.repositories]] +url = "github:icedos/tweaks" +modules = [ + "dmem", +] + [icedos.applications.steam.headless-session] colorManagement = true hdr = true diff --git a/flake.nix b/flake.nix index d466cb6..97468cb 100644 --- a/flake.nix +++ b/flake.nix @@ -19,28 +19,6 @@ }: let system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; - - atticd = "${pkgs.attic-server}/bin/atticd"; - atticadm = "${pkgs.attic-server}/bin/atticadm"; - - # Foreground supervisor over three nix binaries — no container runtime. - # replaceVarsWith fails the build on any unsubstituted @placeholder@. - supervisor = pkgs.replaceVarsWith { - src = ./stack/supervisor.sh; - name = "icedos-cache"; - dir = "bin"; - isExecutable = true; - - replacements = { - inherit atticd; - nginx = "${pkgs.nginx}/bin/nginx"; - caddy = "${pkgs.caddy}/bin/caddy"; - server = "${self}/stack/conf/server.toml"; - nginxconf = "${self}/stack/conf/nginx.conf"; - caddyfile = "${self}/stack/conf/Caddyfile"; - }; - }; icedosApp = (icedos.lib.mkIceDOS { @@ -53,7 +31,7 @@ build = { type = "app"; program = toString ( - with pkgs; + with nixpkgs.legacyPackages.${system}; writeShellScript "build" '' ${bash}/bin/bash ${./build.sh} '' @@ -61,41 +39,6 @@ }; icedos = icedosApp; - - # Foreground; Ctrl-C or SIGTERM from a keep-alive wrapper drops the stack. - stack = { - type = "app"; - program = "${supervisor}/bin/icedos-cache"; - }; - }; - - devShells.${system} = { - stack = pkgs.mkShell { - buildInputs = with pkgs; [ - attic-client - attic-server - ]; - shellHook = '' - # atticd runs on the host, so mint tokens with atticadm directly. Needs: - # export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="$(sudo cat /etc/icedos-attic-secret)" - generate_attic_admin_token() { - ${atticadm} -f ${self}/stack/conf/server.toml \ - make-token --sub admin --validity '100y' \ - --pull '*' --push '*' --create-cache '*' --configure-cache '*' --configure-cache-retention '*' - } - - generate_attic_builder_token() { - validity="''${1:-1y}" - ${atticadm} -f ${self}/stack/conf/server.toml \ - make-token --sub ci --validity "$validity" \ - --pull icedos --push icedos - } - ''; - }; - }; - - packages.${system} = { - inherit supervisor; }; }; } diff --git a/nix-public.pem b/nix-public.pem index a5db6be..a07a8f8 100644 --- a/nix-public.pem +++ b/nix-public.pem @@ -1 +1 @@ -icedos:POf96Ic4ajCRKlT/8XY/tB+l6h4sZKGCeAvbiFJu85U= \ No newline at end of file +icedos:dt9ftaPurBWdsUyjEEm2eftkGOhXzydZBkSaWthQOd0= \ No newline at end of file diff --git a/scripts/tracked-revs.py b/scripts/tracked-revs.py new file mode 100644 index 0000000..b4091b2 --- /dev/null +++ b/scripts/tracked-revs.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Resolve tracked-input revisions from generated sub-flake locks. + +A tracked input is the bare LEAF node (`nodes.`) inside its module's +sub-flake. Exact key match wins; `endswith("-" + name)` handles prefixed keys. +A `follows` input (array target) has no resolvable revision and is skipped. +""" + +import argparse +import json +import sys +import tomllib +from pathlib import Path + + +def repo_of(node: dict) -> str: + # "scheme:owner/repo" identity of the leaf, so consumers can guard a name + # match against the repo it actually points at. + orig = node.get("original") or {} + kind = orig.get("type") + if kind in ("github", "gitlab", "sourcehut"): + return f"{kind}:{orig.get('owner')}/{orig.get('repo')}" + if kind == "git": + return orig.get("url", "") + return "" + + +def lookup(lock: dict, name: str): + nodes = lock.get("nodes", {}) + key = name if name in nodes else next((k for k in nodes if k.endswith("-" + name)), None) + if key is None: + return None + + entry = nodes[key].get("inputs", {}).get(name) + if isinstance(entry, list): + return None # follows: no resolvable revision + if isinstance(entry, str): + key = entry # reference to another node + + node = nodes.get(key) or {} + rev = (node.get("locked") or {}).get("rev") or (node.get("original") or {}).get("rev") + return {"key": key, "rev": rev, "repo": repo_of(node)} if rev else None + + +def tracked_names(toml_path: str) -> list[str]: + data = tomllib.load(open(toml_path, "rb")) + return [entry["name"] for entry in data["trackedInputs"]] + + +def cmd_extract(args): + names = args.names.split(",") if args.names else tracked_names(args.toml) + lock = json.load(open(args.lock)) + out = {} + for name in names: + found = lookup(lock, name) + if found: + out[name] = found + else: + print(f"warning: could not resolve a revision for tracked input '{name}'", file=sys.stderr) + print(json.dumps(out)) + + +def cmd_diff(args): + names = tracked_names(args.toml) + base = json.load(open(args.base)) if Path(args.base).exists() else {} + locks_dir = Path(args.locks_dir) + out = {} + for name in names: + # Last config wins, mirroring the detector. + found = None + for cfg in (locks_dir / f"{Path(cfg).stem}.lock" for cfg in configs_for(args.toml, name)): + if not cfg.exists(): + continue + found = lookup(json.load(open(cfg)), name) + if not found: + continue + old = base.get(found["key"], "") + if old != found["rev"]: + out[name] = {"name": name, "key": found["key"], "rev": found["rev"], "old": old or None} + print(json.dumps(out)) + + +def configs_for(toml_path: str, name: str) -> list[str]: + data = tomllib.load(open(toml_path, "rb")) + for entry in data["trackedInputs"]: + if entry["name"] == name: + return entry["configs"] + return [] + + +parser = argparse.ArgumentParser(description=__doc__) +sub = parser.add_subparsers(dest="cmd", required=True) + +p_extract = sub.add_parser("extract", help="resolve revs from one lock") +p_extract.add_argument("--lock", required=True) +p_extract.add_argument("--names", help="comma-separated; defaults to every tracked input") +p_extract.add_argument("--toml", default="tracked-inputs.toml") + +p_diff = sub.add_parser("diff", help="revs that differ from a base mapping") +p_diff.add_argument("--toml", default="tracked-inputs.toml") +p_diff.add_argument("--locks-dir", required=True) +p_diff.add_argument("--base", default="state/tracked-inputs.json") + +args = parser.parse_args() +{"extract": cmd_extract, "diff": cmd_diff}[args.cmd](args) diff --git a/stack/conf/Caddyfile b/stack/conf/Caddyfile deleted file mode 100644 index 15752d2..0000000 --- a/stack/conf/Caddyfile +++ /dev/null @@ -1,21 +0,0 @@ -# TLS edge, automatic issue + renew. Uploads stream straight to atticd; everything -# else goes through the nginx disk cache. Both upstreams are localhost. -{ - admin off - grace_period 10s -} - -icedos.mirrors.knp.one { - @api { - path /_api/* - } - handle @api { - reverse_proxy 127.0.0.1:8080 { - header_up Host {host} - } - } - - handle { - reverse_proxy unix//run/icedos-nginx.sock - } -} diff --git a/stack/conf/nginx.conf b/stack/conf/nginx.conf deleted file mode 100644 index dc0819f..0000000 --- a/stack/conf/nginx.conf +++ /dev/null @@ -1,82 +0,0 @@ -# Disk cache in front of atticd, shielding its single core from repeat NAR reassembly. -# Localhost only; Caddy is the TLS edge. Self-contained: no /etc/nginx, logs to stderr. -user root; -worker_processes 1; - -error_log stderr warn; -pid /run/icedos-nginx.pid; - -events { - worker_connections 512; - multi_accept on; -} - -http { - default_type application/octet-stream; - - # CPU/IO-bound box; per-request logs have no value for a cache. - access_log off; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - - # NARs stream through; the disk cache handles storage. - proxy_buffering on; - proxy_buffer_size 4k; - proxy_buffers 4 16k; - proxy_busy_buffers_size 32k; - proxy_max_temp_file_size 64m; - - # Store objects are immutable, but a 404 must not stick or a later push stays invisible. - map $status $nar_cache_control { - default "no-cache"; - 200 "public, max-age=31536000, immutable"; - 301 "public, max-age=31536000, immutable"; - 302 "public, max-age=31536000, immutable"; - } - - # Entries idle for >7 days are evicted and re-fetched from atticd if pulled again. - proxy_cache_path /nix/nar-cache levels=1:2 keys_zone=nar-cache:8m max_size=16g inactive=7d use_temp_path=off; - - upstream atticd { - server 127.0.0.1:8080; - keepalive 8; - } - - server { - listen unix:/run/icedos-nginx.sock; - - # Fewer open/stat/close syscalls per hit, which matters on one core. - open_file_cache max=4096 inactive=60s; - open_file_cache_valid 120s; - open_file_cache_min_uses 1; - open_file_cache_errors on; - - location / { - proxy_cache nar-cache; - proxy_pass http://atticd; - proxy_http_version 1.1; - # Clearing Connection lets keep-alive to atticd use the pool above. - proxy_set_header Connection ""; - proxy_set_header Host $host; - - # atticd reassembles each NAR from chunks per request; cache the result so repeat - # pulls don't re-hit it. Caddy sends uploads straight to atticd, so this is GETs only. - proxy_ignore_headers X-Accel-Expires Expires Cache-Control Set-Cookie; - proxy_cache_valid 200 301 302 365d; - proxy_cache_valid 404 1m; - - # Coalesce concurrent misses. Long timeouts: a cold kernel/cuda NAR can take >60s - # to reassemble on one core, and the 60s default would 504 and stampede atticd. - proxy_cache_lock on; - proxy_cache_lock_timeout 300s; - proxy_read_timeout 300s; - proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; - - add_header Cache-Control $nar_cache_control always; - add_header X-Cache-Status $upstream_cache_status always; - } - } -} diff --git a/stack/conf/server.toml b/stack/conf/server.toml deleted file mode 100644 index ca4d038..0000000 --- a/stack/conf/server.toml +++ /dev/null @@ -1,36 +0,0 @@ -# The JWT HS256 secret comes from ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64, not this file -# (`openssl rand 64 | base64 -w0`) — Attic moves that key between schema versions. - -# Localhost only; Caddy and nginx are the public faces. -listen = "127.0.0.1:8080" - -# Without this atticd synthesizes the endpoint from the Host header and emits wrong URLs. -api-endpoint = "https://icedos.mirrors.knp.one/" - -[database] -# /nix/attic already holds the cache from the old containerised stack. -url = "sqlite:///nix/attic/server.db?mode=rwc" -# Saves a query/minute on a single-core box. -heartbeat = false - -[storage] -type = "local" -path = "/nix/attic/storage" - -# Content-addressed chunking = global dedup. Larger chunks trade dedup ratio for fewer -# reassembly operations per NAR; this box is CPU-bound, not storage-bound. -[chunking] -nar-size-threshold = 131072 -min-size = 32768 -avg-size = 131072 -max-size = 524288 - -# CPU overhead is the scarce resource here, not disk. -[compression] -type = "none" - -# Access-based: an object ages out only if it was neither pulled nor re-pushed for the -# retention period. Zero/unset disables time-based GC entirely, so this key is required. -[garbage-collection] -interval = "12 hours" -default-retention-period = "30d" diff --git a/stack/supervisor.sh b/stack/supervisor.sh deleted file mode 100644 index a290771..0000000 --- a/stack/supervisor.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# Runs atticd + nginx + caddy as foreground children; any signal or child death drops the -# whole stack (wait -n + exit 1). Does NOT daemonise — wrap it (systemd Restart=always). -set -uo pipefail - -: "${ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64:?must be set (systemd EnvironmentFile, or exported before run)}" - -# Persist Caddy's ACME account + certs so restarts don't re-issue and hit rate limits. -caddy_home="${ICEDOS_CADDY_HOME:-/var/lib/icedos-caddy}" -export XDG_DATA_HOME="$caddy_home/data" XDG_CONFIG_HOME="$caddy_home/config" -mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" /nix/attic/storage /nix/nar-cache - -pids=() -# shellcheck disable=SC2329 # invoked indirectly via the trap below -cleanup() { - trap - TERM INT EXIT - [ "${#pids[@]}" -gt 0 ] && kill "${pids[@]}" 2>/dev/null - wait 2>/dev/null -} -trap cleanup TERM INT EXIT - -echo "icedos-stack: starting atticd (127.0.0.1:8080)" -@atticd@ -f @server@ --mode monolithic & -pids+=($!) - -# Wait ~15s for atticd's port so nginx/caddy don't 502 the first requests. -for _ in $(seq 1 30); do - (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null && { - exec 3>&- 3<&- - break - } - sleep 0.5 -done - -echo "icedos-stack: starting nginx (disk cache, 127.0.0.1:8081)" -@nginx@ -c @nginxconf@ -g 'daemon off;' & -pids+=($!) - -echo "icedos-stack: starting caddy (TLS edge, automatic HTTPS)" -@caddy@ run --config @caddyfile@ --adapter caddyfile & -pids+=($!) - -wait -n -echo "icedos-stack: a process exited — dropping the stack" >&2 -exit 1 diff --git a/state/tracked-inputs.json b/state/tracked-inputs.json index a846e38..c2eef8a 100644 --- a/state/tracked-inputs.json +++ b/state/tracked-inputs.json @@ -2,6 +2,6 @@ "nix-cachyos-kernel": "290b62ec266d15827ab79852483e434eeba912e0", "ambiled": "8a9a9042f77129ea5aded8a98f0804a673332c1b", "prefixer": "b7c0a01125ad1b908363d70a51731ad7303ede5e", - "plasmazones": "b2b73e5127f58d630df452d640de15eb8b58ed2c", + "plasmazones": "26fc9144b08591a371b64b9d36cc74ba233bd241", "jovian": "9ffc5dc5af266c2e44066f22e5496274cf93a1a6" } From bd4fe0577d2d8a8678894b2c87603264ae2b50e8 Mon Sep 17 00:00:00 2001 From: "icedos-ci-dispatch[bot]" <307682111+icedos-ci-dispatch[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:55:32 +0000 Subject: [PATCH 2/4] update(inputs): nix-cachyos-kernel nix-cachyos-kernel: 290b62ec266d -> f39e7d511ce7 --- state/tracked-inputs.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/state/tracked-inputs.json b/state/tracked-inputs.json index c2eef8a..faf5a02 100644 --- a/state/tracked-inputs.json +++ b/state/tracked-inputs.json @@ -1,7 +1,13 @@ { - "nix-cachyos-kernel": "290b62ec266d15827ab79852483e434eeba912e0", + "nix-cachyos-kernel": { + "rev": "1aaa1157d53a765dc30e8e9a59b18f667d15f923", + "repo": "" + }, "ambiled": "8a9a9042f77129ea5aded8a98f0804a673332c1b", "prefixer": "b7c0a01125ad1b908363d70a51731ad7303ede5e", - "plasmazones": "26fc9144b08591a371b64b9d36cc74ba233bd241", + "plasmazones": { + "rev": "5b77aa5845e777ca18e170324c5d1c401797d717", + "repo": "" + }, "jovian": "9ffc5dc5af266c2e44066f22e5496274cf93a1a6" } From 52881029ba627e2dfbf6b7fddacfdced4191da6d Mon Sep 17 00:00:00 2001 From: IceDBorn Date: Mon, 31 Aug 2026 12:29:51 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(update):=20read=20object-format=20track?= =?UTF-8?q?ed=20revs=20=E2=80=94=20jq=20crashed=20on=20{rev,repo}=20entrie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/auto-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-update.yml b/.github/workflows/auto-update.yml index ef697a1..f2a3d81 100644 --- a/.github/workflows/auto-update.yml +++ b/.github/workflows/auto-update.yml @@ -88,7 +88,7 @@ jobs: if [ -n "$new_rev" ]; then # The working file stays pristine; it is read, never mutated. - old_rev=$(jq -r --arg k "$input_key" '.[$k] // ""' state/tracked-inputs.json 2>/dev/null) + old_rev=$(jq -r --arg k "$input_key" 'if (.[$k] | type) == "object" then .[$k].rev else (.[$k] // "") end' state/tracked-inputs.json 2>/dev/null) if [ "$new_rev" != "$old_rev" ]; then changed_input_names+=("$name") From c01ac19b202a3214b6f4037f12dd7e0bd0af4ef6 Mon Sep 17 00:00:00 2001 From: "icedos-ci-dispatch[bot]" <307682111+icedos-ci-dispatch[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:33:14 +0000 Subject: [PATCH 4/4] update(nixpkgs): 9fbb54b33e91 -> d2f679497988 --- flake.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/flake.lock b/flake.lock index 79b5748..dae3ef8 100644 --- a/flake.lock +++ b/flake.lock @@ -3,16 +3,16 @@ "cache-server": { "flake": false, "locked": { - "lastModified": 1787752985, - "narHash": "sha256-7JWxzkZ+u9lP2wvBQ+256cUhGLcGfFhHZXjcV3Gcs6U=", + "lastModified": 1788159573, + "narHash": "sha256-KgFI7I6gGZKcfQIVCWEeTHaCCI3u+72/3Gh4RHhA7pI=", "owner": "icedos", "repo": "cache-server", - "rev": "30c17556cdabb624109eef1cd9ab03b63520eadd", + "rev": "b3d8060af644fc201b79f74aca15e12b2085714e", "type": "github" }, "original": { "owner": "icedos", - "ref": "key", + "ref": "cache", "repo": "cache-server", "type": "github" } @@ -25,11 +25,11 @@ ] }, "locked": { - "lastModified": 1787849045, - "narHash": "sha256-8FuFltMbLSJ4p/IjFHwE0D9QgA/d05QnGXJQ/FdbdsA=", + "lastModified": 1788125528, + "narHash": "sha256-7VoNdIPU5zlpD/uH+BLcf+tXoqFHLQwT7o5QmLfL23Y=", "owner": "IceDOS", "repo": "core", - "rev": "09a566fd23b4622fce3168216c5530a04fdce157", + "rev": "886127d7a0878d4326100768fa3cf48b53af1a16", "type": "github" }, "original": { @@ -40,11 +40,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1787736819, - "narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", + "lastModified": 1788039129, + "narHash": "sha256-pa4Q0qErvCvzCaaUph7Sm37RhR4xvPrYI8Lgz6k85+A=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", + "rev": "d2f67949798825fe853f7c5d0492b8bf016d3f88", "type": "github" }, "original": {