Compare commits

...
Author SHA1 Message Date
forust a2c01e07e2 chore(paperless): keep deployment inactive
ci / image-plan (push) Successful in 13s
ci / Image (error-pages) (push) Successful in 16s
ci / Image (forust-homepage) (push) Successful in 15s
renovate-ci / validate-renovate (push) Successful in 2m34s
ci / Image (xdfnx-homepage) (push) Successful in 12s
ci / build (push) Successful in 19s
ci / Compose (push) Successful in 12s
ci / Formatting (push) Successful in 17s
ci / Kubernetes (push) Successful in 9s
ci / Workflows (push) Successful in 6s
ci / Shell (push) Successful in 24s
ci / Python and tests (push) Successful in 11s
ci / YAML (push) Successful in 8s
ci / Dockerfiles (push) Successful in 5s
2026-10-09 01:02:33 +02:00
forust 563e4c2244 feat(homelab): isolate PR runner and add Paperless 2026-10-09 01:01:35 +02:00
forust e3ae86cd01 feat(streaming): expose seerr and jellyfin publicly
ci / Workflows (push) Successful in 7s
ci / Formatting (push) Successful in 24s
ci / Python and tests (push) Successful in 10s
ci / Compose (push) Successful in 13s
ci / Shell (push) Successful in 24s
ci / YAML (push) Successful in 10s
ci / Image (error-pages) (push) Successful in 15s
ci / Dockerfiles (push) Successful in 6s
ci / Kubernetes (push) Successful in 8s
ci / image-plan (push) Successful in 13s
ci / Image (forust-homepage) (push) Successful in 16s
ci / Image (xdfnx-homepage) (push) Successful in 18s
ci / build (push) Successful in 17s
Add prod IngressRoutes for seerr.forust.xyz and jelly.forust.xyz with letsencrypt certificates.
2026-10-08 23:57:55 +02:00
forust 3ea181e966 Merge pull request 'chore(deps): update renovate/renovate docker tag to v44.147.0' (#114) from renovate/renovate-self-update into main
renovate-ci / validate-renovate (push) Successful in 3m10s
ci / Compose (push) Successful in 15s
ci / Workflows (push) Successful in 8s
ci / Shell (push) Successful in 21s
ci / Python and tests (push) Successful in 10s
ci / Formatting (push) Successful in 21s
ci / YAML (push) Successful in 18s
ci / Dockerfiles (push) Successful in 10s
ci / Kubernetes (push) Successful in 14s
ci / image-plan (push) Successful in 24s
ci / Image (error-pages) (push) Successful in 24s
ci / Image (forust-homepage) (push) Successful in 26s
ci / Image (xdfnx-homepage) (push) Successful in 25s
ci / build (push) Successful in 28s
Reviewed-on: #114
2026-10-08 19:15:52 +00:00
renovate-bot Bot eb2f6f7d5d chore(deps): update renovate/renovate docker tag to v44.147.0 2026-10-08 19:15:52 +00:00
forust 67d08fc33e Merge pull request 'chore(deps): update helm release kube-prometheus-stack to v86.3.2' (#107) from renovate/helm-kube-prometheus-stack into main
ci / Compose (push) Canceled after 0s
ci / Workflows (push) Canceled after 0s
ci / Shell (push) Canceled after 0s
ci / Python and tests (push) Canceled after 0s
ci / Formatting (push) Canceled after 0s
ci / YAML (push) Canceled after 0s
ci / Dockerfiles (push) Canceled after 0s
ci / Kubernetes (push) Canceled after 0s
ci / image-plan (push) Canceled after 0s
ci / Image (${{ matrix.name }}) (push) Canceled after 0s
ci / build (push) Canceled after 0s
Reviewed-on: #107
2026-10-08 19:15:42 +00:00
renovate-bot Bot f748a3c7aa chore(deps): update helm release kube-prometheus-stack to v86.3.2 2026-10-08 19:15:42 +00:00
forust 8c8ff47241 Merge pull request 'chore(deps): update helm release reloader to v2.2.18' (#102) from renovate/helm-reloader into main
ci / Compose (push) Canceled after 0s
ci / Workflows (push) Canceled after 0s
ci / Shell (push) Canceled after 0s
ci / Formatting (push) Canceled after 0s
ci / Python and tests (push) Canceled after 0s
ci / YAML (push) Canceled after 0s
ci / Dockerfiles (push) Canceled after 0s
ci / Kubernetes (push) Canceled after 0s
ci / image-plan (push) Canceled after 0s
ci / Image (${{ matrix.name }}) (push) Canceled after 0s
ci / build (push) Canceled after 0s
Reviewed-on: #102
2026-10-08 19:14:10 +00:00
renovate-bot Bot ed2ba44bee chore(deps): update helm release reloader to v2.2.18 2026-10-08 19:14:10 +00:00
forust bce653ebaf Merge pull request 'chore(deps): update all patch updates' (#101) from renovate/all-patch into main
ci / Formatting (push) Canceled after 0s
ci / Python and tests (push) Canceled after 0s
ci / YAML (push) Canceled after 0s
ci / Dockerfiles (push) Canceled after 0s
ci / Kubernetes (push) Canceled after 0s
ci / image-plan (push) Canceled after 0s
ci / Image (${{ matrix.name }}) (push) Canceled after 0s
ci / build (push) Canceled after 0s
renovate-ci / validate-renovate (push) Successful in 2m53s
ci / Compose (push) Canceled after 0s
ci / Workflows (push) Canceled after 0s
ci / Shell (push) Canceled after 0s
Reviewed-on: #101
2026-10-08 19:13:58 +00:00
renovate-bot Bot 624ae84da1 chore(deps): update all patch updates 2026-10-08 19:13:58 +00:00
forust f00c044f3d Merge pull request 'fix(ci): keep build and test reports accurate' (#118) from fix/ci-test-output-isolation into main
ci / Formatting (push) Successful in 21s
ci / Python and tests (push) Successful in 10s
ci / YAML (push) Successful in 8s
ci / Dockerfiles (push) Successful in 5s
ci / Kubernetes (push) Successful in 6s
ci / image-plan (push) Successful in 14s
ci / Image (error-pages) (push) Successful in 23s
ci / Image (forust-homepage) (push) Successful in 18s
ci / Image (xdfnx-homepage) (push) Successful in 19s
ci / build (push) Successful in 18s
ci / Compose (push) Successful in 13s
ci / Workflows (push) Successful in 8s
ci / Shell (push) Successful in 16s
Reviewed-on: #118
2026-10-08 19:13:21 +00:00
forust 0e3035ed74 fix(ci): validate image digests and isolate test outputs
ci / Compose (pull_request) Successful in 12s
ci / Workflows (pull_request) Successful in 9s
ci / Shell (pull_request) Successful in 21s
ci / Formatting (pull_request) Successful in 22s
ci / Python and tests (pull_request) Successful in 10s
ci / YAML (pull_request) Successful in 11s
ci / Dockerfiles (pull_request) Successful in 6s
ci / Kubernetes (pull_request) Successful in 8s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-08 20:46:46 +02:00
forust 1d8eda6e5e test: isolate CI summary and output files
ci / Formatting (pull_request) Successful in 24s
ci / Compose (pull_request) Successful in 16s
ci / Workflows (pull_request) Successful in 9s
ci / Shell (pull_request) Successful in 18s
ci / Python and tests (pull_request) Successful in 11s
ci / YAML (pull_request) Successful in 11s
ci / Dockerfiles (pull_request) Successful in 7s
ci / Kubernetes (pull_request) Successful in 8s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-08 20:20:44 +02:00
forust fade5439c7 Merge pull request 'fix(deploy): correct service selection and recovery validation' (#117) from fix/cicd-review-recovery into main
ci / Compose (push) Successful in 15s
ci / Workflows (push) Successful in 7s
ci / Shell (push) Successful in 18s
ci / Formatting (push) Successful in 17s
ci / Python and tests (push) Successful in 8s
ci / Kubernetes (push) Successful in 7s
ci / YAML (push) Successful in 11s
ci / Dockerfiles (push) Successful in 6s
ci / image-plan (push) Successful in 12s
ci / Image (error-pages) (push) Successful in 16s
ci / Image (forust-homepage) (push) Successful in 32s
ci / Image (xdfnx-homepage) (push) Successful in 16s
ci / build (push) Successful in 26s
Reviewed-on: #117
2026-10-08 18:13:41 +00:00
40 changed files with 862 additions and 90 deletions

No files matched your search

+51 -20
View File
@@ -1,10 +1,13 @@
# Homelab CI/CD # Homelab CI/CD
The native Gitea runners run on **vps**; production runs on **workstation**. The native Gitea runners run on **vps**; production runs on **workstation**.
Main-branch checks and image builds use `homelab:host`. Pull request and Main-branch checks and image builds use `homelab:host`. Pull request checks use
non-main checks use `homelab-pr:host` under a separate account without Docker `homelab-pr` in a Docker job container. CI PR checks use `pull_request_target`,
access. The `homelab-pr` runner is registered at User scope for `forust`, so so Gitea loads the workflow from the trusted base branch. That event then runs
any repository under that account can schedule jobs that request this label. untrusted PR code, so the workflow must select `homelab-pr` before checkout and
must not expose secrets. The CI validation jobs grant only `contents: read` and
checkout the explicit PR head SHA with `persist-credentials: false`. Register
`homelab-pr` at repository scope so only this repository can schedule its jobs.
Each runner accepts one job at a time; the build waits for every check to pass. Each runner accepts one job at a time; the build waits for every check to pass.
CI and deploy runs also show a summary with CI and deploy runs also show a summary with
the release SHA, image build or reuse results, deploy mode, selected services, the release SHA, image build or reuse results, deploy mode, selected services,
@@ -42,29 +45,57 @@ Nothing runs `docker system prune`, removes unrelated images, or deletes volumes
### Pull request runner ### Pull request runner
Install the unprivileged host runner on the VPS: Install the PR container runner on the VPS:
```sh ```sh
sudo bash .gitea/runner/setup-pr-runner.sh sudo bash .gitea/runner/setup-pr-runner.sh
``` ```
Get a registration token from the user Actions runner settings. Run the Create a runner registration token from this repository's Actions runner
installer in a terminal. It asks for the token without echoing it, registers the settings. Run the installer in a terminal. It asks for the token without
runner as `homelab-pr` with label `homelab-pr:host`, then enables the service. echoing it and registers `homelab-pr` with label
The work directory is `/var/lib/gitea-pr-runner`. Confirm that Gitea lists the `homelab-pr:docker://docker.gitea.com/runner-images:ubuntu-24.04-v26.09.01`. Confirm that
runner as User scope before merging the workflow change. An unmatched label can Gitea lists the runner at Repository scope. The service runs as
fall back to the default job image. `gitea-pr-runner`; systemd grants that service access to the Docker socket with
`SupplementaryGroups=docker`. Keep the account itself out of the `docker`
group. The work directory is `/var/lib/gitea-pr-runner`.
Renovate PR validation uses `pull_request_target`, which reads the workflow from The runner config disables privileged containers, forbids workflow volume
the base branch. It checks out the PR head only after runner selection and runs mounts, and prevents the Docker socket from being mounted into job and action
that code on `homelab-pr`. Keep this workflow read-only and do not add secrets. containers. Do not mount the runner home or its host-side tool cache into a job.
The existing host-side cache is retained, but PR job containers cannot read it.
An unmatched label can fall back to the default job image; check the registered
label before enabling PR checks.
The PR runner has a separate home and tool cache. Do not add it to the `docker` The installer reuses `/var/lib/gitea-pr-runner/.runner` when it exists. That
group or give it access to `/var/run/docker.sock`. It runs repository code from file keeps the registration scope assigned by Gitea. To move an existing
pull requests, so keep its registration and permissions separate from the User-scoped runner to Repository scope, stop the service, remove the old runner
trusted `homelab` runner. This separates users and host permissions, but both from Gitea, back up and remove that registration file, then run the installer
runners still share the VPS kernel and network. Use a disposable VM if PRs from with a token created in this repository's Actions runner settings. Confirm the
untrusted external authors must be fully isolated. new scope in Gitea before enabling PR checks.
The CI and Renovate workflows use `pull_request_target`, which reads the
workflow from the base branch. They select `homelab-pr` before checking out PR
code. The explicit head SHA and `persist-credentials: false` are mandatory:
without the latter, checkout can leave the job token in Git configuration.
Keep PR validation read-only and do not add Actions secrets. In the checked-in
workflows, only a push to `main` or a manual CI run on `main` can select the
trusted `homelab` runner. Gitea schedules jobs by matching `runs-on` labels; the
runner does not restrict jobs by event or branch. Keep Gitea's approval gate for
fork PR workflows enabled. Verify the live Gitea version and approval setting
before relying on this gate; the image tag in the repository does not prove the
version currently running. Before approving a fork workflow run, review all new
and changed workflow files: a PR-defined `pull_request` workflow can request
the `homelab` label. Automatic CI and Renovate PR checks use the trusted base
workflow and select only `homelab-pr`. The release and deploy jobs stay on the
trusted runner.
The runner service can access the host Docker daemon, but job and action
containers do not receive its socket or arbitrary host mounts. The runner and
job containers still share the VPS kernel and Docker daemon. A container escape
can therefore affect the host and other workloads. This is container isolation,
not VM isolation; use disposable VMs for PRs that require a separate kernel and
Docker daemon.
## Workstation setup ## Workstation setup
+5 -1
View File
@@ -3,6 +3,10 @@ runner:
capacity: 1 capacity: 1
timeout: 5h timeout: 5h
labels: labels:
- homelab-pr:host - homelab-pr:docker://docker.gitea.com/runner-images:ubuntu-24.04-v26.09.01
cache: cache:
enabled: false enabled: false
container:
privileged: false
valid_volumes: []
docker_host: "-"
+3 -2
View File
@@ -1,11 +1,12 @@
[Unit] [Unit]
Description=Gitea Actions untrusted pull request runner Description=Gitea Actions untrusted pull request runner
After=network-online.target After=network-online.target docker.service
Wants=network-online.target Wants=network-online.target docker.service
[Service] [Service]
User=gitea-pr-runner User=gitea-pr-runner
Group=gitea-pr-runner Group=gitea-pr-runner
SupplementaryGroups=docker
WorkingDirectory=/var/lib/gitea-pr-runner WorkingDirectory=/var/lib/gitea-pr-runner
Environment=HOME=/var/lib/gitea-pr-runner Environment=HOME=/var/lib/gitea-pr-runner
Environment=PATH=/var/lib/gitea-pr-runner/.cache/homelab-ci/bin:/usr/local/bin:/usr/bin:/bin Environment=PATH=/var/lib/gitea-pr-runner/.cache/homelab-ci/bin:/usr/local/bin:/usr/bin:/bin
+20 -4
View File
@@ -1,16 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install a native runner for untrusted PR jobs without Docker access. # Install the containerized runner service for untrusted PR jobs.
set -euo pipefail set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ "$(id -u)" -eq 0 ] || { echo 'Run with sudo on the runner host' >&2; exit 1; } [ "$(id -u)" -eq 0 ] || { echo 'Run with sudo on the runner host' >&2; exit 1; }
for tool in cp cut date getent id install runuser systemctl useradd; do for tool in cp cut date docker getent id install runuser systemctl useradd; do
command -v "$tool" >/dev/null || { echo "Install missing prerequisite: $tool" >&2; exit 1; } command -v "$tool" >/dev/null || { echo "Install missing prerequisite: $tool" >&2; exit 1; }
done done
docker info >/dev/null || {
echo 'Start Docker Engine before installing the PR runner' >&2
exit 1
}
command -v /usr/local/bin/gitea-runner >/dev/null || { command -v /usr/local/bin/gitea-runner >/dev/null || {
echo 'Install gitea-runner 3.0.2 at /usr/local/bin/gitea-runner first' >&2 echo 'Install gitea-runner 3.0.2 at /usr/local/bin/gitea-runner first' >&2
exit 1 exit 1
} }
runner_version_output="$(/usr/local/bin/gitea-runner --version 2>&1)" || {
echo 'Cannot read the installed gitea-runner version' >&2
exit 1
}
if [[ ! "$runner_version_output" =~ (^|[[:space:]])v?3\.0\.2($|[[:space:]]) ]]; then
printf 'Expected gitea-runner 3.0.2; found: %s\n' "$runner_version_output" >&2
exit 1
fi
getent group docker >/dev/null || {
echo 'Install Docker Engine first; the docker group is missing' >&2
exit 1
}
id gitea-pr-runner >/dev/null 2>&1 || \ id gitea-pr-runner >/dev/null 2>&1 || \
useradd --system --create-home --home-dir /var/lib/gitea-pr-runner --shell /usr/bin/bash gitea-pr-runner useradd --system --create-home --home-dir /var/lib/gitea-pr-runner --shell /usr/bin/bash gitea-pr-runner
runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)" runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)"
@@ -20,7 +36,7 @@ runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)"
} }
case " $(id -nG gitea-pr-runner) " in case " $(id -nG gitea-pr-runner) " in
*' docker '*) *' docker '*)
echo 'The PR runner account must not belong to the docker group' >&2 echo 'Remove gitea-pr-runner from the docker group; only the systemd service gets Docker access' >&2
exit 1 exit 1
;; ;;
esac esac
@@ -44,7 +60,7 @@ if [ ! -f /var/lib/gitea-pr-runner/.runner ]; then
--config /etc/gitea-pr-runner/config.yaml \ --config /etc/gitea-pr-runner/config.yaml \
--instance https://gitea.forust.xyz \ --instance https://gitea.forust.xyz \
--name homelab-pr \ --name homelab-pr \
--labels homelab-pr:host \ --labels 'homelab-pr:docker://docker.gitea.com/runner-images:ubuntu-24.04-v26.09.01' \
--no-interactive --no-interactive
unset GITEA_RUNNER_REGISTRATION_TOKEN unset GITEA_RUNNER_REGISTRATION_TOKEN
fi fi
+23 -2
View File
@@ -91,7 +91,6 @@ if check_referenced_secrets >"$scratch/secrets.log"; then
echo 'Secret check accepted a failed manifest render' >&2 echo 'Secret check accepted a failed manifest render' >&2
exit 1 exit 1
fi fi
printf '%s\n' 'Deploy validation regressions passed.'
# New declared namespaces defer only their own resources during preflight. # New declared namespaces defer only their own resources during preflight.
render_selected_resources() { render_selected_resources() {
@@ -132,4 +131,26 @@ if validate_server_resources true 2>"$scratch/undeclared.log"; then
echo 'Preflight accepted an undeclared missing namespace' >&2 echo 'Preflight accepted an undeclared missing namespace' >&2
exit 1 exit 1
fi fi
printf '%s\n' 'Namespace validation regressions passed.' # Count services, not characters in the newline-separated service names.
compose() {
case "$*" in
*'config --format json') printf '%s\n' '{"services":{"headscale":{},"headplane":{},"web":{},"init":{"restart":"no"}}}' ;;
*'ps --status running --services') printf '%s\n' headscale headplane web ;;
*) return 1 ;;
esac
}
verify_compose_stack example.yaml >"$scratch/compose-count.log"
grep -qF 'all 3 service(s) running' "$scratch/compose-count.log"
compose() {
case "$*" in
*'config --format json') printf '%s\n' '{"services":{"headscale":{},"headplane":{},"web":{}}}' ;;
*'ps --status running --services') printf '%s\n' headscale headplane ;;
*) return 0 ;;
esac
}
if verify_compose_stack example.yaml >"$scratch/compose-missing.log"; then
echo 'Compose verification accepted a missing service' >&2
exit 1
fi
grep -qF 'NOT RUNNING: web' "$scratch/compose-missing.log"
printf '%s\n' 'Deploy validation regressions passed.'
+60 -12
View File
@@ -3,22 +3,30 @@ name: ci
push: push:
branches: branches:
- main - main
pull_request: null # Use the base-branch workflow so PR changes cannot select trusted runners.
pull_request_target: null
workflow_dispatch: null workflow_dispatch: null
permissions: permissions:
contents: read contents: read
actions: read actions: read
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.ref != 'refs/heads/main' }}
jobs: jobs:
# pull_request_target uses the base ref (often main); check the event as well
# as the ref so every PR job stays on the isolated runner.
compose: compose:
name: Compose name: Compose
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Validate Compose files - name: Validate Compose files
shell: bash shell: bash
@@ -66,11 +74,16 @@ jobs:
fi fi
workflows: workflows:
name: Workflows name: Workflows
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -102,11 +115,16 @@ jobs:
fi fi
shell: shell:
name: Shell name: Shell
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -146,11 +164,16 @@ jobs:
fi fi
formatting: formatting:
name: Formatting name: Formatting
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -194,11 +217,16 @@ jobs:
fi fi
python: python:
name: Python and tests name: Python and tests
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -232,11 +260,16 @@ jobs:
fi fi
yaml: yaml:
name: YAML name: YAML
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -280,11 +313,16 @@ jobs:
fi fi
dockerfiles: dockerfiles:
name: Dockerfiles name: Dockerfiles
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -326,11 +364,16 @@ jobs:
fi fi
kubernetes: kubernetes:
name: Kubernetes name: Kubernetes
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} permissions:
contents: read
runs-on: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
id: source id: source
- name: Prepare pinned tools - name: Prepare pinned tools
shell: bash shell: bash
@@ -377,7 +420,7 @@ jobs:
fi fi
image-plan: image-plan:
needs: [compose, workflows, shell, formatting, python, yaml, dockerfiles, kubernetes] needs: [compose, workflows, shell, formatting, python, yaml, dockerfiles, kubernetes]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: homelab runs-on: homelab
timeout-minutes: 10 timeout-minutes: 10
outputs: outputs:
@@ -388,6 +431,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Detect build inputs against successful CI - name: Detect build inputs against successful CI
id: plan id: plan
env: env:
@@ -433,6 +477,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
id: source id: source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Download the checked image plan - name: Download the checked image plan
id: inputs id: inputs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -480,6 +526,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
id: source id: source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Download all image results - name: Download all image results
id: inputs id: inputs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+5 -4
View File
@@ -330,11 +330,11 @@ rollback_workloads() {
# written straight into a `helm upgrade` command would never be updated: these # written straight into a `helm upgrade` command would never be updated: these
# have to be declared as custom.regex managers in renovate/renovate.json. # have to be declared as custom.regex managers in renovate/renovate.json.
HELM_RELEASES=( HELM_RELEASES=(
"prometheus-stack|prometheus-community/kube-prometheus-stack|prometheus|86.2.3|prometheus-stack/k8s/grafana-values.yaml|prometheus-stack/k8s/active" "prometheus-stack|prometheus-community/kube-prometheus-stack|prometheus|86.3.2|prometheus-stack/k8s/grafana-values.yaml|prometheus-stack/k8s/active"
"victoria-operator|victoriametrics/victoria-metrics-operator|prometheus|0.68.1|prometheus-stack/k8s/victoria-operator-values.yaml|prometheus-stack/k8s/active" "victoria-operator|victoriametrics/victoria-metrics-operator|prometheus|0.68.1|prometheus-stack/k8s/victoria-operator-values.yaml|prometheus-stack/k8s/active"
"loki|grafana/loki|prometheus|7.3.0|loki/k8s/loki-values.yaml|loki/k8s/active" "loki|grafana/loki|prometheus|7.3.0|loki/k8s/loki-values.yaml|loki/k8s/active"
"alloy|grafana/alloy|prometheus|1.12.1|loki/k8s/alloy-values.yaml|loki/k8s/active" "alloy|grafana/alloy|prometheus|1.12.1|loki/k8s/alloy-values.yaml|loki/k8s/active"
"reloader|stakater/reloader|reloader|2.2.17|reloader/k8s/reloader-values.yaml|reloader/k8s/active" "reloader|stakater/reloader|reloader|2.2.18|reloader/k8s/reloader-values.yaml|reloader/k8s/active"
) )
# "name url" for the Helm repository hosting a chart, empty if unknown. # "name url" for the Helm repository hosting a chart, empty if unknown.
@@ -827,12 +827,13 @@ stage_verify_k8s() {
# actually be running. # actually be running.
verify_compose_stack() { verify_compose_stack() {
local cf="$1" local cf="$1"
local expected running missing=() local expected running svc missing=() service_count=0
expected="$(compose "$cf" config --format json | jq -r ' .services | to_entries[] | select(.value.restart != "no") | .key' | sort)" || return 1 expected="$(compose "$cf" config --format json | jq -r ' .services | to_entries[] | select(.value.restart != "no") | .key' | sort)" || return 1
running="$(compose "$cf" ps --status running --services | sort)" || return 1 running="$(compose "$cf" ps --status running --services | sort)" || return 1
[ -n "$expected" ] || return 0 [ -n "$expected" ] || return 0
while IFS= read -r svc; do while IFS= read -r svc; do
[ -n "$svc" ] || continue [ -n "$svc" ] || continue
service_count=$((service_count + 1))
# restart:"no" services are allowed to have exited. # restart:"no" services are allowed to have exited.
if ! printf '%s\n' "$running" | grep -qx "$svc"; then if ! printf '%s\n' "$running" | grep -qx "$svc"; then
missing+=("$svc") missing+=("$svc")
@@ -843,7 +844,7 @@ verify_compose_stack() {
compose "$cf" ps --all 2>/dev/null | sed 's/^/ /' || true compose "$cf" ps --all 2>/dev/null | sed 's/^/ /' || true
return 1 return 1
fi fi
echo " all ${#expected} service(s) running" echo " all $service_count service(s) running"
return 0 return 0
} }
+10 -7
View File
@@ -305,7 +305,6 @@ def build_images(output, report, name, plan):
if exists: if exists:
print(f'Reuse {name}: inputs unchanged') print(f'Reuse {name}: inputs unchanged')
digest = old_digest digest = old_digest
report['reused'].append(name)
else: else:
print(f'Build {name}', flush=True) print(f'Build {name}', flush=True)
metadata = Path(docker_config) / 'metadata.json' metadata = Path(docker_config) / 'metadata.json'
@@ -332,14 +331,14 @@ def build_images(output, report, name, plan):
env=env, env=env,
) )
digest = json.loads(metadata.read_text())['containerimage.digest'] digest = json.loads(metadata.read_text())['containerimage.digest']
report['built'].append(name) if not isinstance(digest, str) or not DIGEST.fullmatch(digest):
raise ValueError('Image job returned an invalid digest')
release['images'][image] = digest release['images'][image] = digest
release['inputs'][image] = inputs release['inputs'][image] = inputs
if not DIGEST.fullmatch(digest): report['reused' if exists else 'built'].append(name)
raise ValueError('Image job returned an invalid digest')
output.write_text(json.dumps(release, indent=2) + '\n') output.write_text(json.dumps(release, indent=2) + '\n')
report['current'] = None report['current'] = None
report['phase'] = 'Release file saved' report['phase'] = 'Image result file saved'
finally: finally:
# Cleanup errors must neither leak credentials nor mask the original build error. # Cleanup errors must neither leak credentials nor mask the original build error.
try: try:
@@ -395,13 +394,17 @@ def build(output, name, plan):
result = 'success' result = 'success'
finally: finally:
lines = [ lines = [
f'## Image release `{os.environ.get("GITHUB_SHA", "unknown")}`', f'## Image build result `{name}`',
'',
f'- Commit: `{os.environ.get("GITHUB_SHA", "unknown")}`',
'', '',
f'- Result: **{result}**', f'- Result: **{result}**',
f'- Last stage: {report["phase"]}', f'- Last stage: {report["phase"]}',
] ]
if result == 'failure': if result == 'failure':
lines.append('- No release from this build can be deployed. Open the failed step log.') lines.append('- This image job failed. The complete release cannot be published. Open the failed step log.')
if result == 'success':
lines.append('- This is one image result. The final build job must publish the complete release.')
if report['current']: if report['current']:
lines.append(f'- Image at the failure: `{report["current"]}`') lines.append(f'- Image at the failure: `{report["current"]}`')
for title, key in (('Built', 'built'), ('Reused from successful CI', 'reused')): for title, key in (('Built', 'built'), ('Reused from successful CI', 'reused')):
+3
View File
@@ -28,6 +28,8 @@ permissions:
jobs: jobs:
validate-renovate: validate-renovate:
permissions:
contents: read
runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
@@ -35,6 +37,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
# renovate/k8s/cronjob.yaml is the single source of truth for the version. # renovate/k8s/cronjob.yaml is the single source of truth for the version.
- name: Resolve the deployed Renovate version - name: Resolve the deployed Renovate version
+1 -1
View File
@@ -14,7 +14,7 @@ ACTIONLINT_VERSION="1.7.7"
SHELLCHECK_VERSION="0.11.0" SHELLCHECK_VERSION="0.11.0"
KUBECONFORM_VERSION="0.8.0" KUBECONFORM_VERSION="0.8.0"
PRETTIER_VERSION="3.8.1" PRETTIER_VERSION="3.8.1"
RUFF_VERSION="0.16.8" RUFF_VERSION="0.16.10"
YAMLLINT_VERSION="1.38.0" YAMLLINT_VERSION="1.38.0"
HADOLINT_VERSION="2.14.0" HADOLINT_VERSION="2.14.0"
# pip-audit reads the advisory database over the network, so a floating version # pip-audit reads the advisory database over the network, so a floating version
+3
View File
@@ -115,3 +115,6 @@ prometheus-stack/k8s/grafana-values.yaml
traefik/k8s/local-tls.yaml traefik/k8s/local-tls.yaml
converters/k8s/config.yaml converters/k8s/config.yaml
convertx/k8s/config.yaml convertx/k8s/config.yaml
# Graphify local index and generated reports
graphify-out/
+1 -1
View File
@@ -42,7 +42,7 @@ services:
bentopdf: bentopdf:
container_name: bentopdf container_name: bentopdf
image: bentopdf/bentopdf@sha256:4eb4ec8f5030faf87c29a73d3d5a2781f28a597cf440c3ab111eb96aee550871 image: bentopdfteam/bentopdf-simple:2.8.8
restart: unless-stopped restart: unless-stopped
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
app: bentopdf app: bentopdf
spec: spec:
containers: containers:
- image: bentopdf/bentopdf@sha256:4eb4ec8f5030faf87c29a73d3d5a2781f28a597cf440c3ab111eb96aee550871 - image: bentopdfteam/bentopdf-simple:2.8.8
imagePullPolicy: Always imagePullPolicy: Always
name: bentopdf name: bentopdf
ports: ports:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
kener: kener:
image: rajnandan1/kener:4.1.5 image: rajnandan1/kener:v4.1.7
container_name: kener container_name: kener
restart: unless-stopped restart: unless-stopped
# ports: # ports:
+1 -1
View File
@@ -31,7 +31,7 @@ spec:
spec: spec:
containers: containers:
- name: kener - name: kener
image: rajnandan1/kener:4.1.5 image: rajnandan1/kener:v4.1.7
envFrom: envFrom:
- configMapRef: - configMapRef:
name: kener-config name: kener-config
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
n8n: n8n:
image: docker.n8n.io/n8nio/n8n:2.43.0 image: docker.n8n.io/n8nio/n8n:2.43.2
container_name: n8n container_name: n8n
restart: unless-stopped restart: unless-stopped
environment: environment:
+1 -1
View File
@@ -31,7 +31,7 @@ spec:
spec: spec:
containers: containers:
- name: n8n - name: n8n
image: docker.n8n.io/n8nio/n8n:2.43.0 image: docker.n8n.io/n8nio/n8n:2.43.2
envFrom: envFrom:
- configMapRef: - configMapRef:
name: n8n-config name: n8n-config
+49
View File
@@ -0,0 +1,49 @@
# Paperless-ngx
Paperless-ngx runs in the `paperless` namespace. It uses the shared PostgreSQL
service in the `database` namespace and Valkey for its task queue. The document
library, exports, and consume folder are stored on the `local-path-retain`
volume. The PVC size is fixed at 50 GiB because this storage class does not
support volume expansion.
The local route is `https://papers.workstation.internal`; the public route is
`https://papers.forust.xyz`. Both use TLS. Paperless keeps its own login and
password authentication. OCR is configured for Russian and English documents.
## Prepare the secret
Create `k8s/secrets.yaml` on the workstation from
`k8s/secrets.yaml.example`. Set a unique random `PAPERLESS_SECRET_KEY`, a long
`PAPERLESS_ADMIN_PASSWORD`, and `PAPERLESS_DB_PASSWORD`.
Add the same `PAPERLESS_DB_PASSWORD` value to the local
`postgres/k8s/secrets.yaml` file. Keep both secret files out of Git. The
database bootstrap Job creates the `paperless` role and database from the
shared PostgreSQL secret. The job runs in the `database` namespace and needs
that namespace's existing `postgres-shared-secrets` Secret.
For example, generate a key with:
```sh
python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
```
Then apply the secret before enabling the service:
```sh
kubectl apply -f paperless/k8s/namespace.yaml
kubectl apply -f postgres/k8s/secrets.yaml
kubectl apply -f paperless/k8s/secrets.yaml
```
The normal deploy workflow applies the remaining manifests when
`paperless/k8s/active` is present. Verify the rollout and ingress after deploy:
```sh
kubectl -n paperless rollout status deployment/paperless
kubectl -n paperless get pods,pvc,services
```
Back up the `paperless-data` PVC and the shared PostgreSQL database. The PVC
contains the originals, archived PDFs, and export/consume folders. Valkey has
no persistent volume; queued tasks are recreated after a restart.
+28
View File
@@ -0,0 +1,28 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: paperless-prod-tls
namespace: paperless
spec:
secretName: paperless-prod-tls
dnsNames:
- papers.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: paperless
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+58
View File
@@ -0,0 +1,58 @@
apiVersion: batch/v1
kind: Job
metadata:
name: paperless-database-init
namespace: database
spec:
backoffLimit: 5
template:
metadata:
labels:
app.kubernetes.io/name: paperless-database-init
spec:
restartPolicy: OnFailure
containers:
- name: create-database
image: postgres:17.11-alpine
command:
- /bin/sh
- -ec
- |
PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \
--host postgres \
--username postgres \
--dbname postgres \
--set ON_ERROR_STOP=1 \
--set paperless_password="$PAPERLESS_DB_PASSWORD" <<'SQL'
SELECT format(
'CREATE ROLE paperless LOGIN PASSWORD %L',
:'paperless_password'
)
WHERE NOT EXISTS (
SELECT FROM pg_roles WHERE rolname = 'paperless'
)
\gexec
SELECT format('CREATE DATABASE paperless OWNER paperless')
WHERE NOT EXISTS (
SELECT FROM pg_database WHERE datname = 'paperless'
)
\gexec
SQL
env:
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: POSTGRES_ADMIN_PASSWORD
- name: PAPERLESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: PAPERLESS_DB_PASSWORD
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
+33
View File
@@ -0,0 +1,33 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-prod
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.forust.xyz`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: paperless-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-local
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.workstation.internal`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: internal-wildcard-tls
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: paperless
+39
View File
@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- protocol: TCP
port: 8000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-valkey-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: paperless
ports:
- protocol: TCP
port: 6379
+210
View File
@@ -0,0 +1,210 @@
apiVersion: v1
kind: Service
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
selector:
app.kubernetes.io/name: paperless
ports:
- name: http
port: 8000
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless
template:
metadata:
labels:
app.kubernetes.io/name: paperless
spec:
enableServiceLinks: false
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1
ports:
- name: http
containerPort: 8000
env:
- name: PAPERLESS_URL
value: https://papers.forust.xyz
- name: PAPERLESS_ALLOWED_HOSTS
value: papers.forust.xyz,papers.workstation.internal
- name: PAPERLESS_CSRF_TRUSTED_ORIGINS
value: https://papers.forust.xyz,https://papers.workstation.internal
- name: PAPERLESS_TIME_ZONE
value: Europe/Bratislava
- name: PAPERLESS_REDIS
value: redis://paperless-valkey:6379
- name: PAPERLESS_DBENGINE
value: postgresql
- name: PAPERLESS_DBHOST
value: postgres.database.svc.cluster.local
- name: PAPERLESS_DBNAME
value: paperless
- name: PAPERLESS_DBUSER
value: paperless
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_DB_PASSWORD
- name: PAPERLESS_OCR_LANGUAGE
value: rus+eng
- name: PAPERLESS_OCR_LANGUAGES
value: rus
- name: PAPERLESS_TASK_WORKERS
value: "1"
- name: PAPERLESS_ADMIN_USER
value: admin
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_SECRET_KEY
- name: PAPERLESS_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_ADMIN_PASSWORD
volumeMounts:
- name: documents
mountPath: /usr/src/paperless/data
subPath: data
- name: documents
mountPath: /usr/src/paperless/media
subPath: media
- name: documents
mountPath: /usr/src/paperless/export
subPath: export
- name: documents
mountPath: /usr/src/paperless/consume
subPath: consume
startupProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
failureThreshold: 60
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumes:
- name: documents
persistentVolumeClaim:
claimName: paperless-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data
namespace: paperless
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path-retain
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: Service
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
selector:
app.kubernetes.io/name: paperless-valkey
ports:
- name: redis
port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
template:
metadata:
labels:
app.kubernetes.io/name: paperless-valkey
spec:
containers:
- name: valkey
image: valkey/valkey:9.0.3-alpine
args:
- valkey-server
- --save
- ""
- --appendonly
- "no"
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 30
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: paperless-secrets
namespace: paperless
type: Opaque
stringData:
PAPERLESS_SECRET_KEY: "<GENERATE_WITH_python3_-c_import_secrets;_print(secrets.token_urlsafe(64))>"
PAPERLESS_ADMIN_PASSWORD: "<SET_A_LONG_UNIQUE_PASSWORD>"
PAPERLESS_DB_PASSWORD: "<SET_THE_SAME_VALUE_AS_database_PAPERLESS_DB_PASSWORD>"
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
portainer: portainer:
image: portainer/portainer-ce:2.45.1 image: portainer/portainer-ce:2.45.2
container_name: portainer container_name: portainer
restart: always restart: always
volumes: volumes:
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
spec: spec:
containers: containers:
- name: portainer - name: portainer
image: portainer/portainer-ce:2.45.1 image: portainer/portainer-ce:2.45.2
ports: ports:
- containerPort: 9000 - containerPort: 9000
volumeMounts: volumeMounts:
+1 -1
View File
@@ -19,7 +19,7 @@ spec:
restartPolicy: Never restartPolicy: Never
containers: containers:
- name: renovate - name: renovate
image: renovate/renovate:44.140.0 image: renovate/renovate:44.147.0
env: env:
- name: RENOVATE_PLATFORM - name: RENOVATE_PLATFORM
value: gitea value: gitea
+1 -1
View File
@@ -2,7 +2,7 @@ services:
renovate: renovate:
# Kept in step with renovate/k8s/cronjob.yaml by the "renovate self-update" # Kept in step with renovate/k8s/cronjob.yaml by the "renovate self-update"
# package rule in renovate/renovate.json. # package rule in renovate/renovate.json.
image: renovate/renovate:44.136.0 image: renovate/renovate:44.147.0
container_name: renovate container_name: renovate
restart: "no" restart: "no"
env_file: env_file:
+2 -1
View File
@@ -87,7 +87,8 @@ services:
- streaming - streaming
jellyseerr: jellyseerr:
image: fallenbagel/jellyseerr:latest image: ghcr.io/seerr-team/seerr:v3.5.0
init: true
container_name: jellyseerr container_name: jellyseerr
restart: unless-stopped restart: unless-stopped
environment: environment:
+26 -13
View File
@@ -78,16 +78,29 @@ spec:
# issuerRef: # issuerRef:
# name: letsencrypt-prod # name: letsencrypt-prod
# kind: ClusterIssuer # kind: ClusterIssuer
# --- ---
# apiVersion: cert-manager.io/v1 apiVersion: cert-manager.io/v1
# kind: Certificate kind: Certificate
# metadata: metadata:
# name: jellyseerr-prod-tls name: seerr-prod-tls
# namespace: streaming namespace: streaming
# spec: spec:
# secretName: jellyseerr-prod-tls secretName: seerr-prod-tls
# dnsNames: dnsNames:
# - jellyseerr.forust.xyz - seerr.forust.xyz
# issuerRef: issuerRef:
# name: letsencrypt-prod name: letsencrypt-prod
# kind: ClusterIssuer kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: jelly-prod-tls
namespace: streaming
spec:
secretName: jelly-prod-tls
dnsNames:
- jelly.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+34
View File
@@ -1,5 +1,39 @@
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata:
name: seerr-prod
namespace: streaming
spec:
entryPoints:
- websecure
routes:
- match: Host(`seerr.forust.xyz`)
kind: Rule
services:
- name: jellyseerr
port: 15055
tls:
secretName: seerr-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: jelly-prod
namespace: streaming
spec:
entryPoints:
- websecure
routes:
- match: Host(`jelly.forust.xyz`)
kind: Rule
services:
- name: jellyfin
port: 18096
tls:
secretName: jelly-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: metadata:
name: jellyfin-local name: jellyfin-local
namespace: streaming namespace: streaming
+24
View File
@@ -0,0 +1,24 @@
"""Keep unit-test workflow commands out of the real CI job files."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
CI_COMMAND_FILES = ('GITHUB_STEP_SUMMARY', 'GITHUB_OUTPUT', 'GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_STATE')
class IsolatedCITestCase(unittest.TestCase):
def setUp(self):
super().setUp()
directory = tempfile.TemporaryDirectory(prefix='homelab-test-ci-')
self.addCleanup(directory.cleanup)
paths = {}
for variable in CI_COMMAND_FILES:
path = Path(directory.name) / variable
path.touch()
paths[variable] = str(path)
environment = patch.dict(os.environ, paths)
environment.start()
self.addCleanup(environment.stop)
+60
View File
@@ -0,0 +1,60 @@
"""Run the real unit tests with external CI files and detect leaked writes."""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
from ci_test_case import CI_COMMAND_FILES, IsolatedCITestCase
class CIOutputIsolationTests(IsolatedCITestCase):
def test_all_command_files_are_private_and_environment_is_restored(self):
with tempfile.TemporaryDirectory() as scratch:
external = {variable: str(Path(scratch) / variable) for variable in CI_COMMAND_FILES}
for path in external.values():
Path(path).write_text('external CI file\n')
with patch.dict(os.environ, external):
probe = IsolatedCITestCase()
probe.setUp()
private = []
try:
for variable in CI_COMMAND_FILES:
self.assertNotEqual(os.environ[variable], external[variable])
path = Path(os.environ[variable])
private.append(path)
path.write_text('test-only command\n')
finally:
probe.doCleanups()
for variable in CI_COMMAND_FILES:
self.assertEqual(os.environ[variable], external[variable])
self.assertEqual(Path(external[variable]).read_text(), 'external CI file\n')
self.assertTrue(all(not path.exists() for path in private))
def test_unit_suite_preserves_external_ci_files(self):
tests = Path(__file__).resolve().parent
modules = sorted(p.stem for p in tests.glob('test_*.py') if p.name != Path(__file__).name)
with tempfile.TemporaryDirectory() as scratch:
environment = os.environ.copy()
environment['PYTHONPATH'] = str(tests) + os.pathsep + environment.get('PYTHONPATH', '')
expected = {}
for variable in CI_COMMAND_FILES:
path = Path(scratch) / variable
content = f'external {variable}\n'
path.write_text(content)
environment[variable] = str(path)
expected[path] = content
result = subprocess.run( # noqa: S603 -- Run local test modules with the current Python interpreter.
[sys.executable, '-m', 'unittest', *modules, '-q'],
cwd=tests.parent,
env=environment,
capture_output=True,
text=True,
check=False,
timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for path, content in expected.items():
self.assertEqual(path.read_text(), content, f'Unit tests wrote to external {path.name}')
+7 -4
View File
@@ -9,6 +9,8 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from ci_test_case import IsolatedCITestCase
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -34,7 +36,7 @@ def release(sha='a' * 40):
} }
class ReleaseGateTests(unittest.TestCase): class ReleaseGateTests(IsolatedCITestCase):
def test_release_rejects_wrong_sha_missing_images_and_mutable_tags(self): def test_release_rejects_wrong_sha_missing_images_and_mutable_tags(self):
for mutation in ('sha', 'missing', 'tag'): for mutation in ('sha', 'missing', 'tag'):
data = release() data = release()
@@ -91,8 +93,9 @@ class ReleaseGateTests(unittest.TestCase):
api.release({'id': 1, 'head_sha': 'a' * 40}) api.release({'id': 1, 'head_sha': 'a' * 40})
class SelectionTests(unittest.TestCase): class SelectionTests(IsolatedCITestCase):
def setUp(self): def setUp(self):
super().setUp()
self.scratch = tempfile.TemporaryDirectory() self.scratch = tempfile.TemporaryDirectory()
self.addCleanup(self.scratch.cleanup) self.addCleanup(self.scratch.cleanup)
self.repo = Path(self.scratch.name) self.repo = Path(self.scratch.name)
@@ -171,7 +174,7 @@ class SelectionTests(unittest.TestCase):
self.assertEqual(result['selected']['k8s'], ['one', 'postgres', 'two']) self.assertEqual(result['selected']['k8s'], ['one', 'postgres', 'two'])
class ComposeConfigurationTests(unittest.TestCase): class ComposeConfigurationTests(IsolatedCITestCase):
def test_pin_preserves_project_volumes_paths_and_previous_image(self): def test_pin_preserves_project_volumes_paths_and_previous_image(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch) root = Path(scratch)
@@ -305,7 +308,7 @@ class ComposeConfigurationTests(unittest.TestCase):
) )
class ControllerTests(unittest.TestCase): class ControllerTests(IsolatedCITestCase):
def test_completed_stage_cannot_apply_again(self): def test_completed_stage_cannot_apply_again(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
directory = Path(scratch) directory = Path(scratch)
+76 -4
View File
@@ -10,10 +10,11 @@ import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from ci_test_case import IsolatedCITestCase
from test_cicd import ROOT, controller, release, release_module from test_cicd import ROOT, controller, release, release_module
class ArtifactTests(unittest.TestCase): class ArtifactTests(IsolatedCITestCase):
def test_archive_rejects_nested_or_extra_files(self): def test_archive_rejects_nested_or_extra_files(self):
api = object.__new__(release_module.Gitea) api = object.__new__(release_module.Gitea)
api.base = 'https://example.test/api/v1/repos/a/b' api.base = 'https://example.test/api/v1/repos/a/b'
@@ -103,7 +104,7 @@ class ArtifactTests(unittest.TestCase):
self.assertEqual(json.loads((root / 'error-pages.json').read_text())['sha'], 'e' * 40) self.assertEqual(json.loads((root / 'error-pages.json').read_text())['sha'], 'e' * 40)
class DurableRunTests(unittest.TestCase): class DurableRunTests(IsolatedCITestCase):
def test_duplicate_start_only_reattaches(self): def test_duplicate_start_only_reattaches(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
state = Path(scratch) state = Path(scratch)
@@ -203,7 +204,7 @@ class DurableRunTests(unittest.TestCase):
self.assertEqual(json.loads((directory / 'status.json').read_text())['state'], 'failure') self.assertEqual(json.loads((directory / 'status.json').read_text())['state'], 'failure')
class FailureSummaryTests(unittest.TestCase): class FailureSummaryTests(IsolatedCITestCase):
def test_build_failure_keeps_progress_and_does_not_expose_exception_text(self): def test_build_failure_keeps_progress_and_does_not_expose_exception_text(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
summary = Path(scratch) / 'summary.md' summary = Path(scratch) / 'summary.md'
@@ -225,6 +226,77 @@ class FailureSummaryTests(unittest.TestCase):
self.assertIn('xdfnx-homepage', content) self.assertIn('xdfnx-homepage', content)
self.assertNotIn('private value', content) self.assertNotIn('private value', content)
def test_invalid_digest_is_not_reported_as_a_completed_image(self):
for digest in ('invalid-private-metadata', None, ['invalid']):
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
summary = root / 'summary.md'
name = 'error-pages'
context, dockerfile = release_module.IMAGES[name]
plan = {
'sha': 'a' * 40,
'targets': [
{
'name': name,
'context': context,
'dockerfile': dockerfile,
'inputs': 'c' * 64,
'reuse_digest': None,
}
],
}
def fake_command(*args, digest=digest, **_kwargs):
if args[:3] == ('docker', 'buildx', 'build'):
Path(args[args.index('--metadata-file') + 1]).write_text(
json.dumps({'containerimage.digest': digest})
)
return ''
with (
patch.dict(
os.environ,
{
'GITHUB_STEP_SUMMARY': str(summary),
'GITHUB_SHA': 'a' * 40,
'REGISTRY_USERNAME': 'test',
'REGISTRY_PASSWORD': 'placeholder',
},
),
patch.object(release_module, 'checked_plan', return_value=plan),
patch.object(release_module.Path, 'home', return_value=root),
patch.object(release_module, 'command', side_effect=fake_command),
patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 0)),
self.assertRaisesRegex(ValueError, 'invalid digest'),
):
release_module.build(root / 'image.json', name, root / 'plan.json')
self.assertFalse((root / 'image.json').exists())
content = summary.read_text()
self.assertIn('**failure**', content)
self.assertIn('### Built\n- None', content)
self.assertIn('### Completed image digests\n- None', content)
self.assertNotIn('invalid-private-metadata', content)
def test_successful_image_result_does_not_claim_complete_release(self):
def complete_image(_output, report, _name, _plan):
report.update(phase='Image result file saved', built=['error-pages'])
report['images']['gcr.forust.xyz/forust/error-pages'] = 'sha256:' + 'b' * 64
with (
patch.dict(os.environ, {'GITHUB_SHA': 'a' * 40}),
patch.object(
release_module,
'build_images',
side_effect=complete_image,
),
):
release_module.build(Path('unused.json'), 'error-pages', Path('unused-plan.json'))
content = Path(os.environ['GITHUB_STEP_SUMMARY']).read_text()
self.assertIn('## Image build result `error-pages`', content)
self.assertIn('Commit: `' + 'a' * 40 + '`', content)
self.assertIn('final build job must publish the complete release', content)
self.assertNotIn('## Image release', content)
def test_deploy_failure_reports_completed_apply_and_rollback_result(self): def test_deploy_failure_reports_completed_apply_and_rollback_result(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
state = Path(scratch) state = Path(scratch)
@@ -261,7 +333,7 @@ class FailureSummaryTests(unittest.TestCase):
self.assertIn('Compose requires manual recovery', content) self.assertIn('Compose requires manual recovery', content)
class InstallerTests(unittest.TestCase): class InstallerTests(IsolatedCITestCase):
def test_version_comparison_is_exact_without_network_or_host_packages(self): def test_version_comparison_is_exact_without_network_or_host_packages(self):
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch) root = Path(scratch)
+2 -2
View File
@@ -3,10 +3,10 @@
import json import json
import os import os
import tempfile import tempfile
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
from ci_test_case import IsolatedCITestCase
from test_cicd import release, release_module from test_cicd import release, release_module
@@ -26,7 +26,7 @@ def plan_data(changed):
return {'sha': 'a' * 40, 'targets': targets} return {'sha': 'a' * 40, 'targets': targets}
class MatrixTests(unittest.TestCase): class MatrixTests(IsolatedCITestCase):
def test_no_change_one_image_all_images_and_missing_baseline(self): def test_no_change_one_image_all_images_and_missing_baseline(self):
for changed in (set(), {'error-pages'}, set(release_module.IMAGES)): for changed in (set(), {'error-pages'}, set(release_module.IMAGES)):
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as scratch: with self.subTest(changed=changed), tempfile.TemporaryDirectory() as scratch:
+4 -1
View File
@@ -7,11 +7,14 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from ci_test_case import IsolatedCITestCase
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
class NetbirdRuntimeTests(unittest.TestCase): class NetbirdRuntimeTests(IsolatedCITestCase):
def setUp(self): def setUp(self):
super().setUp()
self.temp = tempfile.TemporaryDirectory() self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup) self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name) self.root = Path(self.temp.name)
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: traefik:v3.7.13 image: traefik:v3.7.14
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
command: command:
+1 -1
View File
@@ -3,7 +3,7 @@ hostNetwork: false
image: image:
registry: docker.io/library registry: docker.io/library
repository: traefik repository: traefik
tag: v3.7.13 tag: v3.7.14
securityContext: securityContext:
capabilities: capabilities: