Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc74380fb7 |
@@ -0,0 +1,26 @@
|
||||
name: Blog release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Test the release contract
|
||||
run: cd delivery && python3 -m unittest discover -s tests -v
|
||||
|
||||
- name: Build, package and inspect the immutable release
|
||||
run: delivery/build.sh
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Deploy blog immutable package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
revision:
|
||||
description: Full source commit
|
||||
required: true
|
||||
artifact_url:
|
||||
description: Immutable Gitea generic-package URL
|
||||
required: true
|
||||
artifact_digest:
|
||||
description: sha256 digest of site.tar.gz
|
||||
required: true
|
||||
target:
|
||||
description: Provisioned site-scoped release root
|
||||
required: true
|
||||
verify_url:
|
||||
description: Public release marker URL
|
||||
required: true
|
||||
desired_commit:
|
||||
description: gitops-sandbox commit that selected this release
|
||||
required: true
|
||||
desired_generation:
|
||||
description: Ancestor count of desired_commit; the receiver refuses older selections
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Best-effort only. Ordering is enforced by the receiver, which applies a
|
||||
# selection only if its generation is not older than the one already live, so
|
||||
# a stale dispatch or a re-run of an old run cannot replace a newer release.
|
||||
concurrency:
|
||||
group: blog-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
|
||||
- name: Validate dispatch contract without network access
|
||||
env:
|
||||
REVISION: ${{ inputs.revision }}
|
||||
ARTIFACT_URL: ${{ inputs.artifact_url }}
|
||||
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
|
||||
TARGET: ${{ inputs.target }}
|
||||
VERIFY_URL: ${{ inputs.verify_url }}
|
||||
DESIRED_COMMIT: ${{ inputs.desired_commit }}
|
||||
DESIRED_GENERATION: ${{ inputs.desired_generation }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 delivery/scripts/deploy_contract.py validate \
|
||||
--revision "$REVISION" \
|
||||
--artifact-url "$ARTIFACT_URL" \
|
||||
--artifact-digest "$ARTIFACT_DIGEST" \
|
||||
--target "$TARGET" \
|
||||
--verify-url "$VERIFY_URL" \
|
||||
--site-config delivery/site.json \
|
||||
--desired-commit "$DESIRED_COMMIT" \
|
||||
--desired-generation "$DESIRED_GENERATION"
|
||||
|
||||
deploy:
|
||||
needs: contract
|
||||
if: ${{ vars.BLOG_STATIC_DEPLOY_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ACTIVATION: ${{ vars.BLOG_STATIC_DEPLOY_ENABLED }}
|
||||
REVISION: ${{ inputs.revision }}
|
||||
ARTIFACT_URL: ${{ inputs.artifact_url }}
|
||||
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
|
||||
TARGET: ${{ inputs.target }}
|
||||
VERIFY_URL: ${{ inputs.verify_url }}
|
||||
DESIRED_COMMIT: ${{ inputs.desired_commit }}
|
||||
DESIRED_GENERATION: ${{ inputs.desired_generation }}
|
||||
STATIC_PACKAGE_READ_USER: ${{ secrets.BLOG_PACKAGE_READ_USER }}
|
||||
STATIC_PACKAGE_READ_TOKEN: ${{ secrets.BLOG_PACKAGE_READ_TOKEN }}
|
||||
STATIC_DEPLOY_HOST: ${{ secrets.BLOG_STATIC_DEPLOY_HOST }}
|
||||
STATIC_DEPLOY_USER: ${{ secrets.BLOG_STATIC_DEPLOY_USER }}
|
||||
STATIC_DEPLOY_SSH_KEY: ${{ secrets.BLOG_STATIC_DEPLOY_SSH_KEY }}
|
||||
STATIC_DEPLOY_KNOWN_HOSTS: ${{ secrets.BLOG_STATIC_DEPLOY_KNOWN_HOSTS }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
|
||||
- name: Enforce reviewed deployment gate
|
||||
run: |
|
||||
set -eu
|
||||
test "$ACTIVATION" = true
|
||||
python3 delivery/scripts/delivery_gate.py static_deploy \
|
||||
--config delivery/.delivery/config.json
|
||||
test -n "$STATIC_DEPLOY_HOST"
|
||||
test -n "$STATIC_DEPLOY_USER"
|
||||
test -n "$STATIC_DEPLOY_SSH_KEY"
|
||||
test -n "$STATIC_DEPLOY_KNOWN_HOSTS"
|
||||
case "$STATIC_DEPLOY_HOST" in *[!A-Za-z0-9.-]*|'') exit 1;; esac
|
||||
case "$STATIC_DEPLOY_USER" in *[!A-Za-z0-9_-]*|'') exit 1;; esac
|
||||
|
||||
- name: Download and inspect the declared immutable artifact
|
||||
run: |
|
||||
set -eu
|
||||
python3 delivery/scripts/deploy_contract.py prepare \
|
||||
--revision "$REVISION" \
|
||||
--artifact-url "$ARTIFACT_URL" \
|
||||
--artifact-digest "$ARTIFACT_DIGEST" \
|
||||
--target "$TARGET" \
|
||||
--verify-url "$VERIFY_URL" \
|
||||
--site-config delivery/site.json \
|
||||
--desired-commit "$DESIRED_COMMIT" \
|
||||
--desired-generation "$DESIRED_GENERATION" \
|
||||
--archive delivery/.build/site.tar.gz \
|
||||
--checksum delivery/.build/site.tar.gz.sha256 \
|
||||
--request-token delivery/.build/deploy-request.token
|
||||
|
||||
- name: Send the artifact to the site-scoped forced command
|
||||
id: receive
|
||||
run: |
|
||||
set -eu
|
||||
umask 077
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$STATIC_DEPLOY_SSH_KEY" > "$HOME/.ssh/id_static_deploy"
|
||||
printf '%s\n' "$STATIC_DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
cat > "$HOME/.ssh/config" <<CONFIG
|
||||
Host static-release-target
|
||||
HostName $STATIC_DEPLOY_HOST
|
||||
User $STATIC_DEPLOY_USER
|
||||
IdentityFile $HOME/.ssh/id_static_deploy
|
||||
UserKnownHostsFile $HOME/.ssh/known_hosts
|
||||
IdentitiesOnly yes
|
||||
BatchMode yes
|
||||
StrictHostKeyChecking yes
|
||||
ConnectTimeout 10
|
||||
CONFIG
|
||||
request="$(cat delivery/.build/deploy-request.token)"
|
||||
status=0
|
||||
ssh static-release-target "static-release-receive $request" \
|
||||
< delivery/.build/site.tar.gz || status=$?
|
||||
# 3: the receiver already applied a newer selection; nothing changed.
|
||||
if [ "$status" -eq 3 ]; then
|
||||
echo "Superseded: generation $DESIRED_GENERATION ($DESIRED_COMMIT) is older than the live selection; nothing deployed."
|
||||
printf 'superseded=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
test "$status" -eq 0
|
||||
printf 'superseded=false\n' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Confirm public revision and digest
|
||||
if: ${{ steps.receive.outputs.superseded == 'false' }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 delivery/scripts/static_release.py verify \
|
||||
--url "$VERIFY_URL" \
|
||||
--revision "$REVISION" \
|
||||
--digest "$ARTIFACT_DIGEST"
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Publish blog immutable package
|
||||
|
||||
# Every main push builds and tests the release. Publication additionally needs
|
||||
# the activation variable, the reviewed repository gate and the scoped package
|
||||
# secrets; without them nothing is published. Publication never deploys:
|
||||
# selecting the release is a reviewed change to gitops-sandbox/stacks.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
revision:
|
||||
description: Full public/blog commit to publish
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: blog-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REQUESTED_REVISION: ${{ inputs.revision || github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: ${{ inputs.revision || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Validate requested source, tests and build
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$REQUESTED_REVISION"
|
||||
(cd delivery && python3 -m unittest discover -s tests -v)
|
||||
delivery/build.sh
|
||||
|
||||
publish:
|
||||
needs: validate
|
||||
if: ${{ vars.BLOG_PACKAGE_PUBLISH_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
ACTIVATION: ${{ vars.BLOG_PACKAGE_PUBLISH_ENABLED }}
|
||||
REQUESTED_REVISION: ${{ inputs.revision || github.sha }}
|
||||
STATIC_PACKAGE_USER: ${{ secrets.BLOG_PACKAGE_USER }}
|
||||
STATIC_PACKAGE_TOKEN: ${{ secrets.BLOG_PACKAGE_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: ${{ inputs.revision || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Enforce reviewed publication gate
|
||||
run: |
|
||||
set -eu
|
||||
test "$ACTIVATION" = true
|
||||
python3 delivery/scripts/delivery_gate.py package_publish \
|
||||
--config delivery/.delivery/config.json
|
||||
test -n "$STATIC_PACKAGE_USER"
|
||||
test -n "$STATIC_PACKAGE_TOKEN"
|
||||
|
||||
- name: Build and publish immutable package files
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$REQUESTED_REVISION"
|
||||
delivery/build.sh
|
||||
python3 delivery/scripts/generic_package.py \
|
||||
--registry "${{ gitea.server_url }}" \
|
||||
--owner public \
|
||||
--package blog-site \
|
||||
--revision "$REQUESTED_REVISION" \
|
||||
--archive delivery/.build/site.tar.gz \
|
||||
--checksum delivery/.build/site.tar.gz.sha256 \
|
||||
--coordinate delivery/.build/release-coordinate.json \
|
||||
> delivery/.build/published.json
|
||||
cat delivery/.build/published.json
|
||||
echo "Select this release with a reviewed gitops-sandbox stacks.yml change:"
|
||||
python3 - delivery/.build/published.json <<'PY'
|
||||
import json, sys
|
||||
c = json.load(open(sys.argv[1]))
|
||||
print(" blog:")
|
||||
for key in ("revision", "artifact_url", "artifact_digest"):
|
||||
print(f" {key}: {c[key]}")
|
||||
PY
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated output and caches. Releases are immutable packages built in CI;
|
||||
# nothing here is published from a checkout.
|
||||
/public/
|
||||
/resources/_gen/
|
||||
.hugo_build.lock
|
||||
/delivery/.build/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,47 @@
|
||||
# blog
|
||||
|
||||
Hugo source for the LibreTECH blog with the
|
||||
[Pickles](https://github.com/mismith0227/hugo_theme_pickles) theme as a pinned
|
||||
submodule.
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://git.librete.ch/public/blog.git
|
||||
hugo server # local preview
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
The blog is published as an immutable release, never from a checkout. The
|
||||
served tree contains only the built site and its release marker: no source,
|
||||
`.git`, credentials or links outside the release.
|
||||
|
||||
1. `delivery/build.sh` builds the checked-out commit with the pinned Hugo
|
||||
release (verified by SHA-256) and packages it deterministically as
|
||||
`delivery/.build/site.tar.gz`. A rebuild of the same commit has the same
|
||||
digest.
|
||||
2. **Publish blog immutable package** (`publish-blog.yml`) runs on every `main`
|
||||
push. With `BLOG_PACKAGE_PUBLISH_ENABLED=true`, the reviewed
|
||||
`package_publish` gate and the `BLOG_PACKAGE_USER`/`BLOG_PACKAGE_TOKEN`
|
||||
secrets, it publishes `public/blog-site/<revision>/` to the Gitea package
|
||||
registry and prints the `stacks.yml` coordinate.
|
||||
3. A reviewed pull request in
|
||||
[`libretech/gitops-sandbox`](https://git.librete.ch/libretech/gitops-sandbox)
|
||||
puts that coordinate in the `blog` record. Merging it dispatches
|
||||
**Deploy blog immutable package** (`deploy-blog.yml`).
|
||||
4. The deploy workflow re-downloads and inspects the package, then streams it
|
||||
to the site-scoped receiver on Netcup, which activates
|
||||
`/srv/libretech-static/blog/current` atomically and verifies
|
||||
`https://blog.static.librete.ch/.well-known/release.json`. A stale or
|
||||
re-run dispatch older than the live selection ends as *superseded* and
|
||||
changes nothing.
|
||||
|
||||
Rollback is a reviewed `stacks.yml` change back to an earlier complete
|
||||
coordinate. See the
|
||||
[operations guide](https://git.librete.ch/libretech/gitops-sandbox/src/branch/main/OPERATIONS.md).
|
||||
|
||||
`delivery/scripts/` is a copy of the receiver modules from
|
||||
`libretech/librete.ch` (`donatella/scripts/`); update them together.
|
||||
|
||||
`blog.librete.ch` itself is still served from the earlier Uberspace in-place
|
||||
build (`publishDir` in `hugo.toml`) until its DNS cut-over; the release
|
||||
workflows override `publishDir` and never write there.
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":1,"package_publish":true,"static_deploy":true}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Build the checked-out commit into an immutable release archive:
|
||||
# delivery/.build/site.tar.gz and its .sha256. Needs full Git history
|
||||
# (enableGitInfo) and the theme submodule.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
revision="$(git rev-parse HEAD)"
|
||||
test -f themes/hugo_theme_pickles/theme.toml
|
||||
build="$PWD/delivery/.build"
|
||||
rm -rf "$build"
|
||||
mkdir -p "$build"
|
||||
# CI uses the pinned release; HUGO may name a local binary for previews only.
|
||||
hugo="${HUGO:-$(python3 delivery/scripts/fetch_hugo.py --destination "$build/bin")}"
|
||||
"$hugo" version
|
||||
# --destination and --cacheDir override the legacy in-place publishDir.
|
||||
"$hugo" --source . --environment production \
|
||||
--destination "$build/hugo" --cacheDir "$build/cache" --cleanDestinationDir
|
||||
python3 delivery/scripts/static_release.py build \
|
||||
--source "$build/hugo" --output "$build/dist" --revision "$revision"
|
||||
python3 delivery/scripts/static_release.py package \
|
||||
--directory "$build/dist" \
|
||||
--archive "$build/site.tar.gz" \
|
||||
--checksum "$build/site.tar.gz.sha256"
|
||||
python3 delivery/scripts/static_release.py inspect \
|
||||
--archive "$build/site.tar.gz" \
|
||||
--checksum "$build/site.tar.gz.sha256" \
|
||||
--revision "$revision"
|
||||
cat "$build/site.tar.gz.sha256"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail closed unless a delivery capability is enabled in reviewed config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CAPABILITIES = {"package_publish", "static_deploy"}
|
||||
|
||||
|
||||
class GateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def check(config_path: Path, capability: str) -> None:
|
||||
if capability not in CAPABILITIES:
|
||||
raise GateError(f"unsupported delivery capability: {capability}")
|
||||
try:
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise GateError(f"invalid delivery configuration: {exc}") from exc
|
||||
if set(config) != {"schema", *CAPABILITIES} or config["schema"] != 1:
|
||||
raise GateError("delivery configuration has an unsupported shape or schema")
|
||||
if any(type(config[name]) is not bool for name in CAPABILITIES):
|
||||
raise GateError("delivery capability values must be booleans")
|
||||
if not config[capability]:
|
||||
raise GateError(f"{capability} is disabled in reviewed repository configuration")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("capability", choices=sorted(CAPABILITIES))
|
||||
parser.add_argument("--config", type=Path, default=Path(".delivery/config.json"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
check(args.config, args.capability)
|
||||
except GateError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and prepare the project-owned static deployment request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
from .generic_package import PackageError, package_file_url, request
|
||||
from .static_release import (
|
||||
ReleaseError,
|
||||
inspect_archive,
|
||||
validate_digest,
|
||||
validate_order,
|
||||
validate_revision,
|
||||
)
|
||||
except ImportError: # Direct script execution adds scripts/ to sys.path.
|
||||
from generic_package import PackageError, package_file_url, request
|
||||
from static_release import (
|
||||
ReleaseError,
|
||||
inspect_archive,
|
||||
validate_digest,
|
||||
validate_order,
|
||||
validate_revision,
|
||||
)
|
||||
|
||||
|
||||
REGISTRY = "https://git.librete.ch"
|
||||
OWNER = "libretech"
|
||||
PACKAGE = "donatella-site"
|
||||
PUBLIC_HOST = "donatella.static.librete.ch"
|
||||
TARGET = "/srv/libretech-static/donatella"
|
||||
SERVE_ROOT = f"{TARGET}/current"
|
||||
VERIFY_URL = f"https://{PUBLIC_HOST}/.well-known/release.json"
|
||||
RELEASE_FIELDS = ("revision", "artifact_url", "artifact_digest", "target", "verify_url")
|
||||
ORDER_FIELDS = ("desired_commit", "desired_generation")
|
||||
SITE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
|
||||
class ContractError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Site:
|
||||
"""One enrolled static site: its package, release root and public marker."""
|
||||
|
||||
name: str
|
||||
owner: str
|
||||
package: str
|
||||
target: str
|
||||
verify_url: str
|
||||
registry: str = REGISTRY
|
||||
|
||||
|
||||
DONATELLA = Site("donatella", OWNER, PACKAGE, TARGET, VERIFY_URL)
|
||||
|
||||
|
||||
def load_site(path: Path | None) -> Site:
|
||||
"""Read a reviewed site.json; without one, the receiver serves Donatella."""
|
||||
if path is None:
|
||||
return DONATELLA
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"invalid site configuration: {exc}") from exc
|
||||
fields = {"schema", "name", "owner", "package", "target", "verify_url"}
|
||||
if not isinstance(document, dict) or set(document) != fields or document["schema"] != 1:
|
||||
raise ContractError("site configuration has an unsupported shape or schema")
|
||||
site = Site(**{key: document[key] for key in fields - {"schema"}})
|
||||
if not all(isinstance(value, str) for value in vars(site).values()):
|
||||
raise ContractError("site configuration values must be strings")
|
||||
if not SITE_NAME_RE.fullmatch(site.name) or not SITE_NAME_RE.fullmatch(site.owner):
|
||||
raise ContractError("site name and owner must be lowercase identifiers")
|
||||
if not SITE_NAME_RE.fullmatch(site.package):
|
||||
raise ContractError("package must be a lowercase identifier")
|
||||
if site.target != f"/srv/libretech-static/{site.name}":
|
||||
raise ContractError("site target must be /srv/libretech-static/<name>")
|
||||
if not site.verify_url.startswith("https://") or not site.verify_url.endswith(
|
||||
"/.well-known/release.json",
|
||||
):
|
||||
raise ContractError("site verify_url must be an HTTPS release marker")
|
||||
return site
|
||||
|
||||
|
||||
def validate_contract(
|
||||
*,
|
||||
revision: str,
|
||||
artifact_url: str,
|
||||
artifact_digest: str,
|
||||
target: str,
|
||||
verify_url: str,
|
||||
registry: str | None = None,
|
||||
allow_http: bool = False,
|
||||
site: Site = DONATELLA,
|
||||
) -> dict[str, str]:
|
||||
values = {
|
||||
"revision": revision,
|
||||
"artifact_url": artifact_url,
|
||||
"artifact_digest": artifact_digest,
|
||||
"target": target,
|
||||
"verify_url": verify_url,
|
||||
}
|
||||
if any(not isinstance(value, str) or not value for value in values.values()):
|
||||
raise ContractError("all deployment contract fields must be non-empty strings")
|
||||
try:
|
||||
validate_revision(revision)
|
||||
validate_digest(artifact_digest)
|
||||
expected_url = package_file_url(
|
||||
registry or site.registry,
|
||||
site.owner,
|
||||
site.package,
|
||||
revision,
|
||||
"site.tar.gz",
|
||||
allow_http=allow_http,
|
||||
)
|
||||
except (ReleaseError, PackageError) as exc:
|
||||
raise ContractError(str(exc)) from exc
|
||||
if artifact_url != expected_url:
|
||||
raise ContractError("artifact URL is not the immutable project package for this revision")
|
||||
if target != site.target:
|
||||
raise ContractError(f"target must be exactly {site.target}")
|
||||
if verify_url != site.verify_url:
|
||||
raise ContractError(f"verification URL must be exactly {site.verify_url}")
|
||||
return values
|
||||
|
||||
|
||||
def validate_request(*, site: Site = DONATELLA, **fields) -> dict[str, str]:
|
||||
"""Validate a release coordinate plus the GitOps selection that chose it."""
|
||||
if set(fields) != set(RELEASE_FIELDS) | set(ORDER_FIELDS):
|
||||
raise ContractError("deployment request must contain exactly the seven declared fields")
|
||||
contract = validate_contract(site=site, **{key: fields[key] for key in RELEASE_FIELDS})
|
||||
order = {key: fields[key] for key in ORDER_FIELDS}
|
||||
try:
|
||||
validate_order(order)
|
||||
except ReleaseError as exc:
|
||||
raise ContractError(str(exc)) from exc
|
||||
return {**contract, **order}
|
||||
|
||||
|
||||
def order_of(request: dict[str, str]) -> dict[str, str]:
|
||||
return {key: request[key] for key in ORDER_FIELDS}
|
||||
|
||||
|
||||
def request_token(contract: dict[str, str]) -> str:
|
||||
encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(contract, separators=(",", ":")).encode("utf-8"),
|
||||
).decode("ascii")
|
||||
return encoded.rstrip("=")
|
||||
|
||||
|
||||
def prepare(
|
||||
contract: dict[str, str],
|
||||
*,
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
token_output: Path,
|
||||
username: str | None = None,
|
||||
package_token: str | None = None,
|
||||
) -> None:
|
||||
status, content = request(
|
||||
"GET",
|
||||
contract["artifact_url"],
|
||||
username=username,
|
||||
token=package_token,
|
||||
)
|
||||
if status != 200:
|
||||
raise ContractError(f"artifact download returned HTTP {status}")
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
checksum.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive.write_bytes(content)
|
||||
checksum.write_text(
|
||||
f"{contract['artifact_digest']} {archive.name}\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
try:
|
||||
inspect_archive(archive, checksum, contract["revision"])
|
||||
except ReleaseError as exc:
|
||||
raise ContractError(str(exc)) from exc
|
||||
token_output.write_text(request_token(contract) + "\n", encoding="ascii")
|
||||
|
||||
|
||||
def add_contract_arguments(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument("--revision", required=True)
|
||||
command.add_argument("--artifact-url", required=True)
|
||||
command.add_argument("--artifact-digest", required=True)
|
||||
command.add_argument("--target", required=True)
|
||||
command.add_argument("--verify-url", required=True)
|
||||
command.add_argument("--desired-commit", required=True)
|
||||
command.add_argument("--desired-generation", required=True)
|
||||
command.add_argument("--site-config", type=Path)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
command = argparse.ArgumentParser(description=__doc__)
|
||||
sub = command.add_subparsers(dest="command", required=True)
|
||||
validate_cmd = sub.add_parser("validate")
|
||||
add_contract_arguments(validate_cmd)
|
||||
prepare_cmd = sub.add_parser("prepare")
|
||||
add_contract_arguments(prepare_cmd)
|
||||
prepare_cmd.add_argument("--archive", type=Path, required=True)
|
||||
prepare_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
prepare_cmd.add_argument("--request-token", type=Path, required=True)
|
||||
return command
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
contract = validate_request(
|
||||
site=load_site(args.site_config),
|
||||
revision=args.revision,
|
||||
artifact_url=args.artifact_url,
|
||||
artifact_digest=args.artifact_digest,
|
||||
target=args.target,
|
||||
verify_url=args.verify_url,
|
||||
desired_commit=args.desired_commit,
|
||||
desired_generation=args.desired_generation,
|
||||
)
|
||||
if args.command == "prepare":
|
||||
prepare(
|
||||
contract,
|
||||
archive=args.archive,
|
||||
checksum=args.checksum,
|
||||
token_output=args.request_token,
|
||||
username=os.environ.get("STATIC_PACKAGE_READ_USER") or None,
|
||||
package_token=os.environ.get("STATIC_PACKAGE_READ_TOKEN") or None,
|
||||
)
|
||||
else:
|
||||
print(json.dumps(contract, separators=(",", ":")))
|
||||
except (ContractError, PackageError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the pinned Hugo release after verifying its published SHA-256."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
|
||||
VERSION = "0.150.1"
|
||||
URL = (
|
||||
f"https://github.com/gohugoio/hugo/releases/download/v{VERSION}/"
|
||||
f"hugo_extended_{VERSION}_linux-amd64.tar.gz"
|
||||
)
|
||||
SHA256 = "e1248fa077d99232794e38df5ec533494993aafafca1ae3e331a4ed629079ed6"
|
||||
MAX_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
class FetchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def extract_hugo(archive: bytes, expected: str, destination: Path) -> Path:
|
||||
actual = hashlib.sha256(archive).hexdigest()
|
||||
if actual != expected:
|
||||
raise FetchError(f"Hugo archive digest mismatch: {actual}")
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
|
||||
member = tar.getmember("hugo")
|
||||
if not member.isfile():
|
||||
raise FetchError("Hugo archive member is not a regular file")
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise FetchError("cannot read the Hugo binary")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
binary = destination / "hugo"
|
||||
binary.write_bytes(source.read())
|
||||
binary.chmod(0o755)
|
||||
return binary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--destination", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with urllib.request.urlopen(URL, timeout=60) as response:
|
||||
archive = response.read(MAX_BYTES + 1)
|
||||
if len(archive) > MAX_BYTES:
|
||||
raise FetchError("Hugo archive exceeds the size limit")
|
||||
print(extract_hugo(archive, SHA256, args.destination))
|
||||
except (OSError, KeyError, tarfile.TarError, FetchError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a static release as immutable files in Gitea's generic registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
from .static_release import ReleaseError, inspect_archive, validate_revision
|
||||
except ImportError: # Direct script execution adds scripts/ to sys.path.
|
||||
from static_release import ReleaseError, inspect_archive, validate_revision
|
||||
|
||||
|
||||
COMPONENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
||||
ARCHIVE_NAME = "site.tar.gz"
|
||||
CHECKSUM_NAME = "site.tar.gz.sha256"
|
||||
COORDINATE_NAME = "release-coordinate.json"
|
||||
MAX_RESPONSE_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
class PackageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_component(value: str, label: str) -> None:
|
||||
if not COMPONENT_RE.fullmatch(value):
|
||||
raise PackageError(f"{label} contains unsupported characters")
|
||||
|
||||
|
||||
def validate_registry(registry: str, *, allow_http: bool = False) -> str:
|
||||
parsed = urllib.parse.urlsplit(registry)
|
||||
allowed_schemes = {"https"} | ({"http"} if allow_http else set())
|
||||
if (
|
||||
parsed.scheme not in allowed_schemes
|
||||
or not parsed.netloc
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise PackageError("registry must be an HTTPS origin without credentials, query or fragment")
|
||||
return registry.rstrip("/")
|
||||
|
||||
|
||||
def package_file_url(
|
||||
registry: str,
|
||||
owner: str,
|
||||
package: str,
|
||||
version: str,
|
||||
filename: str,
|
||||
*,
|
||||
allow_http: bool = False,
|
||||
) -> str:
|
||||
registry = validate_registry(registry, allow_http=allow_http)
|
||||
for value, label in ((owner, "owner"), (package, "package"), (filename, "filename")):
|
||||
validate_component(value, label)
|
||||
validate_revision(version)
|
||||
components = [owner, package, version, filename]
|
||||
owner_part, package_part, version_part, filename_part = (
|
||||
urllib.parse.quote(value, safe="") for value in components
|
||||
)
|
||||
return (
|
||||
f"{registry}/api/packages/{owner_part}/generic/"
|
||||
f"{package_part}/{version_part}/{filename_part}"
|
||||
)
|
||||
|
||||
|
||||
def authorization(username: str | None, token: str | None) -> str | None:
|
||||
if bool(username) != bool(token):
|
||||
raise PackageError("package username and token must be supplied together")
|
||||
if not username:
|
||||
return None
|
||||
encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii")
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
username: str | None,
|
||||
token: str | None,
|
||||
data: bytes | None = None,
|
||||
timeout: float = 300.0,
|
||||
) -> tuple[int, bytes]:
|
||||
headers = {"User-Agent": "donatella-static-publisher/1"}
|
||||
auth = authorization(username, token)
|
||||
if auth:
|
||||
headers["Authorization"] = auth
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/octet-stream"
|
||||
outgoing = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(outgoing, timeout=timeout) as response:
|
||||
content = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(content) > MAX_RESPONSE_BYTES:
|
||||
raise PackageError("package registry response exceeds the size limit")
|
||||
return response.status, content
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
content = exc.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(content) > MAX_RESPONSE_BYTES:
|
||||
raise PackageError("package registry error response exceeds the size limit")
|
||||
return exc.code, content
|
||||
finally:
|
||||
exc.close()
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise PackageError(f"package registry request failed: {exc}") from exc
|
||||
|
||||
|
||||
def put_immutable(
|
||||
url: str,
|
||||
content: bytes,
|
||||
*,
|
||||
username: str,
|
||||
token: str,
|
||||
timeout: float = 300.0,
|
||||
) -> None:
|
||||
status, existing = request("GET", url, username=username, token=token, timeout=timeout)
|
||||
if status == 200:
|
||||
if existing != content:
|
||||
raise PackageError(f"immutable package file already exists with different content: {url}")
|
||||
return
|
||||
if status != 404:
|
||||
raise PackageError(f"package preflight returned HTTP {status}: {url}")
|
||||
|
||||
status, _ = request(
|
||||
"PUT",
|
||||
url,
|
||||
username=username,
|
||||
token=token,
|
||||
data=content,
|
||||
timeout=timeout,
|
||||
)
|
||||
if status not in {201, 409}:
|
||||
raise PackageError(f"package upload returned HTTP {status}: {url}")
|
||||
|
||||
status, published = request("GET", url, username=username, token=token, timeout=timeout)
|
||||
if status != 200 or published != content:
|
||||
raise PackageError(f"published package file did not verify byte-for-byte: {url}")
|
||||
|
||||
|
||||
def coordinate_bytes(revision: str, artifact_url: str, digest: str) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"revision": revision,
|
||||
"artifact_url": artifact_url,
|
||||
"artifact_digest": digest,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def publish_release(
|
||||
*,
|
||||
registry: str,
|
||||
owner: str,
|
||||
package: str,
|
||||
revision: str,
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
coordinate: Path,
|
||||
username: str,
|
||||
token: str,
|
||||
allow_http: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if not username or not token:
|
||||
raise PackageError("package username and token are required")
|
||||
if archive.name != ARCHIVE_NAME or checksum.name != CHECKSUM_NAME:
|
||||
raise PackageError(f"release files must be named {ARCHIVE_NAME} and {CHECKSUM_NAME}")
|
||||
try:
|
||||
_, _, digest = inspect_archive(archive, checksum, revision)
|
||||
except ReleaseError as exc:
|
||||
raise PackageError(str(exc)) from exc
|
||||
|
||||
artifact_url = package_file_url(
|
||||
registry,
|
||||
owner,
|
||||
package,
|
||||
revision,
|
||||
ARCHIVE_NAME,
|
||||
allow_http=allow_http,
|
||||
)
|
||||
checksum_url = package_file_url(
|
||||
registry,
|
||||
owner,
|
||||
package,
|
||||
revision,
|
||||
CHECKSUM_NAME,
|
||||
allow_http=allow_http,
|
||||
)
|
||||
coordinate_url = package_file_url(
|
||||
registry,
|
||||
owner,
|
||||
package,
|
||||
revision,
|
||||
COORDINATE_NAME,
|
||||
allow_http=allow_http,
|
||||
)
|
||||
payload = coordinate_bytes(revision, artifact_url, digest)
|
||||
if coordinate.resolve() in {archive.resolve(), checksum.resolve()}:
|
||||
raise PackageError("coordinate output must be separate from the archive and checksum")
|
||||
coordinate.parent.mkdir(parents=True, exist_ok=True)
|
||||
coordinate.write_bytes(payload)
|
||||
|
||||
for url, content in (
|
||||
(artifact_url, archive.read_bytes()),
|
||||
(checksum_url, checksum.read_bytes()),
|
||||
(coordinate_url, payload),
|
||||
):
|
||||
put_immutable(url, content, username=username, token=token)
|
||||
|
||||
return {
|
||||
"revision": revision,
|
||||
"artifact_url": artifact_url,
|
||||
"artifact_digest": digest,
|
||||
}
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
command = argparse.ArgumentParser(description=__doc__)
|
||||
command.add_argument("--registry", required=True)
|
||||
command.add_argument("--owner", required=True)
|
||||
command.add_argument("--package", required=True)
|
||||
command.add_argument("--revision", required=True)
|
||||
command.add_argument("--archive", type=Path, required=True)
|
||||
command.add_argument("--checksum", type=Path, required=True)
|
||||
command.add_argument("--coordinate", type=Path, required=True)
|
||||
return command
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
result = publish_release(
|
||||
registry=args.registry,
|
||||
owner=args.owner,
|
||||
package=args.package,
|
||||
revision=args.revision,
|
||||
archive=args.archive,
|
||||
checksum=args.checksum,
|
||||
coordinate=args.coordinate,
|
||||
username=os.environ.get("STATIC_PACKAGE_USER", ""),
|
||||
token=os.environ.get("STATIC_PACKAGE_TOKEN", ""),
|
||||
)
|
||||
except PackageError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+646
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build, inspect, publish, verify and roll back trusted static releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
REVISION_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
GENERATION_RE = re.compile(r"^[1-9][0-9]{0,11}$")
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
MAX_FILES = 10_000
|
||||
# The Sarah Weisman portfolio contains 164,651,446 bytes of site assets.
|
||||
# Keep one bounded limit for the archive, receiver and extracted release.
|
||||
MAX_BYTES = 200 * 1024 * 1024
|
||||
SOURCE_MARKER = Path(".release-source.json")
|
||||
PUBLIC_MARKER = Path(".well-known/release.json")
|
||||
# Last desired-state selection this target applied, outside the served tree.
|
||||
DESIRED_STATE = Path(".desired-state.json")
|
||||
FORBIDDEN_PARTS = {".git", ".hg", ".svn"}
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SupersededError(ReleaseError):
|
||||
"""A newer desired-state generation has already been applied here."""
|
||||
|
||||
|
||||
def validate_revision(revision: str) -> None:
|
||||
if not REVISION_RE.fullmatch(revision):
|
||||
raise ReleaseError("revision must be a full 40-character lowercase Git SHA")
|
||||
|
||||
|
||||
def validate_digest(digest: str) -> None:
|
||||
if not DIGEST_RE.fullmatch(digest):
|
||||
raise ReleaseError("digest must have the form sha256:<64 lowercase hex characters>")
|
||||
|
||||
|
||||
def validate_order(order: dict) -> dict:
|
||||
"""Validate the GitOps selection that requested a release.
|
||||
|
||||
``desired_generation`` is the ancestor count of ``desired_commit`` on the
|
||||
GitOps main branch. Main only moves to descendants, so a later selection
|
||||
always has a larger generation than an earlier one.
|
||||
"""
|
||||
if not isinstance(order, dict) or set(order) != {"desired_commit", "desired_generation"}:
|
||||
raise ReleaseError("order must contain exactly desired_commit and desired_generation")
|
||||
if not isinstance(order["desired_commit"], str) or not REVISION_RE.fullmatch(order["desired_commit"]):
|
||||
raise ReleaseError("desired_commit must be a full 40-character lowercase Git SHA")
|
||||
if not isinstance(order["desired_generation"], str) or not GENERATION_RE.fullmatch(order["desired_generation"]):
|
||||
raise ReleaseError("desired_generation must be a positive decimal integer without leading zeros")
|
||||
return order
|
||||
|
||||
|
||||
def load_desired_state(root: Path) -> dict | None:
|
||||
path = root / DESIRED_STATE
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return None
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseError("desired-state record must be a regular file")
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid desired-state record: {exc}") from exc
|
||||
if not isinstance(state, dict) or set(state) != {
|
||||
"desired_commit", "desired_generation", "revision", "artifact_digest",
|
||||
}:
|
||||
raise ReleaseError("desired-state record has an unsupported shape")
|
||||
validate_order({key: state[key] for key in ("desired_commit", "desired_generation")})
|
||||
validate_revision(state["revision"])
|
||||
validate_digest(state["artifact_digest"])
|
||||
return state
|
||||
|
||||
|
||||
def check_order(root: Path, revision: str, digest: str, order: dict) -> None:
|
||||
"""Refuse a request whose selection is older than the one applied here.
|
||||
|
||||
An identical request (same generation, commit and release) is a retry of
|
||||
the accepted selection and may be applied again.
|
||||
"""
|
||||
state = load_desired_state(root)
|
||||
if state is None:
|
||||
return
|
||||
requested = int(order["desired_generation"])
|
||||
applied = int(state["desired_generation"])
|
||||
if requested < applied:
|
||||
raise SupersededError(
|
||||
f"superseded: generation {requested} ({order['desired_commit']}) is older than "
|
||||
f"applied generation {applied} ({state['desired_commit']})"
|
||||
)
|
||||
if requested == applied and (
|
||||
order["desired_commit"] != state["desired_commit"]
|
||||
or revision != state["revision"]
|
||||
or digest != state["artifact_digest"]
|
||||
):
|
||||
raise ReleaseError(
|
||||
f"generation {requested} was already applied with a different selection"
|
||||
)
|
||||
|
||||
|
||||
def write_desired_state(root: Path, revision: str, digest: str, order: dict) -> None:
|
||||
state = {**order, "revision": revision, "artifact_digest": digest}
|
||||
temporary = root / f".desired-state.next-{os.getpid()}"
|
||||
temporary.write_text(json.dumps(state, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, root / DESIRED_STATE)
|
||||
|
||||
|
||||
def validate_site_path(path: PurePosixPath) -> None:
|
||||
if any(part in FORBIDDEN_PARTS for part in path.parts):
|
||||
raise ReleaseError(f"version-control metadata is forbidden: {path}")
|
||||
filename = path.name.lower()
|
||||
if filename == ".env" or filename.startswith(".env.") or filename in {"id_rsa", "id_ed25519"}:
|
||||
raise ReleaseError(f"common secret filename is forbidden: {path}")
|
||||
|
||||
|
||||
def iter_files(root: Path):
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = PurePosixPath(path.relative_to(root).as_posix())
|
||||
validate_site_path(relative)
|
||||
if path.is_symlink():
|
||||
raise ReleaseError(f"symlinks are forbidden: {relative}")
|
||||
if path.is_file():
|
||||
yield path
|
||||
elif not path.is_dir():
|
||||
raise ReleaseError(f"unsupported filesystem entry: {relative}")
|
||||
|
||||
|
||||
def validate_tree(root: Path) -> None:
|
||||
count = 0
|
||||
size = 0
|
||||
for path in iter_files(root):
|
||||
count += 1
|
||||
size += path.stat().st_size
|
||||
if count > MAX_FILES or size > MAX_BYTES:
|
||||
raise ReleaseError("site exceeds the file-count or uncompressed-size limit")
|
||||
if not (root / "index.html").is_file():
|
||||
raise ReleaseError("release has no index.html")
|
||||
|
||||
|
||||
def load_source_marker(root: Path) -> dict:
|
||||
try:
|
||||
marker = json.loads((root / SOURCE_MARKER).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid or missing source marker: {exc}") from exc
|
||||
if set(marker) != {"revision"}:
|
||||
raise ReleaseError("source marker must contain only the revision")
|
||||
validate_revision(marker["revision"])
|
||||
validate_tree(root)
|
||||
return marker
|
||||
|
||||
|
||||
def load_public_marker(root: Path) -> dict:
|
||||
try:
|
||||
marker = json.loads((root / PUBLIC_MARKER).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid or missing public release marker: {exc}") from exc
|
||||
if set(marker) != {"revision", "artifact_digest"}:
|
||||
raise ReleaseError("public marker must contain revision and artifact_digest")
|
||||
validate_revision(marker["revision"])
|
||||
validate_digest(marker["artifact_digest"])
|
||||
return marker
|
||||
|
||||
|
||||
def build(source: Path, output: Path, revision: str) -> None:
|
||||
validate_revision(revision)
|
||||
if not source.is_dir():
|
||||
raise ReleaseError(f"source directory does not exist: {source}")
|
||||
if (source / SOURCE_MARKER).exists() or (source / PUBLIC_MARKER).exists():
|
||||
raise ReleaseError("source must not provide reserved release markers")
|
||||
validate_tree(source)
|
||||
|
||||
source_root = source.resolve()
|
||||
output_root = output.resolve()
|
||||
if (
|
||||
output_root == source_root
|
||||
or output_root.is_relative_to(source_root)
|
||||
or source_root.is_relative_to(output_root)
|
||||
):
|
||||
raise ReleaseError("build output and source must be separate directory trees")
|
||||
|
||||
if output.exists():
|
||||
if output.is_symlink() or not output.is_dir():
|
||||
raise ReleaseError(f"build output must be a directory: {output}")
|
||||
shutil.rmtree(output)
|
||||
output.mkdir(parents=True)
|
||||
for path in iter_files(source):
|
||||
target = output / path.relative_to(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, target)
|
||||
target.chmod(0o644)
|
||||
(output / SOURCE_MARKER).write_text(
|
||||
json.dumps({"revision": revision}, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
load_source_marker(output)
|
||||
|
||||
|
||||
def package(directory: Path, archive: Path, checksum: Path) -> str:
|
||||
load_source_marker(directory)
|
||||
release_root = directory.resolve()
|
||||
if archive.resolve().is_relative_to(release_root) or checksum.resolve().is_relative_to(release_root):
|
||||
raise ReleaseError("archive and checksum must be written outside the release directory")
|
||||
if archive.resolve() == checksum.resolve():
|
||||
raise ReleaseError("archive and checksum paths must differ")
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open("wb") as raw:
|
||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in iter_files(directory):
|
||||
relative = path.relative_to(directory).as_posix()
|
||||
info = tar.gettarinfo(str(path), arcname=relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mode = 0o644
|
||||
info.mtime = 0
|
||||
with path.open("rb") as source:
|
||||
tar.addfile(info, source)
|
||||
digest = "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
checksum.write_text(f"{digest} {archive.name}\n", encoding="ascii")
|
||||
return digest
|
||||
|
||||
|
||||
def expected_archive_digest(checksum: Path, archive: Path) -> str:
|
||||
try:
|
||||
fields = checksum.read_text(encoding="ascii").strip().split()
|
||||
except OSError as exc:
|
||||
raise ReleaseError(f"cannot read checksum: {exc}") from exc
|
||||
if len(fields) != 2 or fields[1] != archive.name:
|
||||
raise ReleaseError("checksum file must contain 'sha256:<digest> <archive-name>'")
|
||||
validate_digest(fields[0])
|
||||
return fields[0]
|
||||
|
||||
|
||||
def inspect_archive(
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
revision: str,
|
||||
) -> tuple[bytes, list[tarfile.TarInfo], str]:
|
||||
validate_revision(revision)
|
||||
expected = expected_archive_digest(checksum, archive)
|
||||
try:
|
||||
if archive.stat().st_size > MAX_BYTES:
|
||||
raise ReleaseError("compressed archive exceeds the size limit")
|
||||
archive_data = archive.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ReleaseError(f"cannot read archive: {exc}") from exc
|
||||
actual = "sha256:" + hashlib.sha256(archive_data).hexdigest()
|
||||
if actual != expected:
|
||||
raise ReleaseError("archive digest mismatch")
|
||||
|
||||
members: list[tarfile.TarInfo] = []
|
||||
member_names: set[str] = set()
|
||||
total = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or not path.parts or any(part in ("", ".", "..") for part in path.parts):
|
||||
raise ReleaseError(f"unsafe archive path: {member.name!r}")
|
||||
validate_site_path(path)
|
||||
if not member.isfile():
|
||||
raise ReleaseError(f"archive may contain regular files only: {member.name!r}")
|
||||
if member.name in member_names:
|
||||
raise ReleaseError(f"duplicate archive path: {member.name!r}")
|
||||
total += member.size
|
||||
members.append(member)
|
||||
member_names.add(member.name)
|
||||
if len(members) > MAX_FILES or total > MAX_BYTES:
|
||||
raise ReleaseError("archive exceeds the file-count or uncompressed-size limit")
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise ReleaseError(f"invalid archive: {exc}") from exc
|
||||
|
||||
names = {member.name for member in members}
|
||||
if "index.html" not in names or SOURCE_MARKER.as_posix() not in names:
|
||||
raise ReleaseError("archive must contain index.html and .release-source.json")
|
||||
if PUBLIC_MARKER.as_posix() in names:
|
||||
raise ReleaseError("artifact must not provide the publisher-owned public marker")
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
destination = Path(temp)
|
||||
extract_members(archive_data, members, destination)
|
||||
marker = load_source_marker(destination)
|
||||
if marker["revision"] != revision:
|
||||
raise ReleaseError("archive revision does not match the requested revision")
|
||||
return archive_data, members, actual
|
||||
|
||||
|
||||
def extract_members(archive_data: bytes, members: list[tarfile.TarInfo], destination: Path) -> None:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as tar:
|
||||
by_name = {member.name: member for member in tar.getmembers()}
|
||||
for inspected in members:
|
||||
member = by_name.get(inspected.name)
|
||||
if member is None or not member.isfile() or member.size != inspected.size:
|
||||
raise ReleaseError("archive changed while it was being inspected")
|
||||
target = destination.joinpath(*PurePosixPath(member.name).parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise ReleaseError(f"cannot extract archive member: {member.name}")
|
||||
with source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
target.chmod(0o644)
|
||||
|
||||
|
||||
def validate_verify_url(url: str) -> None:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
|
||||
raise ReleaseError("verify URL must be an HTTP(S) marker URL without query or fragment")
|
||||
if not parsed.path.endswith("/.well-known/release.json"):
|
||||
raise ReleaseError("verify URL must end with /.well-known/release.json")
|
||||
|
||||
|
||||
def read_url_json(url: str, timeout: float) -> dict:
|
||||
request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
if response.status != 200:
|
||||
raise ReleaseError(f"public marker returned HTTP {response.status}")
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"public marker verification failed: {exc}") from exc
|
||||
|
||||
|
||||
def verify_public(url: str, revision: str, digest: str, timeout: float = 10.0) -> dict:
|
||||
validate_revision(revision)
|
||||
validate_digest(digest)
|
||||
validate_verify_url(url)
|
||||
marker = read_url_json(url, timeout)
|
||||
if marker.get("revision") != revision or marker.get("artifact_digest") != digest:
|
||||
raise ReleaseError("public marker does not identify the expected release")
|
||||
|
||||
index_url = url[: -len(PUBLIC_MARKER.as_posix())] + "index.html"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(index_url, headers={"Cache-Control": "no-cache"}),
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200 or not response.read(1):
|
||||
raise ReleaseError("public index is missing or empty")
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise ReleaseError(f"public index verification failed: {exc}") from exc
|
||||
return marker
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def release_lock(root: Path):
|
||||
if (
|
||||
not root.is_absolute()
|
||||
or root == Path("/")
|
||||
or root.resolve(strict=False) != root
|
||||
or not root.is_dir()
|
||||
or root.is_symlink()
|
||||
):
|
||||
raise ReleaseError(f"release root must be a provisioned directory: {root}")
|
||||
with (root / ".deploy.lock").open("a+") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def current_release(root: Path) -> dict | None:
|
||||
pointer = root / "current"
|
||||
if not pointer.exists() and not pointer.is_symlink():
|
||||
return None
|
||||
if not pointer.is_symlink():
|
||||
raise ReleaseError(f"current target must be a managed symlink: {pointer}")
|
||||
resolved = pointer.resolve(strict=True)
|
||||
releases = (root / "releases").resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(releases)
|
||||
except ValueError as exc:
|
||||
raise ReleaseError("current pointer escapes the managed releases directory") from exc
|
||||
if len(relative.parts) != 1 or not re.fullmatch(r"[0-9a-f]{64}", relative.name):
|
||||
raise ReleaseError("current pointer has an invalid release target")
|
||||
marker = load_public_marker(resolved)
|
||||
if marker["artifact_digest"] != f"sha256:{relative.name}":
|
||||
raise ReleaseError("current pointer and public marker digest disagree")
|
||||
return marker
|
||||
|
||||
|
||||
def switch(root: Path, digest: str) -> None:
|
||||
digest_hex = digest.removeprefix("sha256:")
|
||||
temporary = root / f".current.next-{os.getpid()}"
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
temporary.symlink_to(Path("releases") / digest_hex)
|
||||
os.replace(temporary, root / "current")
|
||||
|
||||
|
||||
def record(
|
||||
root: Path,
|
||||
action: str,
|
||||
revision: str,
|
||||
digest: str,
|
||||
previous: dict | None,
|
||||
outcome: str,
|
||||
actor: str | None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
entry = {
|
||||
"at": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"action": action,
|
||||
"actor": actor or os.environ.get("GITHUB_ACTOR") or os.environ.get("USER") or "unknown",
|
||||
"artifact_digest": digest,
|
||||
"revision": revision,
|
||||
"previous_revision": previous["revision"] if previous else None,
|
||||
"previous_artifact_digest": previous["artifact_digest"] if previous else None,
|
||||
"target": str(root / "current"),
|
||||
"outcome": outcome,
|
||||
"desired_commit": order["desired_commit"] if order else None,
|
||||
"desired_generation": order["desired_generation"] if order else None,
|
||||
}
|
||||
with (root / ".deployments.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def promote(
|
||||
root: Path,
|
||||
revision: str,
|
||||
digest: str,
|
||||
*,
|
||||
verify_url: str | None,
|
||||
action: str,
|
||||
actor: str | None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
validate_revision(revision)
|
||||
validate_digest(digest)
|
||||
release = root / "releases" / digest.removeprefix("sha256:")
|
||||
if not release.is_dir():
|
||||
raise ReleaseError(f"release is not staged: {digest}")
|
||||
source = load_source_marker(release)
|
||||
marker = load_public_marker(release)
|
||||
if source["revision"] != revision or marker != {"revision": revision, "artifact_digest": digest}:
|
||||
raise ReleaseError("staged release identity mismatch")
|
||||
|
||||
previous = current_release(root)
|
||||
switch(root, digest)
|
||||
try:
|
||||
if verify_url:
|
||||
verify_public(verify_url, revision, digest)
|
||||
record(root, action, revision, digest, previous, "verified", actor, order)
|
||||
except BaseException:
|
||||
if previous is None:
|
||||
(root / "current").unlink(missing_ok=True)
|
||||
else:
|
||||
switch(root, previous["artifact_digest"])
|
||||
record(root, action, revision, digest, previous, "rolled-back", actor, order)
|
||||
raise
|
||||
if order is not None:
|
||||
write_desired_state(root, revision, digest, order)
|
||||
|
||||
|
||||
def publish(
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
root: Path,
|
||||
revision: str,
|
||||
verify_url: str | None,
|
||||
actor: str | None = None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
if order is not None:
|
||||
validate_order(order)
|
||||
archive_data, members, digest = inspect_archive(archive, checksum, revision)
|
||||
with release_lock(root):
|
||||
if order is not None:
|
||||
try:
|
||||
check_order(root, revision, digest, order)
|
||||
except SupersededError:
|
||||
record(root, "publish", revision, digest, current_release(root), "superseded", actor, order)
|
||||
raise
|
||||
releases = root / "releases"
|
||||
releases.mkdir(exist_ok=True)
|
||||
destination = releases / digest.removeprefix("sha256:")
|
||||
if destination.exists():
|
||||
marker = load_public_marker(destination)
|
||||
if marker != {"revision": revision, "artifact_digest": digest}:
|
||||
raise ReleaseError("existing immutable release has the wrong identity")
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix=".incoming-", dir=releases) as temp:
|
||||
incoming = Path(temp)
|
||||
extract_members(archive_data, members, incoming)
|
||||
source = load_source_marker(incoming)
|
||||
if source["revision"] != revision:
|
||||
raise ReleaseError("extracted release revision mismatch")
|
||||
marker_path = incoming / PUBLIC_MARKER
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text(
|
||||
json.dumps({"revision": revision, "artifact_digest": digest}, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
load_public_marker(incoming)
|
||||
os.replace(incoming, destination)
|
||||
promote(
|
||||
root,
|
||||
revision,
|
||||
digest,
|
||||
verify_url=verify_url,
|
||||
action="publish",
|
||||
actor=actor,
|
||||
order=order,
|
||||
)
|
||||
|
||||
|
||||
def rollback(
|
||||
root: Path,
|
||||
revision: str,
|
||||
digest: str,
|
||||
verify_url: str | None,
|
||||
actor: str | None = None,
|
||||
) -> None:
|
||||
with release_lock(root):
|
||||
promote(root, revision, digest, verify_url=verify_url, action="rollback", actor=actor)
|
||||
|
||||
|
||||
def retention(root: Path, keep: int) -> dict:
|
||||
"""Report releases outside the retention set. Never deletes anything.
|
||||
|
||||
Kept: the current release, the last applied desired selection, and the
|
||||
``keep`` most recent distinct verified releases from the deployment log.
|
||||
"""
|
||||
if keep < 1:
|
||||
raise ReleaseError("keep must be at least 1")
|
||||
releases = root / "releases"
|
||||
present = sorted(
|
||||
path.name for path in releases.iterdir()
|
||||
if path.is_dir() and re.fullmatch(r"[0-9a-f]{64}", path.name)
|
||||
) if releases.is_dir() else []
|
||||
reasons: dict[str, list[str]] = {}
|
||||
|
||||
def keep_digest(digest: str | None, reason: str) -> None:
|
||||
if digest:
|
||||
reasons.setdefault(digest.removeprefix("sha256:"), []).append(reason)
|
||||
|
||||
current = current_release(root)
|
||||
keep_digest(current and current["artifact_digest"], "current")
|
||||
state = load_desired_state(root)
|
||||
keep_digest(state and state["artifact_digest"], "desired-state")
|
||||
verified: list[str] = []
|
||||
log = root / ".deployments.jsonl"
|
||||
if log.is_file():
|
||||
for line in reversed(log.read_text(encoding="utf-8").splitlines()):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
digest = entry.get("artifact_digest")
|
||||
if entry.get("outcome") == "verified" and digest not in verified:
|
||||
verified.append(digest)
|
||||
for digest in verified[:keep]:
|
||||
keep_digest(digest, "recent-verified")
|
||||
return {
|
||||
"root": str(root),
|
||||
"keep": {digest: reasons[digest] for digest in present if digest in reasons},
|
||||
"candidates": [digest for digest in present if digest not in reasons],
|
||||
}
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
command = argparse.ArgumentParser(description=__doc__)
|
||||
sub = command.add_subparsers(dest="command", required=True)
|
||||
|
||||
build_cmd = sub.add_parser("build")
|
||||
build_cmd.add_argument("--source", type=Path, required=True)
|
||||
build_cmd.add_argument("--output", type=Path, required=True)
|
||||
build_cmd.add_argument("--revision", required=True)
|
||||
|
||||
package_cmd = sub.add_parser("package")
|
||||
package_cmd.add_argument("--directory", type=Path, required=True)
|
||||
package_cmd.add_argument("--archive", type=Path, required=True)
|
||||
package_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
|
||||
inspect_cmd = sub.add_parser("inspect")
|
||||
inspect_cmd.add_argument("--archive", type=Path, required=True)
|
||||
inspect_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
inspect_cmd.add_argument("--revision", required=True)
|
||||
|
||||
publish_cmd = sub.add_parser("publish")
|
||||
publish_cmd.add_argument("--archive", type=Path, required=True)
|
||||
publish_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
publish_cmd.add_argument("--root", type=Path, required=True)
|
||||
publish_cmd.add_argument("--revision", required=True)
|
||||
publish_cmd.add_argument("--verify-url")
|
||||
publish_cmd.add_argument("--actor")
|
||||
|
||||
verify_cmd = sub.add_parser("verify")
|
||||
verify_cmd.add_argument("--url", required=True)
|
||||
verify_cmd.add_argument("--revision", required=True)
|
||||
verify_cmd.add_argument("--digest", required=True)
|
||||
|
||||
rollback_cmd = sub.add_parser("rollback")
|
||||
rollback_cmd.add_argument("--root", type=Path, required=True)
|
||||
rollback_cmd.add_argument("--revision", required=True)
|
||||
rollback_cmd.add_argument("--digest", required=True)
|
||||
rollback_cmd.add_argument("--verify-url")
|
||||
rollback_cmd.add_argument("--actor")
|
||||
|
||||
retention_cmd = sub.add_parser("retention", help="report prune candidates; never deletes")
|
||||
retention_cmd.add_argument("--root", type=Path, required=True)
|
||||
retention_cmd.add_argument("--keep", type=int, default=5)
|
||||
return command
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
if args.command == "build":
|
||||
build(args.source, args.output, args.revision)
|
||||
elif args.command == "package":
|
||||
package(args.directory, args.archive, args.checksum)
|
||||
elif args.command == "inspect":
|
||||
inspect_archive(args.archive, args.checksum, args.revision)
|
||||
elif args.command == "publish":
|
||||
publish(args.archive, args.checksum, args.root, args.revision, args.verify_url, args.actor)
|
||||
elif args.command == "verify":
|
||||
verify_public(args.url, args.revision, args.digest)
|
||||
elif args.command == "rollback":
|
||||
rollback(args.root, args.revision, args.digest, args.verify_url, args.actor)
|
||||
elif args.command == "retention":
|
||||
print(json.dumps(retention(args.root, args.keep), indent=2, sort_keys=True))
|
||||
except ReleaseError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":1,"name":"blog","owner":"public","package":"blog-site","target":"/srv/libretech-static/blog","verify_url":"https://blog.static.librete.ch/.well-known/release.json"}
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from scripts import deploy_contract
|
||||
from scripts import delivery_gate
|
||||
from scripts import fetch_hugo
|
||||
from scripts import generic_package
|
||||
|
||||
|
||||
DELIVERY = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = DELIVERY.parent
|
||||
WORKFLOWS = REPO_ROOT / ".gitea/workflows"
|
||||
REVISION = "a" * 40
|
||||
|
||||
|
||||
class BlogSiteTests(unittest.TestCase):
|
||||
def test_site_is_the_netcup_release_root_not_a_checkout(self):
|
||||
site = deploy_contract.load_site(DELIVERY / "site.json")
|
||||
self.assertEqual(site.target, "/srv/libretech-static/blog")
|
||||
self.assertEqual(site.verify_url, "https://blog.static.librete.ch/.well-known/release.json")
|
||||
url = generic_package.package_file_url(
|
||||
deploy_contract.REGISTRY, "public", "blog-site", REVISION, generic_package.ARCHIVE_NAME,
|
||||
)
|
||||
request = deploy_contract.validate_request(
|
||||
site=site,
|
||||
revision=REVISION,
|
||||
artifact_url=url,
|
||||
artifact_digest="sha256:" + "b" * 64,
|
||||
target=site.target,
|
||||
verify_url=site.verify_url,
|
||||
desired_commit="c" * 40,
|
||||
desired_generation="1",
|
||||
)
|
||||
self.assertEqual(request["artifact_url"], url)
|
||||
with self.assertRaisesRegex(deploy_contract.ContractError, "immutable project package"):
|
||||
deploy_contract.validate_request(**{**request, "artifact_url": url.replace("public", "libretech")}, site=site)
|
||||
|
||||
def test_reviewed_gate_enables_publication_and_deployment(self):
|
||||
config = DELIVERY / ".delivery/config.json"
|
||||
for capability in delivery_gate.CAPABILITIES:
|
||||
delivery_gate.check(config, capability)
|
||||
|
||||
def test_hugo_is_pinned_and_verified(self):
|
||||
self.assertRegex(fetch_hugo.SHA256, r"^[0-9a-f]{64}$")
|
||||
self.assertIn(f"/v{fetch_hugo.VERSION}/", fetch_hugo.URL)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
info = tarfile.TarInfo("hugo")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"\x7fELF"))
|
||||
archive = buffer.getvalue()
|
||||
binary = fetch_hugo.extract_hugo(archive, hashlib.sha256(archive).hexdigest(), Path(temp))
|
||||
self.assertEqual(binary.read_bytes(), b"\x7fELF")
|
||||
with self.assertRaisesRegex(fetch_hugo.FetchError, "digest mismatch"):
|
||||
fetch_hugo.extract_hugo(archive, "0" * 64, Path(temp))
|
||||
|
||||
|
||||
class BlogWorkflowTests(unittest.TestCase):
|
||||
def read(self, name):
|
||||
return (WORKFLOWS / name).read_text(encoding="utf-8")
|
||||
|
||||
def test_no_workflow_publishes_from_a_checkout(self):
|
||||
for workflow in sorted(WORKFLOWS.glob("*.yml")):
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
with self.subTest(workflow=workflow.name):
|
||||
for forbidden in ("rsync", "scp ", "publishDir", "/var/www"):
|
||||
self.assertNotIn(forbidden, text)
|
||||
|
||||
def test_publication_is_gated_before_any_package_write(self):
|
||||
workflow = self.read("publish-blog.yml")
|
||||
_, _, publish = workflow.partition("\n publish:\n")
|
||||
self.assertIn("vars.BLOG_PACKAGE_PUBLISH_ENABLED == 'true'", publish)
|
||||
self.assertLess(publish.index("delivery_gate.py package_publish"), publish.index("generic_package.py"))
|
||||
_, _, validate = workflow.partition("\n validate:\n")
|
||||
self.assertNotIn("secrets.", validate.partition("\n publish:\n")[0])
|
||||
|
||||
def test_deployment_is_gated_ordered_and_site_scoped(self):
|
||||
workflow = self.read("deploy-blog.yml")
|
||||
self.assertIn("workflow_dispatch:", workflow)
|
||||
self.assertNotIn("\n push:", workflow)
|
||||
self.assertIn("vars.BLOG_STATIC_DEPLOY_ENABLED == 'true'", workflow)
|
||||
self.assertNotIn("DONATELLA", workflow)
|
||||
self.assertEqual(workflow.count("--site-config delivery/site.json"), 2)
|
||||
for field in ("revision", "artifact_url", "artifact_digest", "target", "verify_url",
|
||||
"desired_commit", "desired_generation"):
|
||||
self.assertIn(f" {field}:\n", workflow)
|
||||
self.assertIn("StrictHostKeyChecking yes", workflow)
|
||||
self.assertIn("steps.receive.outputs.superseded == 'false'", workflow)
|
||||
self.assertLess(
|
||||
workflow.index("delivery_gate.py static_deploy"),
|
||||
workflow.index("ssh static-release-target"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Categories on LibreTECH</title>
|
||||
<link>https://blog.librete.ch/categories/</link>
|
||||
<description>Recent content in Categories on LibreTECH</description>
|
||||
<generator>Hugo</generator>
|
||||
<language>en-us</language>
|
||||
<atom:link href="https://blog.librete.ch/categories/index.xml" rel="self" type="application/rss+xml" />
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>LibreTECH</title>
|
||||
<link>https://blog.librete.ch/</link>
|
||||
<description>Recent content on LibreTECH</description>
|
||||
<generator>Hugo</generator>
|
||||
<language>en-us</language>
|
||||
<atom:link href="https://blog.librete.ch/index.xml" rel="self" type="application/rss+xml" />
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://blog.librete.ch/categories/</loc>
|
||||
</url><url>
|
||||
<loc>https://blog.librete.ch/</loc>
|
||||
</url><url>
|
||||
<loc>https://blog.librete.ch/tags/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Tags on LibreTECH</title>
|
||||
<link>https://blog.librete.ch/tags/</link>
|
||||
<description>Recent content in Tags on LibreTECH</description>
|
||||
<generator>Hugo</generator>
|
||||
<language>en-us</language>
|
||||
<atom:link href="https://blog.librete.ch/tags/index.xml" rel="self" type="application/rss+xml" />
|
||||
</channel>
|
||||
</rss>
|
||||
Reference in New Issue
Block a user