Compare commits

...
24 Commits
Author SHA1 Message Date
Bastian de BylandClaude Fable 5.1 bc423a1c7c chore(gitea-actions): bump ESP-IDF CI image to v5.5.1
CI Images / Plan (push) Successful in 35s
CI Images / Build ci (push) Successful in 7m53s
CI Images / Build espidf (push) Successful in 18m27s
CI Images / Build platformio (push) Successful in 25m48s
esp32-vi-can-obd2 needs the IDF 5.5 TWAI node driver. ci-images.yml publishes
gitbot/gitea-ci-espidf:v5.5.1 from this pin; the v5.4.1 tag stays in the
registry for esp-mg-tpms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:08:58 -04:00
Bastian de BylandClaude Opus 5.5 4b826a0008 chore(gregtime): bump to 3.21.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:22 -04:00
Bastian de BylandClaude Opus 5.5 4220a2190d chore(gregtime): bump to 3.21.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:42:30 -04:00
Bastian de BylandClaude Opus 5.5 867b3038c8 chore(gregtime): bump to 3.20.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:09:39 -04:00
Bastian de BylandClaude Opus 5.5 ecb85e96d0 chore(gregtime): bump to 3.20.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:42:43 -04:00
Bastian de BylandClaude Opus 5.5 135c26369f chore(gregtime): bump to 3.19.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:27:07 -04:00
Bastian de BylandClaude Opus 5.5 bcebdc19cc chore(gregtime): bump to 3.19.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:36:55 -04:00
Bastian de BylandClaude Opus 5.5 a38cb080da feat(hass): hook the living room desk lamp into the light schedule
switch.desk_lamp (EP10) is an on/off switch, so the area-targeted
light.* actions skip it. It now follows light.living_room explicitly:
on at sunset unless TV mode is on, on when the TV goes off in the
evening, and off with the living room at 23:30, the 01:00 sweep and
when the TV goes off late at night.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:21:09 -04:00
Bastian de BylandClaude Opus 5.5 3d03332aaa fix(hass): drop dead device-based automations, use entity ids
The five "Lights - 01/02/03/04/10" automations had no triggers left and
were superseded by the sunset/evening-ramp/lights-out/sweep automations;
nothing references them. "Driveway String Lights Off" and "Lights - 00 -
Morning" referenced device ids that no longer exist in the device
registry (they only worked because HA resolved the entity registry id),
so they now target switch.driveway_string_lights and
light.bathroom_hallway directly. Ids and aliases are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:58:01 -04:00
Bastian de BylandClaude Opus 5.5 f575f8f62d chore(hass): add one-off playbook to repoint tplink entries at IoT VLAN
The IoT WiFi moved from Default (192.168.1.x) to the IoT VLAN
(192.168.2.x) with fixed IPs reserved in UniFi. HA 2026.9.3's tplink
reconfigure flow ignores the entered host and reconnects to the stored
one, so this edits data.host in .storage/core.config_entries with HA
stopped, matching entries by MAC and refusing to write unless all 9
match. Backs up core.config_entries first and always restarts hass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:45:59 -04:00
Bastian de BylandClaude Opus 5.5 339d4b150c chore(gregtime): bump to 3.19.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:46:04 -04:00
Bastian de BylandClaude Fable 5.1 0bd6c6e3fe chore(fulfillr): bump prod and dev image to 20260929.1624 (SCRUM-208 activity feed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:40:08 -04:00
Bastian de BylandClaude Fable 5.1 4759ee5909 chore(fulfillr): bump prod and dev image to 20260929.1553 (SCRUM-207, SCRUM-208)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:04:00 -04:00
Bastian de BylandClaude Fable 5.1 a55e7b7c3d chore(fulfillr): bump prod image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:23:53 -04:00
Bastian de BylandClaude Fable 5.1 554d3acb6e chore(fulfillr-dev): bump image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:22:05 -04:00
Bastian de BylandClaude Fable 5.1 69d2c2de92 chore(fulfillr): bump prod and dev image to 20260929.0203 (project invoicing, SCRUM-205)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:39:39 -04:00
Bastian de BylandClaude Fable 5.1 72c1006b3e chore(fulfillr-dev): bump image to 20260929.0015 (project invoicing, SCRUM-202)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 20:23:29 -04:00
Bastian de BylandClaude Opus 5.5 1f399cee91 chore(vault): rotate gitea registry token to write:package
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:53:46 -04:00
Bastian de BylandClaude Opus 5.5 0cc4c1460e feat(debyltech-cloud): limit customers to Files, Activity and signing
Nothing was group-restricted, so customers saw Dashboard, Photos, Office
and the rest, plus Nextcloud's first-run and promo apps.

- Disable for everyone: firstrunwizard, recommendations, related_resources,
  weather_status, survey_client, support, app_api, contactsinteraction,
  photos. A refused disable now fails the play (occ exits 0 on "can't be
  disabled").
- Restrict dashboard and office to staff. defaultapp=dashboard,files so
  staff land on the dashboard and customers fall through to Files.
- libresign groups_request_sign pinned to staff and asserted in verify.
  LibreSign itself is deliberately NOT group-restricted: that also blocks
  anonymous requests and would break public signing links.
- profile.enabled=false; lookup_server="" (lookup_server_connector
  can't be disabled).
- README: what customers can open.

Checked as a probe customer: apps=files,activity,libresign,text,viewer,
lands in Files, can't request signatures; staff land on Dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:48:39 -04:00
Bastian de BylandClaude Opus 5.5 c4fe506860 feat(debyltech-cloud): lock customers to read-only, no discovery
Customers are admin-created accounts in per-customer groups. They should
read what staff share with them and nothing else. Checked against a test
customer in the UI, and by running the sharee and contacts-menu search
services as that user.

- shareapi_exclude_groups=allow, list [admin]: only staff can share. Exclude
  mode ("yes") only disables sharing for users whose groups are ALL
  excluded, so it never catches a customer in their own group.
- User/group autocomplete off. By default a customer typing "bas" found the
  owner's account. Staff share by exact group name; LibreSign signers are
  found by email.
- New shares default to View only (shareapi_default_permissions=1).
- files default_quota 0 B, so customers get no personal storage. Staff in
  cloud_debyltech_staff_users are exempted (skipped if not yet created).
- No "Leon Green" sample contact in new address books.
- The verify script fails the deploy if any isolation setting drifts.
- README: staff and customer onboarding checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:47 -04:00
Bastian de BylandClaude Opus 5.5 be50798096 fix(ci-images): static build matrix for Gitea
CI Images / Plan (push) Successful in 29s
CI Images / Build ci (push) Failing after 1m8s
CI Images / Build espidf (push) Failing after 1m16s
CI Images / Build platformio (push) Failing after 1m30s
Gitea expands a job's matrix when the run is created, before plan has any
outputs, so fromJSON(needs.plan.outputs.matrix) collapsed to a single empty
"Build ${{ matrix.key }}" job and nothing was ever built. The matrix is now
the fixed list of image keys; plan emits every image's spec with a build flag
and each matrix job looks its own entry up, no-opping when it wasn't picked.

Also document that both registry tokens need write:package -- the vault token
was read-only, so the gitea_ci_build_local bootstrap failed its push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:00:35 -04:00
Bastian de BylandClaude Opus 5.5 8701ada7e2 feat(debyltech-cloud): plain login background, empty homes for new accounts
- theming background -> backgroundColor, dropping the stock image for the
  navy theming colour.
- skeletondirectory/templatedirectory set to "" so customer accounts start
  empty instead of getting Nextcloud's sample Manual, intro video and
  Templates folder.
- The system-config compare now tells an unset key apart from an empty
  value. Both print nothing, so an intentionally empty setting was
  silently skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:38:52 -04:00
Bastian de BylandClaude Opus 5.5 9ce9610965 fix(debyltech-cloud): external signers, proxy trust, light-only mail
- Enable LibreSign's email identify method (click-to-sign, no account
  creation), remove the stamp background and collect signer metadata.
  On Skudak these were only ever set in the admin UI. Without the email
  method a fresh instance answers "No signers." for any outside address.
  The verify script now asserts it.
- Store add_footer=true explicitly. The code already defaults to it, but
  the 14.2 admin page shows unset as unchecked.
- trusted_proxies = the container's own address, read per deploy. Behind
  rootless port forwarding every request arrives from it, so without this
  X-Forwarded-For was ignored and every client shared one IP for
  brute-force throttling.
- maintenance_window_start and default_phone_region, which clears the setup
  warnings.
- Mail declares color-scheme "light only" so Apple Mail's dark mode doesn't
  repaint the white ground and bury the black wordmark (asserted in verify).
- Idempotency: redis image fully qualified (docker.io/library/...), the
  debyltechmail copy owned by the mapped www-data uid, and theming
  compared before setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:08:41 -04:00
Bastian de BylandClaude Opus 5.5 fa5bbf8e54 feat(debyltech-cloud): add cloud.debyltech.com Nextcloud
A de Byl Technologies LLC Nextcloud cloned from the Skudak instance:
LibreSign signing for people without an account, registration off
(admin-created accounts only), no Group Folders. DNS is a terraform-managed
ALIAS to fulfillr.debyltech.com.

- containers/debyltech/cloud.yml: nextcloud/mariadb/redis on port 8091.
  It installs unattended on the first deploy, sends mail through SES as
  noreply@debyltech.com, and re-asserts the Skudak LibreSign settings.
- files/debyltechmail: skudakmail rebranded, with a new black-and-white
  wordmark and white web-UI logos.
- LibreSign is pinned to 14.2.2 from the GitHub release (sha256-checked)
  rather than `occ app:install`. The app store served a same-day 14.2.3
  whose tarball has no binary-signature metadata. 14.2.x also doesn't
  create its own download dirs, so they're pre-created.
- The backup runs nightly at 04:15 to TrueNAS /mnt/glacier/debyltechcloud and
  reaches personal iDrive via the "iDrive E2 Backup" task; the TrueNAS side
  excludes /debyltechcloud/_backup/config/**.
- Fix the libresign:configure:check gate in both instances: '\berror\b'
  becomes a backspace in Jinja and never matched, so a check reporting three
  errors passed clean. Now '\\berror\\b'.
- vault: cloud_debyltech_* secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:04:04 -04:00
26 changed files with 2204 additions and 201 deletions
+40 -9
View File
@@ -63,7 +63,7 @@ jobs:
name: Plan name: Plan
runs-on: fedora runs-on: fedora
outputs: outputs:
matrix: ${{ steps.plan.outputs.matrix }} images: ${{ steps.plan.outputs.images }}
any: ${{ steps.plan.outputs.any }} any: ${{ steps.plan.outputs.any }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -153,7 +153,12 @@ jobs:
else: else:
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched] picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
print(f"matrix={json.dumps({'include': picked})}") # Every image, keyed by matrix.key, each flagged build or skip. The
# build job's matrix is static (see there), so it needs the full set
# to look its own entry up in, not just the picked ones.
picked_keys = {i["key"] for i in picked}
specs = {i["key"]: {**i, "build": i["key"] in picked_keys} for i in images}
print(f"images={json.dumps(specs)}")
print(f"any={'true' if picked else 'false'}") print(f"any={'true' if picked else 'false'}")
print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr) print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
PY PY
@@ -167,11 +172,34 @@ jobs:
# One image failing must not cancel the others: they are independent, and # One image failing must not cancel the others: they are independent, and
# a half-published set is what this whole workflow exists to avoid. # a half-published set is what this whole workflow exists to avoid.
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} # Static on purpose. Gitea expands the matrix when the run is created,
# before plan has produced any outputs, so a
# fromJSON(needs.plan.outputs.*) matrix collapses to one empty job. Each
# entry instead looks its spec up in plan's output and no-ops its steps
# when plan did not pick it. Keys must match `images` in plan.
matrix:
key: [ci, espidf, platformio]
steps: steps:
- name: Look up the ${{ matrix.key }} image spec
id: spec
env:
IMAGES: ${{ needs.plan.outputs.images }}
KEY: ${{ matrix.key }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os
spec = json.loads(os.environ["IMAGES"])[os.environ["KEY"]]
for k in ("containerfile", "tag", "build_args"):
print(f"{k}={spec[k]}")
print(f"build={'true' if spec['build'] else 'false'}")
PY
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: steps.spec.outputs.build == 'true'
- name: Log in to the Gitea Container Registry - name: Log in to the Gitea Container Registry
if: steps.spec.outputs.build == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -184,18 +212,20 @@ jobs:
# image, so PRs get a throwaway tag that the cleanup step removes. # image, so PRs get a throwaway tag that the cleanup step removes.
- name: Resolve build tag - name: Resolve build tag
id: tag id: tag
if: steps.spec.outputs.build == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "image=${{ matrix.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT" echo "image=${{ steps.spec.outputs.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else else
echo "image=${{ matrix.tag }}" >> "$GITHUB_OUTPUT" echo "image=${{ steps.spec.outputs.tag }}" >> "$GITHUB_OUTPUT"
fi fi
- name: Build ${{ matrix.key }} - name: Build ${{ matrix.key }}
if: steps.spec.outputs.build == 'true'
env: env:
IMAGE: ${{ steps.tag.outputs.image }} IMAGE: ${{ steps.tag.outputs.image }}
BUILD_ARGS: ${{ matrix.build_args }} BUILD_ARGS: ${{ steps.spec.outputs.build_args }}
run: | run: |
set -euo pipefail set -euo pipefail
args=() args=()
@@ -205,10 +235,11 @@ jobs:
docker build --pull \ docker build --pull \
"${args[@]}" \ "${args[@]}" \
-t "$IMAGE" \ -t "$IMAGE" \
-f "$CONTEXT/${{ matrix.containerfile }}" \ -f "$CONTEXT/${{ steps.spec.outputs.containerfile }}" \
"$CONTEXT" "$CONTEXT"
- name: Verify the prune-exemption label survived the build - name: Verify the prune-exemption label survived the build
if: steps.spec.outputs.build == 'true'
env: env:
IMAGE: ${{ steps.tag.outputs.image }} IMAGE: ${{ steps.tag.outputs.image }}
run: | run: |
@@ -224,7 +255,7 @@ jobs:
} }
- name: Push ${{ matrix.key }} - name: Push ${{ matrix.key }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' && steps.spec.outputs.build == 'true'
env: env:
IMAGE: ${{ steps.tag.outputs.image }} IMAGE: ${{ steps.tag.outputs.image }}
run: | run: |
@@ -236,7 +267,7 @@ jobs:
# so the nightly prune will not reclaim it and a few skipped cleanups add # so the nightly prune will not reclaim it and a few skipped cleanups add
# up to gigabytes in the runner's store. # up to gigabytes in the runner's store.
- name: Drop the pull-request image - name: Drop the pull-request image
if: always() && github.event_name == 'pull_request' if: always() && github.event_name == 'pull_request' && steps.spec.outputs.build == 'true'
env: env:
IMAGE: ${{ steps.tag.outputs.image }} IMAGE: ${{ steps.tag.outputs.image }}
run: docker rmi -f "$IMAGE" || true run: docker rmi -f "$IMAGE" || true
+1 -1
View File
@@ -60,7 +60,7 @@ ansible/
Containers are organized in `ansible/roles/podman/tasks/containers/`: Containers are organized in `ansible/roles/podman/tasks/containers/`:
- `base/` - Core infrastructure containers (Caddy web server, AWS DDNS) - `base/` - Core infrastructure containers (Caddy web server, AWS DDNS)
- `home/` - Home-specific services (Home Assistant, PartKeepr, Immich photos, Nextcloud, Redis) - `home/` - Home-specific services (Home Assistant, PartKeepr, Immich photos, Nextcloud, Redis)
- `debyltech/` - Personal/business services (Fulfillr) - `debyltech/` - Personal/business services (Fulfillr, Nextcloud at cloud.debyltech.com - cloned from the Skudak instance, backs up to personal iDrive via TrueNAS)
- `skudak/` - Additional services (BookStack wiki, Nextcloud) - `skudak/` - Additional services (BookStack wiki, Nextcloud)
### Security Model ### Security Model
@@ -0,0 +1,50 @@
"""Point HA's 9 TP-Link (Kasa) config entries at their new IoT VLAN addresses.
Run on galactica as the podman user while HA is STOPPED:
python3 /tmp/fix_tplink.py # dry run, prints what would change
python3 /tmp/fix_tplink.py apply # writes the change (refuses unless all 9 match)
Only data.host is changed; entries are matched by MAC (unique_id).
"""
import json
import os
import re
import sys
path = "/home/podman/.local/share/volumes/hass/config/.storage/core.config_entries"
new = {
"54af970a5d01": "192.168.2.220", # Printer Plug KP115
"788cb56955b4": "192.168.2.146", # Driveway String Lights HS200
"2887ba1bd687": "192.168.2.199", # Kitchen Wall Light KS230
"5ce9319dc5d6": "192.168.2.38", # Kitchen Lights HS220
"5ce9319da5e0": "192.168.2.200", # Dining Hall HS220
"5ce9319dd193": "192.168.2.159", # Dining Room HS220
"5ce931a23a6e": "192.168.2.61", # Bathroom Hallway HS220
"f0a731771227": "192.168.2.55", # Stair Light KS230
"788cb5694e4a": "192.168.2.252", # Bedroom Light HS200
}
apply = len(sys.argv) > 1 and sys.argv[1] == "apply"
with open(path) as f:
d = json.load(f)
n = 0
for e in d["data"]["entries"]:
if e.get("domain") != "tplink":
continue
mac = re.sub(r"[^0-9a-f]", "", (e.get("unique_id") or "").lower())
ip = new.get(mac)
print(f'{e["title"]:<36} host={e["data"].get("host")} -> {ip}')
if ip:
e["data"]["host"] = ip
n += 1
print("matched", n, "of 9")
if apply:
if n != 9:
sys.exit("refusing to write: not all 9 matched")
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f, indent=4, ensure_ascii=False)
os.replace(tmp, path)
print("written")
+72
View File
@@ -0,0 +1,72 @@
---
# One-off: repoint Home Assistant's TP-Link (Kasa) config entries at their new
# addresses after the IoT WiFi moved from the Default network to the IoT VLAN
# (192.168.1.x -> 192.168.2.x, fixed IPs reserved in UniFi).
#
# HA 2026.9.3's tplink "Reconfigure" flow ignores the host entered in the form
# and reconnects to the stored one, so it cannot be used to change the address.
# Instead this edits data.host in .storage/core.config_entries while HA is
# stopped. Entries are matched by MAC (unique_id); nothing else is touched.
#
# Run:
# .venv/bin/ansible-playbook -i ansible/inventories/home/hosts.yml \
# ansible/oneoff/hass-tplink-rehost.yml
- name: Repoint HA tplink entries at the IoT VLAN
hosts: home.debyl.io
gather_facts: false
become: true
become_user: podman
vars:
hass_storage: /home/podman/.local/share/volumes/hass/config/.storage
hass_entries: "{{ hass_storage }}/core.config_entries"
tasks:
- name: Get podman uid for systemctl --user
ansible.builtin.command: id -u
register: podman_uid
changed_when: false
- name: Dry run - all 9 tplink entries must match before HA is stopped
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py
executable: python3
register: dry_run
changed_when: false
failed_when: "'matched 9 of 9' not in dry_run.stdout"
- name: Show dry run
ansible.builtin.debug:
var: dry_run.stdout_lines
- name: Back up core.config_entries
ansible.builtin.copy:
src: "{{ hass_entries }}"
dest: "{{ hass_entries }}.bak-iot-vlan-20261001"
remote_src: true
mode: preserve
force: false
- name: Stop HA, rewrite hosts, start HA
environment:
XDG_RUNTIME_DIR: "/run/user/{{ podman_uid.stdout }}"
block:
- name: Stop hass
ansible.builtin.systemd:
name: hass.service
state: stopped
scope: user
- name: Rewrite tplink hosts
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py apply
executable: python3
register: applied
- name: Show result
ansible.builtin.debug:
var: applied.stdout_lines
always:
- name: Start hass
ansible.builtin.systemd:
name: hass.service
state: started
scope: user
+5
View File
@@ -59,6 +59,11 @@ belonging to the same `gitbot` user: Gitea authorises a package push by the
token's owner, not by the path, so pushing to `gitbot/` means logging in as token's owner, not by the path, so pushing to `gitbot/` means logging in as
`gitbot`. `gitbot`.
Both tokens need the `write:package` scope, not just `read:package`: the
workflow pushes with `REGISTRY_TOKEN`, and the `gitea_ci_build_local` bootstrap
below pushes with the vault token. A read-only token logs in and pulls fine but
fails the push with `authentication required` (Gitea logs `reqPackageAccess`).
### Rebuilding ### Rebuilding
Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push
@@ -42,7 +42,7 @@ gitea_ci_registry: git.debyl.io
gitea_ci_registry_namespace: gitbot gitea_ci_registry_namespace: gitbot
gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest" gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest"
# ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from. # ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from.
esp_idf_version: v5.4.1 esp_idf_version: v5.5.1
gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}" gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}"
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface). # PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
# Tag tracks the pre-baked espressif32 platform version; both pins match the # Tag tracks the pre-baked espressif32 platform version; both pins match the
+56 -10
View File
@@ -39,9 +39,13 @@ before you need it, and record the date you last did.
Dumps live on the host at `/var/backups/nextcloud/<name>/db/<name>-YYYYMMDD.sql.gz` Dumps live on the host at `/var/backups/nextcloud/<name>/db/<name>-YYYYMMDD.sql.gz`
and on TrueNAS at `<remote_path>/_backup/db/`. TrueNAS in turn cloud-syncs and on TrueNAS at `<remote_path>/_backup/db/`. TrueNAS in turn cloud-syncs
`/mnt/glacier/skudakcloud` to Skudak's own iDrive e2 bucket, so a third copy each dataset offsite, so a third copy exists there — but restoring from it
exists there — but restoring from it means going through the TrueNAS console, means going through the TrueNAS console, not this host:
not this host.
| Dataset | Offsite |
|---|---|
| `skudakcloud`, `skudakapps`, `skudakgit` | Skudak's own iDrive e2 bucket (excluded from the personal task) |
| `nextcloud`, `gitea`, `debyltechcloud` | Personal iDrive e2 bucket, via the "iDrive E2 Backup" task over `/mnt/glacier` |
Verify the dump before trusting it: Verify the dump before trusting it:
@@ -102,23 +106,25 @@ The signing CA lives in the data tree at
brings it back with everything else. After restoring, confirm it: brings it back with everything else. After restoring, confirm it:
```bash ```bash
sudo -H -u podman bash -c 'cd; podman exec -u www-data skudak-cloud php occ libresign:configure:check' sudo -H -u podman bash -c 'cd; podman exec -u www-data <skudak-cloud|debyltech-cloud> php occ libresign:configure:check'
``` ```
Every check must report `success`. If `openssl-configure` reports an error, the Every check must report `success`. If `openssl-configure` reports an error, the
`certificate_engine` / `config_path` app config is pointing somewhere without a `certificate_engine` / `config_path` app config is pointing somewhere without a
CA — see the guarded generate task in `tasks/containers/skudak/cloud.yml`. CA — see the guarded generate task in `tasks/containers/{skudak,debyltech}/cloud.yml`.
**Do not** simply re-run `libresign:configure:openssl` on a restored instance **Do not** simply re-run `libresign:configure:openssl` on a restored instance
without understanding why: it mints a *new* root CA and invalidates the trust without understanding why: it mints a *new* root CA and invalidates the trust
chain on every document already signed under the old one. chain on every document already signed under the old one.
## LibreSign ## LibreSign
Deployed on `skudak-cloud` only. LibreSign 14.1.0 requires Nextcloud server Deployed on `skudak-cloud` and `debyltech-cloud`. LibreSign 14.1.0 requires
`>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.2-apache` satisfies. If the Nextcloud server `>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.3-apache`
Nextcloud tag is bumped to 35, LibreSign must be held or upgraded in step — the satisfies. If the Nextcloud tag is bumped to 35, LibreSign must be held or
two instances are pinned independently in `tasks/main.yml`, so `skudak-cloud` upgraded in step — each instance is pinned independently in `tasks/main.yml`,
can lag `cloud` if needed. so the LibreSign instances can lag `cloud` if needed. The branding apps
(`files/skudakmail`, `files/debyltechmail`) pin `max-version="34"` too and
must be bumped alongside.
Dependency split, which drives what survives a container recreate: Dependency split, which drives what survives a container recreate:
@@ -148,6 +154,46 @@ LibreSign setup failures and buys nothing at this scale.
(LibreSign issue #4872). (LibreSign issue #4872).
## cloud.debyltech.com accounts
Registration is off, so every account is created by an admin in
Settings → Accounts. The isolation policy in
`tasks/containers/debyltech/cloud.yml` is enforced on every deploy and checked
by the verify script.
**Staff**: add to the `admin` group (the only group allowed to share) and to
`cloud_debyltech_staff_users` in `defaults/main.yml`, so the next deploy
exempts them from the 0 B default quota. Until then, set the account's quota
to *Unlimited* by hand.
**Customer**:
1. Create a group per customer, e.g. `Customer - Acme`. Use a consistent
prefix: autocomplete is off, so you share by typing the **exact** group
name.
2. Create the account with only their email filled in and no password, in
that group. Nextcloud sends a branded welcome email with a set-password
link.
3. Share a folder (for example `Customers/Acme`) with the group. It defaults
to **View only**; choose *Allow editing* only if they should upload.
What a customer gets:
- read-only access to what's shared with them
- no personal storage (0 B quota)
- no sharing or public links
- no view of other accounts or groups: the share search and contacts menu
return nothing
What they can open: Files, Activity, and LibreSign for signing only.
Dashboard and Office are staff-only. Promotional or directory-style apps
(first-run wizard, recommendations, weather, Photos, contacts interaction,
lookup server, ...) are disabled for everyone. Only staff can *request*
signatures.
Signature requests to customers need no account: use LibreSign with their
email address. LibreSign stays enabled for all accounts on purpose.
Restricting an app to a group also blocks visitors who aren't logged in,
which would break the public signing links.
## Logging ## Logging
Every container runs with `log_driver=journald`, so container stdout lands in Every container runs with `log_driver=journald`, so container stdout lands in
+33
View File
@@ -5,6 +5,7 @@ bookstack_path: "{{ podman_volumes }}/bookstack"
cam2ip_path: "{{ podman_volumes }}/cam2ip" cam2ip_path: "{{ podman_volumes }}/cam2ip"
cloud_path: "{{ podman_volumes }}/cloud" cloud_path: "{{ podman_volumes }}/cloud"
cloud_skudak_path: "{{ podman_volumes }}/skudakcloud" cloud_skudak_path: "{{ podman_volumes }}/skudakcloud"
cloud_debyltech_path: "{{ podman_volumes }}/debyltechcloud"
debyltech_path: "{{ podman_volumes }}/debyltech" debyltech_path: "{{ podman_volumes }}/debyltech"
# drone_path: removed - Drone CI decommissioned # drone_path: removed - Drone CI decommissioned
factorio_path: "{{ podman_volumes }}/factorio" factorio_path: "{{ podman_volumes }}/factorio"
@@ -218,6 +219,37 @@ libresign_skudak_cert_c: US
libresign_skudak_cert_st: New Hampshire libresign_skudak_cert_st: New Hampshire
libresign_skudak_cert_l: Newbury libresign_skudak_cert_l: Newbury
# de Byl Technologies Nextcloud (containers/debyltech/cloud.yml). DNS is a
# Route53 ALIAS to fulfillr.debyltech.com, managed in ~/src/debyltech/terraform
# (aws/cloud.tf), so no awsddns container of its own.
cloud_debyltech_server_name: cloud.debyltech.com
# debyltech-com $primary-color (copper). Drives the web UI theming; the mail
# CTA carries the same value as a constant in files/debyltechmail.
theming_debyltech_primary: "#bc804d"
# Login/header background. Dark site ink rather than copper so the white
# wordmark and mark shipped in files/debyltechmail/img stay legible on it.
theming_debyltech_background: "#0a1a2b"
# LibreSign root CA identity: the issuer on every signed document. Same caveat
# as the Skudak block above -- changing these does not re-issue the CA.
libresign_debyltech_cert_cn: de Byl Technologies LLC
libresign_debyltech_cert_o: de Byl Technologies LLC
libresign_debyltech_cert_c: US
libresign_debyltech_cert_st: New Hampshire
libresign_debyltech_cert_l: Newbury
# Outbound mail via AWS SES SMTP as noreply@debyltech.com. The IAM user is
# NextcloudSMTP in the terraform repo; its SMTP username/password are
# cloud_debyltech_smtp_user / cloud_debyltech_smtp_pass in the vault.
cloud_debyltech_smtp_host: email-smtp.us-east-1.amazonaws.com
cloud_debyltech_smtp_port: 465
# Staff vs customers (see "customer isolation" in containers/debyltech/cloud.yml).
# Only this group may share; everyone else -- customers -- can only read what
# is shared with them. Staff accounts are exempted from the 0 B default quota.
# Accounts listed here that do not exist yet are skipped, not created.
cloud_debyltech_staff_group: admin
cloud_debyltech_staff_users:
- admin
- bastian@debyltech.com
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security # Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
@@ -284,6 +316,7 @@ caddy_log_names:
- graylog - graylog
- cloud - cloud
- cloud-skudak - cloud-skudak
- cloud-debyltech
- gitea-debyl - gitea-debyl
- gitea-skudak - gitea-skudak
- fulfillr - fulfillr
@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>debyltechmail</id>
<name>de Byl Tech Customisations</name>
<summary>de Byl Technologies-branded email templates and UI overrides for Nextcloud and LibreSign</summary>
<description><![CDATA[
Restyles outgoing Nextcloud and LibreSign mail to match the de Byl
Technologies brand (~/src/debyltech/debyltech-com). Cloned from the Skudak
instance's skudakmail app. Two supported extension points, no core
patch and no LibreSign fork:
1. `OCA\Debyltechmail\Mail\DebyltechEMailTemplate` extends Nextcloud's EMailTemplate
and is wired in via the `mail_template_class` system config value, which
Nextcloud checks in `lib/private/Mail/Mailer.php::createEMailTemplate()`.
It owns layout, typography, subject rewriting, button labels and the
footer LibreSign never adds.
2. `OCA\Debyltechmail\Listener\DebyltechMailListener` listens on
`OCP\Mail\Events\BeforeMessageSent` to embed the wordmark as an inline
(cid:) MIME part, so the logo survives the remote-image blocking that
Apple Mail, Gmail and Outlook apply by default. This cannot be done from
the template class, which has no reference to the message.
3. `OCA\Debyltechmail\Listener\DebyltechStyleListener` listens on
`OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent` and adds
css/libresign-mobile.css, which fixes the LibreSign public signing page
being clipped at the bottom on iOS Safari. Serving it from here rather
than patching LibreSign keeps the app's integrity signature intact and
survives app updates, which wipe the app directory.
The app has no routes, no UI, no settings and no database tables. The id
`debyltechmail` is referenced by the `mail_template_class` system config;
its scope is instance-wide customisation, not mail alone.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>de Byl Technologies LLC</author>
<namespace>Debyltechmail</namespace>
<category>customization</category>
<dependencies>
<nextcloud min-version="34" max-version="34"/>
</dependencies>
</info>
@@ -0,0 +1,40 @@
/*
* Mobile fix for the LibreSign public signing page.
*
* PROBLEM: src/ExternalApp.vue sets `height: 100vh` on `html body #content`
* and again on `#app-sidebar` under `@media (max-width: 512px)`. iOS Safari
* resolves 100vh against the LARGE viewport -- as though the browser chrome
* were hidden -- so the element extends behind the bottom toolbar and the
* signing action bar is clipped off-screen. The built `external` chunk uses
* 100vh seven times and dvh/svh/safe-area zero times.
*
* WHY NOT safe-area-inset: the page's viewport meta is
* `width=device-width, initial-scale=1.0, minimum-scale=1.0` with no
* `viewport-fit=cover`, so env(safe-area-inset-bottom) resolves to 0 here.
*
* WHY dvh: the dynamic viewport unit tracks the chrome as it shows and hides,
* which is exactly the behaviour wanted. Browsers without dvh support drop the
* declaration entirely and keep LibreSign's own 100vh -- so this degrades to
* today's behaviour rather than to something broken. No @supports needed.
*
* SCOPING IS LOad-BEARING. `#content` and `#app-sidebar` are Nextcloud-wide
* IDs used throughout the authenticated UI. Every rule below is scoped to
* `#body-public` + `.app-public`, which the public signing page sets:
* <body id="body-public" class="layout-base">
* <div id="content" class="app-public" role="main">
* Widening these selectors would restyle the whole instance.
*
* UPSTREAM: patched at source in src/ExternalApp.vue (lines 34 and 46) and
* submitted to LibreSign. Once that lands and this instance runs a release
* containing it, this file can be deleted.
*/
#body-public #content.app-public {
height: 100dvh;
}
@media (max-width: 512px) {
#body-public #app-sidebar {
height: 100dvh;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace OCA\Debyltechmail\AppInfo;
use OCA\Debyltechmail\Listener\DebyltechMailListener;
use OCA\Debyltechmail\Listener\DebyltechStyleListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Mail\Events\BeforeMessageSent;
class Application extends App implements IBootstrap {
public const APP_ID = 'debyltechmail';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// BeforeMessageSent fires in Mailer::send() (lib/private/Mail/Mailer.php:186),
// AFTER useTemplate() has flattened the template into subject/plain/html on
// the message, and BEFORE setRecipients() and the Symfony transport. That
// window is the only place an inline (cid:) logo can be attached -- see the
// listener for why the template class alone cannot do it.
$context->registerEventListener(BeforeMessageSent::class, DebyltechMailListener::class);
// BeforeTemplateRenderedEvent is dispatched from
// lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php:35 and
// lib/private/Template/TemplateManager.php:82 -- the latter covers public
// (unauthenticated) pages, which is the case that matters here since the
// LibreSign signing page is a #[PublicPage].
$context->registerEventListener(BeforeTemplateRenderedEvent::class, DebyltechStyleListener::class);
}
public function boot(IBootContext $context): void {
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Embeds the de Byl Technologies wordmark as an inline (cid:) MIME part.
*
* WHY A LISTENER AND NOT THE TEMPLATE CLASS: Apple Mail, Gmail and Outlook all
* block remote images by default, and Apple Mail draws its own placeholder box
* rather than styled alt text -- so no amount of styling in the HTML rescues a
* remote <img>. The fix is a cid: reference backed by an inline MIME part, and
* that part must be attached to the MESSAGE. An IEMailTemplate subclass has no
* reference to the message, so it physically cannot do this; the template emits
* the <img>, this listener supplies the bytes and rewrites the src.
*
* BeforeMessageSent is the sanctioned hook -- "Emitted before a system mail is
* sent. It can be used to alter the message." (lib/public/Mail/Events/
* BeforeMessageSent.php). It fires at lib/private/Mail/Mailer.php:186, after
* useTemplate() has already rendered subject/plain/html onto the message and
* before setRecipients() and the transport, so a body rewrite here takes
* effect. No core patch, no LibreSign fork.
*
* FAILURE POSTURE: every step is defensive. If the asset is missing, the body
* is not ours, or anything throws, the listener leaves the message untouched
* and mail still goes out with a remote <img> -- degraded, never blocked. Mail
* that carries signature requests must not fail to send because branding
* broke.
*/
namespace OCA\Debyltechmail\Listener;
use OC\Mail\Message;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Mail\Events\BeforeMessageSent;
use Psr\Log\LoggerInterface;
/** @template-implements IEventListener<BeforeMessageSent> */
class DebyltechMailListener implements IEventListener {
/** Must match DebyltechEMailTemplate::LOGO_PATH. */
private const LOGO_PATH_FRAGMENT = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
/** Content-ID. Symfony emits this as <debyltech-wordmark.png>. */
private const CID = 'debyltech-wordmark.png';
public function __construct(
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if (!$event instanceof BeforeMessageSent) {
return;
}
try {
$this->embedWordmark($event->getMessage());
} catch (\Throwable $e) {
// Never let branding break delivery of a signature request.
$this->logger->warning('debyltechmail: inline logo embed skipped', [
'exception' => $e,
]);
}
}
private function embedWordmark(\OCP\Mail\IMessage $message): void {
// Mailer::send() guards `instanceof Message` before dispatching this
// event, so the concrete type is guaranteed -- but getSymfonyEmail()
// is not on the interface, so narrow explicitly rather than assume.
if (!$message instanceof Message) {
return;
}
$email = $message->getSymfonyEmail();
$html = $email->getHtmlBody();
if (!is_string($html) || $html === '') {
return;
}
// Only touch mail that actually renders our wordmark. Anything else --
// password resets, share notifications, other apps -- passes through.
if (!str_contains($html, self::LOGO_PATH_FRAGMENT)) {
return;
}
$asset = $this->assetPath();
if ($asset === null) {
return;
}
$bytes = @file_get_contents($asset);
if ($bytes === false || $bytes === '') {
return;
}
// Rewrite the absolute URL to a cid: reference. Matched on the path
// fragment with an optional query string so a cachebuster or a change
// of host still resolves.
$rewritten = preg_replace(
'#https?://[^"\']*' . preg_quote(self::LOGO_PATH_FRAGMENT, '#') . '(\?[^"\']*)?#',
'cid:' . self::CID,
$html,
);
if (!is_string($rewritten) || $rewritten === $html) {
return;
}
$email->embed($bytes, self::CID, 'image/png');
$message->setHtmlBody($rewritten);
}
/**
* Resolves img/debyltech-wordmark.png relative to this file, so the app works
* from whatever apps directory Nextcloud has it in.
*/
private function assetPath(): ?string {
$path = dirname(__DIR__, 2) . '/img/debyltech-wordmark.png';
return is_readable($path) ? $path : null;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* Injects de Byl Tech's CSS overrides into rendered Nextcloud pages.
*
* Currently one override: the LibreSign public signing page clips its bottom
* action bar on iOS Safari, because ExternalApp.vue sizes #content to 100vh and
* Safari resolves that against the large viewport (chrome hidden). See
* css/libresign-mobile.css for the full reasoning.
*
* WHY A LISTENER RATHER THAN PATCHING LIBRESIGN: an app-store app carries
* appinfo/signature.json, so editing a single byte of it raises INVALID_HASH in
* the admin security check, and an app update wipes the directory outright
* (Installer::downloadApp() calls Files::rmdirr on it). A stylesheet served
* from our own app survives both, and survives Nextcloud upgrades.
*
* The stylesheet itself is tightly scoped to #body-public / .app-public. This
* listener is deliberately NOT scoped further -- adding a stylesheet is
* idempotent and cheap, and gating on which app is rendering would couple this
* to LibreSign's route structure for no benefit. The CSS decides where it
* applies; this only decides that it is available.
*/
namespace OCA\Debyltechmail\Listener;
use OCA\Debyltechmail\AppInfo\Application;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class DebyltechStyleListener implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof BeforeTemplateRenderedEvent) {
return;
}
// Never let a styling concern break page rendering. A signing page that
// loads unstyled is recoverable; one that 500s is not.
try {
Util::addStyle(Application::APP_ID, 'libresign-mobile');
} catch (\Throwable $e) {
// Intentionally swallowed -- no logger dependency is worth adding
// for a stylesheet, and a failure here has no user-visible effect
// beyond the override not applying.
}
}
}
@@ -0,0 +1,436 @@
<?php
declare(strict_types=1);
/**
* de Byl Technologies-branded email template. Cloned from the Skudak
* instance's skudakmail app (roles/podman/files/skudakmail); keep fixes to
* the shared mechanics in sync between the two.
*
* Wired in via the `mail_template_class` system config value, which Nextcloud
* checks in lib/private/Mail/Mailer.php::createEMailTemplate(). That is a
* supported extension point -- core is not patched, so Nextcloud upgrades do
* not clobber this.
*
* WHY THIS EXISTS AT ALL: LibreSign's outgoing mail is generic open-source
* boilerplate -- subject "LibreSign: There is a file for you to sign", heading
* "File to sign", button "Sign »filename«", and NO footer whatsoever (it never
* calls addFooter(); verified: zero hits for addFooter in custom_apps/libresign).
* That mail carries customer agreements to signers, so it needs to read as an
* official de Byl Technologies LLC communication.
*
* DESIGN INTENT (palette from ~/src/debyltech/debyltech-com, theme
* assets/scss/_variables.scss):
* - Light ground, near-black text, NO coloured header band, and a pure
* black-and-white wordmark. Transactional mail from Stripe/Linear/DocuSign
* is likewise restrained, and a band leaves an ugly empty slab when the
* logo is blocked (see LOGO note).
* - $primary-color copper #bc804d on the CTA button only -- the one place
* this template spends colour.
* - Open Sans, matching the site's $primary-font, with the stock stack as
* fallback.
*
* LOGO: served from this app's own img/ directory rather than the theming app.
* Two reasons. (1) The theming logo is white-on-transparent because the web UI
* and login page are dark; a white mark is invisible on this template's white
* ground. (2) Decoupling means restyling mail can never disturb the web UI.
* /custom_apps/<app>/img/<file> is served publicly without auth (verified).
*
* Note that remote images are blocked by default in Apple Mail, Gmail and
* Outlook, and Apple Mail renders its own placeholder box rather than styled
* alt text -- so alt styling cannot rescue it. Surviving that requires a CID
* inline part via IMessage::attachInline(), which lives on the MESSAGE and is
* unreachable from a template subclass. Mitigated instead by dropping the
* band: a blocked logo now leaves plain white space, not a black slab.
*
* IMPLEMENTATION NOTE: font restyling is done by string-substitution against
* the PARENT's own markup rather than by redefining it. Those properties are
* large inline-CSS blobs with positional sprintf placeholders; copying them
* wholesale would mean re-auditing every placeholder on every upgrade, and a
* mismatch renders broken mail. Substitution degrades safely -- if upstream
* changes markup the replacements no-op and mail still sends, just unstyled.
* The header IS replaced wholesale, deliberately, because "no band" cannot be
* expressed as a substitution; its placeholder order is documented at its
* definition and must be kept in sync with upstream.
*/
namespace OCA\Debyltechmail\Mail;
use OC\Mail\EMailTemplate;
class DebyltechEMailTemplate extends EMailTemplate {
/** Stock Nextcloud font stack, replaced wholesale. Must match exactly. */
private const STOCK_FONTS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
/** $primary-font, with the stock stack retained as fallback. */
private const BRAND_FONTS = "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
private const ACCENT = '#BC804D'; // $primary-color (copper)
private const ON_ACCENT = '#FFFFFF';
private const INK = '#0A0A0A';
private const MUTED = '#525252';
private const FAINT = '#A3A3A3';
private const RULE = '#E5E5E5';
private const ENTITY = 'de Byl Technologies LLC';
private const SITE = 'https://debyltech.com';
private const LOGO_PATH = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
/** Displayed width in px. The asset is 600px wide for retina. */
private const LOGO_DISPLAY_WIDTH = 190;
/**
* LibreSign's l10n wraps document names in German guillemets -- "Sign
* »contract«" -- regardless of locale. Mapped to US curly quotes, matching
* the ``...'' convention in the LaTeX document templates.
*/
private const QUOTE_MAP = ['»' => "\u{201C}", '«' => "\u{201D}"];
/**
* LibreSign subject -> de Byl Tech subject. Keys are the exact English msgids
* from custom_apps/libresign/lib/Service/MailService.php (lines 51, 87,
* 121, 150, 172). Anything unmatched passes through untouched, so an
* upstream string change degrades to the original subject rather than a
* blank one.
*/
private const SUBJECT_MAP = [
'LibreSign: There is a file for you to sign' => 'Document for your signature',
'LibreSign: Changes into a file for you to sign' => 'Updated document for your signature',
'LibreSign: A file has been signed' => 'A document has been signed',
'LibreSign: A signature request has been canceled' => 'Signature request cancelled',
'LibreSign: Code to sign file' => 'Your signing verification code',
];
/**
* LibreSign heading -> de Byl Tech heading. Exact English msgids from
* MailService.php lines 53/89, 123, 152.
*/
private const HEADING_MAP = [
'File to sign' => 'Review and sign',
'File signed' => 'Document signed',
'Signature request canceled' => 'Signature request cancelled',
];
/**
* LibreSign body copy -> de Byl Tech body copy (MailService.php lines 60, 96,
* 174). Only the strings with NO %s interpolation are mapped; the two that
* carry a name or filename (lines 125, 154) arrive already substituted and
* so cannot be matched exactly -- they pass through unchanged.
*/
private const BODY_MAP = [
'There is a document for you to sign. Access the link below:'
=> 'de Byl Technologies LLC has sent you a document that requires your signature. Review it and sign using the link below.',
'Changes have been made in a file that you have to sign. Access the link below:'
=> 'A document awaiting your signature has been updated by de Byl Technologies LLC. Review the current version and sign using the link below.',
'Use this code to sign the document:'
=> 'Use this verification code to complete your signature:',
];
/**
* Template properties carrying the font stack. Listed explicitly rather
* than discovered reflectively so an upstream rename fails loudly in
* testing instead of silently skipping a block.
*/
private const STYLED_PARTS = [
'head', 'tail', 'heading', 'bodyBegin', 'bodyText',
'listBegin', 'listItem', 'listEnd', 'buttonGroup', 'button',
'bodyEnd', 'footer',
];
/**
* Own flag, deliberately NOT the parent's $footerAdded.
*
* Message::useTemplate() (lib/private/Mail/Message.php:289-296) calls
* renderText() at :291 BEFORE renderHtml() at :293, and renderText() sets
* $footerAdded = true. Guarding footer injection on !$footerAdded therefore
* never fires on the real send path -- the footer silently vanished from
* every mail while a renderHtml()-only test passed. Both renderers below
* call inject() and this flag makes the second call inert.
*/
private bool $brandFooterInjected = false;
public function __construct(
\OCP\Defaults $themingDefaults,
\OCP\IURLGenerator $urlGenerator,
\OCP\L10N\IFactory $l10nFactory,
?int $logoWidth,
?int $logoHeight,
string $emailId,
array $data,
) {
$this->applyBrandStyling();
// Must run AFTER the substitutions: the parent constructor copies
// $this->head into $htmlBody as its first act, so restyling head
// afterwards would leave the already-emitted copy untouched.
parent::__construct(
$themingDefaults,
$urlGenerator,
$l10nFactory,
$logoWidth,
$logoHeight,
$emailId,
$data,
);
}
private function applyBrandStyling(): void {
foreach (self::STYLED_PARTS as $part) {
if (!property_exists($this, $part)) {
continue;
}
$this->$part = str_replace(self::STOCK_FONTS, self::BRAND_FONTS, $this->$part);
}
// Light, tightly tracked headings, carried over from the Skudak template.
$this->heading = str_replace(
'font-size:24px;font-weight:400',
'font-size:26px;font-weight:300;letter-spacing:-0.02em',
$this->heading,
);
// Opt out of mail-client dark mode. Without this Apple Mail repaints
// the white ground charcoal on its own, and the black wordmark all but
// disappears. Swapping to a white logo under prefers-color-scheme is
// NOT a fix: with Apple Mail's "Use dark backgrounds for messages" off,
// the query still matches while the ground stays white, leaving a
// white-on-white logo. This mail is designed light; render it light.
$this->head = str_replace(
'</head>',
'<meta name="color-scheme" content="light only">'
. '<meta name="supported-color-schemes" content="light only">'
. '<style type="text/css">:root{color-scheme:light only;supported-color-schemes:light only}</style>'
. '</head>',
$this->head,
);
}
/**
* Rewrites LibreSign's subjects. Called by LibreSign on the TEMPLATE
* (MailService.php:51 etc.), not on the message, which is what makes this
* interceptable at all -- Message::useTemplate() later pulls the result via
* renderSubject(). Prefixed with the entity so the sender is unambiguous in
* an inbox list.
*/
public function setSubject(string $subject): void {
$mapped = self::SUBJECT_MAP[$subject] ?? null;
parent::setSubject(
$mapped === null ? $subject : self::ENTITY . ' — ' . $mapped,
);
}
/**
* Replaces the stock header wholesale: no coloured band, wordmark centred
* on white.
*
* Does NOT use the parent's $header property or its placeholder order --
* this is independent markup, so upstream changes to $header cannot break
* it (and equally cannot improve it). $logoWidth/$logoHeight from the
* Mailer are ignored on purpose: they are clamped to MAX_LOGO_SIZE = 105
* (lib/private/Mail/Mailer.php:60), which is too small for a wordmark to
* be legible.
*/
public function addHeader(): void {
if ($this->headerAdded) {
return;
}
$this->headerAdded = true;
$logoUrl = $this->urlGenerator->getAbsoluteURL(self::LOGO_PATH);
$alt = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$w = self::LOGO_DISPLAY_WIDTH;
$fonts = self::BRAND_FONTS;
$ink = self::INK;
$this->htmlBody .= <<<HTML
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:40px 30px 28px 30px;text-align:center;vertical-align:top">
<img src="{$logoUrl}" alt="{$alt}" width="{$w}" style="-ms-interpolation-mode:bicubic;border:0;clear:both;display:block;margin:0 auto;outline:0;text-decoration:none;width:{$w}px;max-width:{$w}px;height:auto;color:{$ink};font-family:{$fonts};font-size:22px;font-weight:300;letter-spacing:-0.02em"/>
</td>
</tr></tbody>
</table>
HTML;
}
/**
* Both renderers inject the footer -- see $brandFooterInjected.
*
* Mirrors the parent's own guard structure (renderHtml at
* lib/private/Mail/EMailTemplate.php:643, renderText at :656): close the
* body, append $tail, flip $footerAdded. The brand block goes in before
* $tail.
*/
public function renderHtml(): string {
$this->injectBrandFooter();
return parent::renderHtml();
}
public function renderText(): string {
$this->injectBrandFooter();
return parent::renderText();
}
private function injectBrandFooter(): void {
if ($this->brandFooterInjected || $this->footerAdded) {
return;
}
$this->brandFooterInjected = true;
// Close the body ourselves so the footer lands INSIDE the layout
// rather than after it. The parent's render methods are then a no-op
// for body closing and only append $tail.
$this->ensureBodyIsClosed();
$this->htmlBody .= $this->brandFooterHtml();
$this->plainBody .= $this->brandFooterText();
}
private function brandFooterHtml(): string {
$year = date('Y');
$entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$fonts = self::BRAND_FONTS;
$site = self::SITE;
[$muted, $faint, $rule, $ink] = [self::MUTED, self::FAINT, self::RULE, self::INK];
// Table-based and fully inline-styled: <style> blocks, flex and grid
// are stripped or unsupported across Outlook and most webmail.
return <<<HTML
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:0 30px 44px 30px;text-align:center;vertical-align:top">
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:center;width:100%;max-width:550px">
<tbody>
<tr><td style="border-collapse:collapse!important;border-top:1px solid {$rule};font-size:0;line-height:0;height:1px;margin:0;padding:0">&#xA0;</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:22px 0 0 0;text-align:center">
This is an official document-signing request from <strong style="color:{$ink};font-weight:600">{$entity}</strong>.<br/>
Nothing is signed unless you open the document and complete it yourself. If you were not expecting this, you can safely ignore it.
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
<a href="{$site}/legal/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
&#160;&#183;&#160;
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<a href="{$site}" style="color:{$muted};text-decoration:underline">debyltech.com</a>
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$faint};font-family:{$fonts};font-size:12px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
&copy; {$year} {$entity}. All rights reserved.<br/>
Automated message &mdash; please do not reply to this address.
</td></tr>
</tbody>
</table>
</td>
</tr></tbody>
</table>
HTML;
}
private function brandFooterText(): string {
$year = date('Y');
$entity = self::ENTITY;
$site = self::SITE;
return <<<TEXT
--
This is an official document-signing request from {$entity}.
Nothing is signed unless you open the document and complete it yourself.
If you were not expecting this, you can safely ignore it.
Privacy Policy: {$site}/legal/privacy
Terms of Use: {$site}/legal/tos
© {$year} {$entity}. All rights reserved.
Automated message — please do not reply to this address.
TEXT;
}
private function tidyQuotes(string $text): string {
return strtr($text, self::QUOTE_MAP);
}
// Signatures below mirror the parent EXACTLY. $plainTitle/$plainText are
// deliberately untyped there (they accept string|bool -- false suppresses
// the plain-text variant), and narrowing a parameter type in an override
// is a fatal error in PHP.
public function addHeading(string $title, $plainTitle = ''): void {
$mapped = self::HEADING_MAP[$title] ?? $this->tidyQuotes($title);
parent::addHeading(
$mapped,
is_string($plainTitle) && $plainTitle !== ''
? (self::HEADING_MAP[$plainTitle] ?? $this->tidyQuotes($plainTitle))
: $plainTitle,
);
}
public function addBodyText(string $text, $plainText = ''): void {
$mapped = self::BODY_MAP[$text] ?? $this->tidyQuotes($text);
parent::addBodyText(
$mapped,
is_string($plainText) && $plainText !== ''
? (self::BODY_MAP[$plainText] ?? $this->tidyQuotes($plainText))
: $plainText,
);
}
/**
* Reimplemented for two reasons: the accent colour, and a fixed label.
*
* LibreSign builds "Sign »%s«" with the raw filename
* (MailService.php:64,100). Real documents here are named things like
* acme-master-services-agreement-rev3, which makes an ungainly button and
* leaks the document name to anyone who sees the inbox preview. Replaced
* with a fixed call to action; the document is identified on the landing
* page behind the link.
*
* Mirrors the parent's vsprintf argument order exactly:
* [$color, $color, $url, $color, $textColor, $textColor, $text].
* Kept in sync with parent::addBodyButton() -- if that changes upstream,
* this needs revisiting.
*/
public function addBodyButton(string $text, string $url, $plainText = ''): void {
if ($this->footerAdded) {
return;
}
$this->ensureBodyIsOpened();
$this->ensureBodyListClosed();
$label = $this->buttonLabelFor($text);
if ($plainText === '') {
$plainText = $label;
} elseif (is_string($plainText)) {
$plainText = $this->tidyQuotes($plainText);
}
$this->htmlBody .= vsprintf($this->button, [
self::ACCENT,
self::ACCENT,
$url,
self::ACCENT,
self::ON_ACCENT,
self::ON_ACCENT,
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
]);
if ($plainText !== false) {
$this->plainBody .= $plainText . ': ';
}
$this->plainBody .= $url . PHP_EOL;
}
/**
* Maps LibreSign's filename-bearing labels onto fixed calls to action.
* Matched on the stable leading verb rather than the whole string, since
* the tail is a filename. Unknown labels pass through with quotes tidied.
*/
private function buttonLabelFor(string $text): string {
if (str_starts_with($text, 'Sign ')) {
return 'Review document';
}
if (str_starts_with($text, 'View signed file')) {
return 'View signed document';
}
return $this->tidyQuotes($text);
}
}
+25 -175
View File
@@ -1,194 +1,28 @@
- id: '1649042328061'
alias: Lights - 01 - On
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single
- id: '1707432903086' - id: '1707432903086'
alias: Driveway String Lights Off alias: Driveway String Lights Off
description: '' description: ''
trigger: triggers:
- platform: time - trigger: time
at: '23:00:00' at: '23:00:00'
condition: []
action:
- type: turn_off
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
mode: single
- id: '1707433130493'
alias: Lights - 02 - Early Dim
description: ''
triggers: []
conditions: [] conditions: []
actions: actions:
- type: turn_on - action: switch.turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: '1707433185560'
alias: Lights - 03 - Mid Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single
- id: '1707433226166'
alias: Lights - 04 - Late Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- action: light.turn_on
metadata: {}
data:
brightness_pct: 1
target: target:
area_id: bedroom entity_id: switch.driveway_string_lights
enabled: false
mode: single
- id: '1711218890065'
alias: Lights - 10 - Off
description: ''
triggers: []
conditions: []
actions:
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
- type: turn_off
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
- type: turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
- type: turn_off
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
entity_id: a30b2da3cd80a5b4c927e1608b91eb65
domain: light
- type: turn_off
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
mode: single mode: single
- id: '1739912161794' - id: '1739912161794'
alias: Lights - 00 - Morning alias: Lights - 00 - Morning
description: '' description: ''
triggers: triggers:
- trigger: time - trigger: time
at: 09:00:00 at: '09:00:00'
conditions: [] conditions: []
actions: actions:
- type: turn_on - action: light.turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd data:
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 100 brightness_pct: 100
target:
entity_id: light.bathroom_hallway
mode: single mode: single
- id: '1762116115638' - id: '1762116115638'
alias: Light - TV On alias: Light - TV On
@@ -255,6 +89,9 @@
- action: script.evening_lights_apply - action: script.evening_lights_apply
data: data:
apply: force apply: force
- action: switch.turn_on
target:
entity_id: switch.desk_lamp
- conditions: - conditions:
- condition: or - condition: or
conditions: conditions:
@@ -266,6 +103,9 @@
- action: light.turn_off - action: light.turn_off
target: target:
area_id: living_room area_id: living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single mode: single
- id: driveway_lights_on - id: driveway_lights_on
alias: Driveway String Lights On alias: Driveway String Lights On
@@ -321,6 +161,10 @@
brightness_pct: 5 brightness_pct: 5
target: target:
area_id: living_room area_id: living_room
else:
- action: switch.turn_on
target:
entity_id: switch.desk_lamp
mode: single mode: single
- id: evening_dim_ramp - id: evening_dim_ramp
alias: Lights - Evening Dim Ramp alias: Lights - Evening Dim Ramp
@@ -363,6 +207,9 @@
- action: light.turn_off - action: light.turn_off
target: target:
entity_id: light.living_room entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single mode: single
- id: lights_sweep_0100 - id: lights_sweep_0100
alias: Lights - Sweep (01:00) alias: Lights - Sweep (01:00)
@@ -392,6 +239,9 @@
- action: light.turn_off - action: light.turn_off
target: target:
entity_id: light.living_room entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single mode: single
- id: '1768862300896' - id: '1768862300896'
alias: Bedroom On alias: Bedroom On
@@ -0,0 +1,855 @@
---
# de Byl Technologies Nextcloud (cloud.debyltech.com).
#
# Cloned from containers/skudak/cloud.yml, which carries the full reasoning for
# nearly every task below -- read the matching comment there before changing
# one here. Comments in this file cover only where the two instances differ.
#
# Differences from Skudak, by design:
# - Fresh install: NEXTCLOUD_ADMIN_* makes the first deploy install
# unattended, and LibreSign is installed from the app store rather than
# assumed present.
# - No Group Folders. Registration stays off, matching Skudak's live state:
# every account, staff and customer alike, is created by the admin, with
# customers in per-customer groups.
# - Outbound mail is AWS SES SMTP (noreply@debyltech.com), set here via occ
# so it lives in git rather than only in the admin UI.
# - Backups go to personal iDrive e2 via TrueNAS -- see the backup include
# at the bottom.
- name: create required debyltech cloud volumes
become: true
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
mode: 0755
notify: restorecon podman
loop:
- "{{ cloud_debyltech_path }}/apps"
- "{{ cloud_debyltech_path }}/config"
- "{{ cloud_debyltech_path }}/data"
- "{{ cloud_debyltech_path }}/mysql"
- "{{ cloud_debyltech_path }}/scripts"
- "{{ cloud_debyltech_path }}/redis"
- name: unshare chown the debyltech cloud volumes
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: |
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps {{ cloud_debyltech_path }}/data {{ cloud_debyltech_path }}/config
- name: flush handlers
ansible.builtin.meta: flush_handlers
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud-db
container_image: "{{ db_image }}"
- name: create debyltech-cloud-db container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud-db
image: "{{ db_image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
env:
MYSQL_ROOT_PASSWORD: "{{ cloud_debyltech_db_root_pass }}"
MYSQL_DATABASE: dtcloud
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
MYSQL_USER: dtcloud
volumes:
- "{{ cloud_debyltech_path }}/mysql:/var/lib/mysql"
- name: create systemd startup job for debyltech-cloud-db
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud-db
# ---------------------------------------------------------------------------
# Redis: distributed cache + file locking. MUST exist before debyltech-cloud
# below -- see the Skudak equivalent for why.
- name: template debyltech cloud redis config
become: true
ansible.builtin.template:
src: nextcloud/redis-debyltech.conf.j2
dest: "{{ cloud_debyltech_path }}/redis/redis.conf"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
mode: 0640
notify: restorecon podman
no_log: true
- name: flush handlers
ansible.builtin.meta: flush_handlers
- name: unshare chown the debyltech redis config to the redis uid
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman unshare chown 999:1000 {{ cloud_debyltech_path }}/redis/redis.conf
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud-redis
container_image: "{{ redis_image }}"
- name: create debyltech-cloud-redis container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud-redis
image: "{{ redis_image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
volumes:
- "{{ cloud_debyltech_path }}/redis/redis.conf:/etc/redis/redis.conf:ro"
command: redis-server /etc/redis/redis.conf
- name: create systemd startup job for debyltech-cloud-redis
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud-redis
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud
container_image: "{{ image }}"
- name: create debyltech cloud container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
env:
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
MYSQL_DATABASE: dtcloud
MYSQL_HOST: debyltech-cloud-db
MYSQL_USER: dtcloud
# Read by the entrypoint ONLY on first start against an empty config
# volume, to run the install unattended; ignored on every start after.
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: "{{ cloud_debyltech_admin_pass }}"
NEXTCLOUD_TRUSTED_DOMAINS: "{{ cloud_debyltech_server_name }}"
PHP_MEMORY_LIMIT: 1024M
PHP_UPLOAD_LIMIT: 512M
LC_ALL: C.UTF-8
LANG: C.UTF-8
REDIS_HOST: debyltech-cloud-redis
REDIS_HOST_PORT: "6379"
REDIS_HOST_PASSWORD: "{{ cloud_debyltech_redis_pass }}"
volumes:
- "{{ cloud_debyltech_path }}/apps:/var/www/html/custom_apps"
- "{{ cloud_debyltech_path }}/data:/var/www/html/data"
- "{{ cloud_debyltech_path }}/config:/var/www/html/config"
ports:
- "8091:80"
- name: create systemd startup job for debyltech-cloud
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud
# ---------------------------------------------------------------------------
# LibreSign
- name: install libresign runtime dependencies in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command:
cmd: >
podman exec -u 0 debyltech-cloud
sh -c "apt-get update && apt-get install -y --no-install-recommends
poppler-utils ghostscript && rm -rf /var/lib/apt/lists/*"
register: libresign_deps
changed_when: "'is already the newest version' not in libresign_deps.stdout"
# On the FIRST deploy this also waits out the unattended install, which takes
# noticeably longer than a restart -- hence the larger budget than Skudak's.
- name: wait for nextcloud to be ready in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ status --output=json
register: debyltech_occ_ready
# String match, not from_json: before the install finishes occ can print a
# plain-text warning ahead of the JSON, and a parse error would abort the
# retry loop instead of waiting. Skudak's bare 'installed' check would also
# match "installed":false, which is exactly the state being waited out here.
until: >-
debyltech_occ_ready.rc == 0
and '"installed":true' in debyltech_occ_ready.stdout
retries: 60
delay: 5
changed_when: false
# LibreSign is PINNED (libresign_version / libresign_sha256 in tasks/main.yml)
# and installed from the upstream GitHub release, NOT `occ app:install`, which
# always takes whatever the app store has that day. On 2026-09-28 that was a
# same-day 14.2.3 whose tarball shipped without appinfo/install-*.json -- the
# maintainer-signed metadata LibreSign verifies its java/pdftk/jsignpdf
# downloads against -- so configure:check failed all three on a clean install.
#
# Upgrading: bump both pins together (the sha256 is on the GitHub release
# asset) and deploy; the tree is replaced and `occ upgrade` runs the app's
# migrations. Downgrading is refused below: Nextcloud does not support it, and
# the only way back is removing the app, which discards its config and CA.
- name: read installed libresign version in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:get libresign installed_version
register: libresign_installed
changed_when: false
failed_when: false
- name: refuse to downgrade libresign in debyltech-cloud
ansible.builtin.fail:
msg: >-
LibreSign {{ libresign_installed.stdout }} is installed but the pin is
{{ libresign_version }}. Nextcloud cannot downgrade an app in place --
raise the pin, or remove the app deliberately if nothing has been signed.
when:
- libresign_installed.rc == 0
- libresign_installed.stdout is version(libresign_version, '>')
- name: install pinned libresign release in debyltech-cloud
when: libresign_installed.rc != 0 or libresign_installed.stdout != libresign_version
block:
- name: fetch pinned libresign release
become: true
ansible.builtin.get_url:
url: "https://github.com/LibreSign/libresign/releases/download/v{{ libresign_version }}/libresign-v{{ libresign_version }}.tar.gz"
dest: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
checksum: "sha256:{{ libresign_sha256 }}"
mode: 0644
- name: remove previous libresign app tree
become: true
ansible.builtin.file:
path: "{{ cloud_debyltech_path }}/apps/libresign"
state: absent
- name: unpack pinned libresign release into custom_apps
become: true
ansible.builtin.unarchive:
src: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
dest: "{{ cloud_debyltech_path }}/apps/"
remote_src: true
# Unpacked as root the files keep the tarball's owners, which lie
# outside the podman user's subuid range, so the unshare chown below
# is refused. Same two-step as the debyltechmail copy.
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
notify: restorecon podman
- name: unshare chown the libresign app tree
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps/libresign
- name: flush handlers
ansible.builtin.meta: flush_handlers
# Only an in-place upgrade needs this; a first install is handled by the
# app:enable below.
- name: run libresign migrations in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ upgrade
when: libresign_installed.rc == 0
- name: ensure libresign app is enabled in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ app:enable libresign
register: libresign_enable
changed_when: "'already enabled' not in libresign_enable.stdout"
# 14.2.x's downloader does not create its own target directories: on a fresh
# appdata every java/pdftk download fails with "Directory ... does not exist
# for sink value". Creating them first is harmless once they exist.
- name: pre-create libresign binary directories in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud sh -c
'd=$(ls -d /var/www/html/data/appdata_*/libresign) &&
mkdir -p "$d/x86_64/linux/java" "$d/x86_64/pdftk"'
# "Finished with success" is printed even when every download failed, so this
# check only catches the command itself falling over. The real gate is the
# configure:check verify task below, which hashes each binary against the
# release's signed metadata.
- name: install libresign java/pdftk/jsignpdf binaries in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:install --java --pdftk --jsignpdf
register: libresign_install
changed_when: false
failed_when: "'Finished with success' not in libresign_install.stdout"
- name: check whether libresign root certificate is configured
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:configure:check --certificate
register: libresign_cert_check
changed_when: false
failed_when: false
# Guarded: re-running would mint a new root CA and orphan every certificate
# already issued. No --ou -- see skudak/cloud.yml.
- name: generate libresign root certificate for debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:configure:openssl
--cn="{{ libresign_debyltech_cert_cn }}"
-o "{{ libresign_debyltech_cert_o }}"
-c "{{ libresign_debyltech_cert_c }}"
-s "{{ libresign_debyltech_cert_st }}"
-l "{{ libresign_debyltech_cert_l }}"
when: "'error' in libresign_cert_check.stdout"
changed_when: true
# Signers are mostly customers WITHOUT an account, reached by emailed
# invitation; the ID-document gate would leave them unable to sign at all.
- name: relax libresign identification-document gate in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign identification_documents --value=0
register: libresign_ident
changed_when: "'is now set to' in libresign_ident.stdout"
# Must be exactly GRAPHIC_ONLY -- see skudak/cloud.yml.
- name: use signature-only stamp in debyltech-cloud libresign
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign signature_render_mode --value=GRAPHIC_ONLY
register: libresign_render
changed_when: "'is now set to' in libresign_render.stdout"
# Lets account-owned emails be added as signers. NEVER set the _email variant
# of this key to 'no' -- see skudak/cloud.yml.
- name: allow account-owned emails as libresign signers in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set core
shareapi_restrict_user_enumeration_full_match --value=no
register: debyltech_enum_fullmatch
changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout"
# Settings Skudak only ever had from clicks in the LibreSign admin page, never
# in git -- a fresh instance without them cannot invite anyone by address:
#
# identify_methods The EMAIL identification method. Without it the signer
# search only lists accounts, so an outside address
# returns a bare "No signers." -- the whole point of this
# instance. clickToSign (no emailed code) and
# can_create_account=false, as on Skudak: the emailed link
# is the identity check, and customers never get accounts.
# signature_background_type=deleted
# Drops the LibreSign logo watermark from behind the
# stamp, so with GRAPHIC_ONLY the stamp is the drawn mark
# and nothing else.
# collect_metadata Records signer IP/user agent alongside each signature.
- name: set libresign signer identification and stamp settings in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign {{ item.k }} --value={{ item.v | quote }}
register: debyltech_libresign_settings
changed_when: "'is now set to' in debyltech_libresign_settings.stdout"
loop:
- k: identify_methods
v: >-
{{ [{'name': 'email', 'friendly_name': 'Email', 'enabled': true,
'mandatory': true,
'signatureMethods': {
'clickToSign': {'name': 'clickToSign', 'enabled': true},
'emailToken': {'name': 'emailToken', 'enabled': false}},
'can_create_account': false,
'test_url': '/index.php/settings/admin/mailtest',
'signatureMethodEnabled': 'clickToSign'}] | to_json }}
- {k: signature_background_type, v: deleted}
- {k: collect_metadata, v: "1"}
loop_control:
label: "{{ item.k }}"
# The validation footer ("Digitally signed by ... Validate in <url>") is WANTED
# -- only its QR code goes, below. FooterHandler defaults add_footer to true
# when unset, but the 14.2 admin page renders unset as UNCHECKED, inviting
# someone to "correct" it into actually turning the footer off. Stored
# explicitly so the page shows what the code does.
- name: keep libresign validation footer text in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign add_footer --value=1 --type=boolean
register: libresign_footer
changed_when: "'is now set to' in libresign_footer.stdout"
- name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign write_qrcode_on_footer
--value=0 --type=boolean
register: libresign_qr
changed_when: "'is now set to' in libresign_qr.stdout"
- name: verify libresign configuration in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:configure:check
register: libresign_verify
changed_when: false
# Double backslashes: Jinja unescapes string literals, so a single '\b'
# becomes a BACKSPACE character and this could never match -- which is
# how a check reporting three errors passed clean on 2026-09-28.
failed_when: libresign_verify.stdout is search('\\berror\\b')
# ---------------------------------------------------------------------------
# Background jobs. A fresh install defaults to AJAX mode, which only runs jobs
# while someone has the web UI open -- LibreSign's queued signature mail and
# every cleanup job would stall. The cloud-cron timer included below drives
# cron.php; this tells Nextcloud to expect it.
- name: set debyltech-cloud background jobs to cron
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set core backgroundjobs_mode --value=cron
register: debyltech_bgjobs
changed_when: "'is now set to' in debyltech_bgjobs.stdout"
- name: disable nextcloud signup link in debyltech-cloud config
become: true
ansible.builtin.lineinfile:
path: "{{ cloud_debyltech_path }}/config/config.php"
regexp: "^\\s*'simpleSignUpLink\\.shown'\\s*=>"
line: " 'simpleSignUpLink.shown' => false,"
insertbefore: '^\);'
create: false
# ---------------------------------------------------------------------------
# Customer isolation. Customers are admin-created accounts in a per-customer
# group, and must only READ what staff share with them -- not upload, not
# share onward, and not discover that other customers exist. Verified
# 2026-09-28 against a test customer, both in the UI and with the sharee and
# contacts-menu search services run as that user.
#
# shareapi_exclude_groups=allow + list=[staff]
# Only staff may share. NOT "yes" (exclude mode): that only disables
# sharing for users whose groups are ALL excluded, so a customer in
# their own per-customer group would never be caught by it.
# shareapi_allow_share_dialog_user_enumeration=no
# No partial-match browsing of accounts or groups, for anyone. By
# default a customer typing "bas" found the owner's account. Staff
# share to a customer group by typing its exact name; LibreSign signers
# are found by email and are unaffected.
# shareapi_default_permissions=1
# New shares default to View only; tick "Allow editing" per share to
# let a customer upload.
# files default_quota=0 B
# No personal storage, so no "+ New" in a customer's own home. Uploads
# into a share granted editing count against the OWNER's quota and still
# work. Staff are exempted by the next task.
# dav enableDefaultContact=false
# No "Leon Green" sample contact in new address books.
- name: set debyltech-cloud customer isolation policy
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set {{ item.app }} {{ item.k }} --value={{ item.v | quote }}
{{ ('--type=' ~ item.t) if item.t is defined else '' }}
register: debyltech_isolation
changed_when: "'is now set to' in debyltech_isolation.stdout"
loop:
- {app: core, k: shareapi_exclude_groups, v: allow}
- {app: core, k: shareapi_exclude_groups_list, v: "{{ [cloud_debyltech_staff_group] | to_json }}"}
- {app: core, k: shareapi_allow_share_dialog_user_enumeration, v: "no"}
- {app: core, k: shareapi_default_permissions, v: "1"}
- {app: files, k: default_quota, v: "0 B"}
- {app: dav, k: enableDefaultContact, v: "0", t: boolean}
loop_control:
label: "{{ item.app }}.{{ item.k }}"
- name: exempt debyltech-cloud staff from the zero default quota
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
occ user:info {{ item | quote }} >/dev/null 2>&1 || exit 0
cur=$(occ user:setting {{ item | quote }} files quota) || cur='<unset>'
if [ "$cur" != none ]; then
occ user:setting {{ item | quote }} files quota none
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_staff_quota
changed_when: "'CHANGED' in debyltech_staff_quota.stdout"
loop: "{{ cloud_debyltech_staff_users }}"
# What a customer can reach. Nextcloud had nothing group-restricted, so every
# customer saw Dashboard, Photos, Office and the rest in the app menu.
#
# Disabled outright -- promos, prompts, or directory-ish features a business
# file-and-signing portal has no use for (contactsinteraction silently adds
# whoever shares with you to your address book; app_api only produces a
# setup warning here). lookup_server_connector cannot be disabled (occ refuses)
# -- the empty `lookup_server` system value below switches it off instead.
#
# Restricted to staff: dashboard and office. `defaultapp` below lists
# dashboard first so staff land there and customers fall through to Files.
#
# NOT restricted: libresign. A group restriction is enforced for anonymous
# requests too (AppManager::checkAppForUser returns false for no user), so it
# would break the public signing links sent to outside signers and to any
# customer already logged in. Who may AUTHOR requests is LibreSign's own
# groups_request_sign, pinned to staff below.
- name: disable unneeded apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if occ app:list --output=json | python3 -c 'import json,sys; sys.exit(0 if sys.argv[1] in json.load(sys.stdin)["enabled"] else 1)' {{ item | quote }}; then
occ app:disable {{ item | quote }}
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_disable
changed_when: "'CHANGED' in debyltech_app_disable.stdout"
# occ exits 0 even when it refuses ("can't be disabled").
failed_when: debyltech_app_disable.rc != 0 or "can't be disabled" in debyltech_app_disable.stdout
loop:
- firstrunwizard
- recommendations
- related_resources
- weather_status
- survey_client
- support
- app_api
- contactsinteraction
- photos
- name: restrict staff-only apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
want={{ [cloud_debyltech_staff_group] | to_json | quote }}
if [ "$(occ config:app:get {{ item | quote }} enabled || true)" != "$want" ]; then
occ app:enable --groups {{ cloud_debyltech_staff_group | quote }} {{ item | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_restrict
changed_when: "'CHANGED' in debyltech_app_restrict.stdout"
loop:
- dashboard
- office
- name: pin who may request libresign signatures in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign groups_request_sign
--value={{ [cloud_debyltech_staff_group] | to_json | quote }}
register: debyltech_request_sign
changed_when: "'is now set to' in debyltech_request_sign.stdout"
# ---------------------------------------------------------------------------
# Branding: debyltechmail, cloned from skudakmail. custom_apps is a persisted
# bind mount, so only enabling and config need reasserting.
# Owned directly by the HOST uid that rootless podman maps www-data (33) to --
# subuid start + 32, since container uid 1 is the first subuid. Skudak copies
# as the subuid and then `podman unshare chown`s, which flips ownership back
# and forth so the copy reports changed on every run; here that would also
# re-import the theming logos below every time.
- name: deploy debyltechmail email-template app to debyltech-cloud
become: true
ansible.builtin.copy:
src: debyltechmail/
dest: "{{ cloud_debyltech_path }}/apps/debyltechmail/"
owner: "{{ podman_subuid.stdout | int + 32 }}"
group: "{{ podman_subuid.stdout | int + 32 }}"
mode: 0644
directory_mode: 0755
register: debyltechmail_copy
notify: restorecon podman
- name: enable debyltechmail app in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ app:enable debyltechmail
register: debyltechmail_enable
changed_when: "'already enabled' not in debyltechmail_enable.stdout"
# Behind rootless podman's port forwarder, every request -- Caddy's included --
# reaches Apache FROM THE CONTAINER'S OWN ADDRESS on `shared`, not from the
# host. Unless exactly that address is a trusted proxy, Nextcloud ignores the
# X-Forwarded-For header Caddy sends, so every client looks like one IP:
# brute-force throttling then penalises everyone at once. Read per deploy
# because the address is assigned at container creation, and deploys are the
# only thing that recreate it.
- name: read debyltech-cloud container address
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman inspect debyltech-cloud
--format "{{ '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' }}"
register: debyltech_cloud_ip
changed_when: false
failed_when: debyltech_cloud_ip.stdout is not match('^[0-9.]+$')
# System config, set only when it differs so a clean re-deploy reports no
# changes (Skudak's equivalents report changed on every run). Values are
# single-quoted into the shell, so the backslashes in mail_template_class pass
# through literally. overwrite.cli.url is what LibreSign invitation links and
# mail asset URLs are built from when sent by a background job.
- name: set debyltech-cloud system config
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
# An UNSET key prints nothing and exits 1 -- indistinguishable from ""
# by output alone, which would skip setting an intentionally empty value.
cur=$(occ config:system:get {{ item.k }}) || cur='<unset>'
if [ "$cur" != {{ item.v | quote }} ]; then
occ config:system:set {{ item.k }} --value={{ item.v | quote }} --type={{ item.t | default('string') }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_sysconfig
changed_when: "'CHANGED' in debyltech_sysconfig.stdout"
loop:
- {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"}
- {k: overwriteprotocol, v: https}
- {k: trusted_proxies 0, v: "{{ debyltech_cloud_ip.stdout }}"}
# Hour in UTC: 05:00 UTC is 01:00/00:00 Eastern, ahead of the 04:15 backup.
- {k: maintenance_window_start, v: "5", t: integer}
- {k: default_phone_region, v: US}
# New accounts -- customers above all -- start with an empty home rather
# than Nextcloud's sample Manual/intro video/Readme and Templates folder.
- {k: skeletondirectory, v: ""}
- {k: templatedirectory, v: ""}
# First ENABLED app wins: staff get the dashboard, and customers -- who
# cannot open it (restricted below) -- fall through to Files.
- {k: defaultapp, v: "dashboard,files"}
# No per-account profile pages.
- {k: profile.enabled, v: "false", t: boolean}
# Never query or publish to the global lookup server (lookup.nextcloud.com).
- {k: lookup_server, v: ""}
- {k: loglevel, v: "2", t: integer}
- {k: log_rotate_size, v: "10485760", t: integer}
- {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"}
- {k: mail_smtpmode, v: smtp}
- {k: mail_smtphost, v: "{{ cloud_debyltech_smtp_host }}"}
- {k: mail_smtpport, v: "{{ cloud_debyltech_smtp_port }}", t: integer}
- {k: mail_smtpsecure, v: ssl}
- {k: mail_smtpauth, v: "true", t: boolean}
- {k: mail_from_address, v: noreply}
- {k: mail_domain, v: debyltech.com}
loop_control:
label: "{{ item.k }}"
- name: set debyltech-cloud SES SMTP credentials
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
cur=$(occ config:system:get {{ item.k }} || true)
if [ "$cur" != {{ item.v | quote }} ]; then
occ config:system:set {{ item.k }} --value={{ item.v | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_smtp_creds
changed_when: "'CHANGED' in debyltech_smtp_creds.stdout"
loop:
- {k: mail_smtpname, v: "{{ cloud_debyltech_smtp_user }}"}
- {k: mail_smtppassword, v: "{{ cloud_debyltech_smtp_pass }}"}
loop_control:
label: "{{ item.k }}"
no_log: true
# Compared first: theming:config prints "Updated" even when nothing changed.
- name: set debyltech-cloud theming
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
cur=$(occ config:app:get theming {{ item.k }} || true)
if [ "$cur" != {{ item.v | quote }} ]; then
occ theming:config {{ item.k }} {{ item.v | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
loop:
- {k: name, v: "de Byl Technologies"}
- {k: slogan, v: "Hardware, firmware and design services"}
- {k: url, v: "https://debyltech.com"}
- {k: primary_color, v: "{{ theming_debyltech_primary }}"}
- {k: background_color, v: "{{ theming_debyltech_background }}"}
register: debyltech_theming
changed_when: "'CHANGED' in debyltech_theming.stdout"
loop_control:
label: "{{ item.k }}"
# The web UI logos ship inside the debyltechmail app (the white variants; the
# ink wordmark is the mail one). theming:config re-imports the file on every
# call, so it runs only when the app's files changed or no logo is set yet.
# `logo` is the wide wordmark on the login page; `logoheader` is the square
# mark in the top bar, where a wordmark would shrink to illegibility.
# Plain theming_debyltech_background instead of Nextcloud's stock blue-shapes
# image. `background backgroundColor` is a special case in UpdateConfig.php
# (absent from --help) that drops the image and sets backgroundMime, exactly
# what the admin UI's "remove background image" does.
- name: use a plain colour login background in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if [ "$(occ config:app:get theming backgroundMime || true)" != backgroundColor ]; then
occ theming:config background backgroundColor >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_background
changed_when: "'CHANGED' in debyltech_background.stdout"
- name: check debyltech-cloud theming logos
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:get theming {{ item }}Mime
loop: [logo, logoheader]
register: debyltech_logo_mime
changed_when: false
failed_when: false
- name: set debyltech-cloud theming logos
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ theming:config {{ item.item }}
/var/www/html/custom_apps/debyltechmail/img/{{ logo_files[item.item] }}
loop: "{{ debyltech_logo_mime.results }}"
when: debyltechmail_copy is changed or item.rc != 0 or item.stdout == ''
vars:
logo_files:
logo: debyltech-wordmark-white.png
logoheader: debyltech-mark-white.png
loop_control:
label: "{{ item.item }}"
# Fails the play if branding, the LibreSign settings above, or Redis locking
# have silently regressed -- see skudak/cloud.yml.
- name: template debyltechmail verification script
become: true
ansible.builtin.template:
src: nextcloud/debyltechmail-verify.php.j2
dest: "{{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
mode: 0644
notify: restorecon podman
- name: verify debyltech mail branding is live
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: >
set -o pipefail;
podman exec -i -u www-data debyltech-cloud php
< {{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php
args:
executable: /bin/bash
register: debyltechmail_verify
changed_when: false
- include_tasks: containers/cloud-cron.yml
vars:
cron_name: debyltech-cloud
cron_container: debyltech-cloud
cron_script_path: /usr/local/bin/debyltech-cloud-cron.sh
# BUSINESS data that DELIBERATELY reaches personal storage -- the opposite of
# Skudak, and on purpose: de Byl Technologies LLC is the owner's own company.
#
# Chain: this rsync -> TrueNAS /mnt/glacier/debyltechcloud (05:00 ZFS
# snapshot) -> the personal "iDrive E2 Backup" cloud-sync task, which pushes
# /mnt/glacier to the personal iDrive e2 bucket. Unlike /skudakcloud/**,
# /skudakapps/** and /skudakgit/**, there is NO exclude for /debyltechcloud/**
# on that task, and there must not be one -- that inclusion IS the offsite
# copy. If that ever changes, give it its own cloud-sync task first.
- include_tasks: containers/cloud-backup.yml
vars:
backup_name: debyltech-cloud
data_path: "{{ cloud_debyltech_path }}/data"
config_path: "{{ cloud_debyltech_path }}/config"
db_container: debyltech-cloud-db
ssh_key_path: /etc/ssh/backup_keys/debyltech-cloud
ssh_key_content: "{{ cloud_debyltech_backup_ssh_key }}"
ssh_user: debyltechcloud
remote_path: /mnt/glacier/debyltechcloud
script_path: /usr/local/bin/debyltech-cloud-backup.sh
# data/ is mode 770 here too; see skudak/cloud.yml.
backup_rsync_extra_args: "--chmod=Du=rwx,Dgo=rx"
# Between the 04:00 personal and 04:30 Skudak runs, before the 05:00
# TrueNAS snapshot.
backup_oncalendar: "*-*-* 04:15:00"
@@ -405,7 +405,10 @@
php occ libresign:configure:check php occ libresign:configure:check
register: libresign_verify register: libresign_verify
changed_when: false changed_when: false
failed_when: libresign_verify.stdout is search('\berror\b') # Double backslashes: Jinja unescapes string literals, so a single '\b'
# becomes a BACKSPACE character and this could never match -- which is
# how a check reporting three errors passed clean on 2026-09-28.
failed_when: libresign_verify.stdout is search('\\berror\\b')
- name: disable nextcloud signup link in config - name: disable nextcloud signup link in config
become: true become: true
+19 -3
View File
@@ -79,15 +79,31 @@
image: docker.io/library/nextcloud:34.0.3-apache image: docker.io/library/nextcloud:34.0.3-apache
tags: skudak, skudak-cloud tags: skudak, skudak-cloud
# cloud.debyltech.com -- cloned from the Skudak instance above; keep the two
# image pins in step. DNS is a terraform-managed ALIAS (see defaults).
- import_tasks: containers/debyltech/cloud.yml
vars:
db_image: docker.io/library/mariadb:10.6
# Fully qualified on purpose: podman records `docker.io/library/redis`, and
# podman-check compares names literally, so the short `docker.io/redis`
# form (as in the Skudak block above) recreates redis on every deploy.
redis_image: docker.io/library/redis:8.2-alpine
image: docker.io/library/nextcloud:34.0.3-apache
# GitHub release asset + its sha256 -- see the pinned-install comment in
# the task file for why this is not left to the app store.
libresign_version: "14.2.2"
libresign_sha256: 8655a4c89f52ca7eaf542d0764d07a7732cb23b39906e7246b37e569ec7a6579
tags: debyltech, debyltech-cloud
- import_tasks: containers/debyltech/fulfillr.yml - import_tasks: containers/debyltech/fulfillr.yml
vars: vars:
image: git.debyl.io/debyltech/fulfillr:20260915.0011 image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr tags: debyltech, fulfillr
# Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config. # Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config.
- import_tasks: containers/debyltech/fulfillr-dev.yml - import_tasks: containers/debyltech/fulfillr-dev.yml
vars: vars:
image: git.debyl.io/debyltech/fulfillr:20260915.0011 image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr-dev tags: debyltech, fulfillr-dev
- import_tasks: containers/debyltech/uptime-kuma.yml - import_tasks: containers/debyltech/uptime-kuma.yml
@@ -123,7 +139,7 @@
- import_tasks: containers/home/gregtime.yml - import_tasks: containers/home/gregtime.yml
vars: vars:
image: localhost/greg-time-bot:3.19.0 image: localhost/greg-time-bot:3.21.1
tags: gregtime tags: gregtime
# Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to # Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to
@@ -457,6 +457,38 @@
} }
} }
# de Byl Tech Nextcloud - {{ cloud_debyltech_server_name }}
{{ cloud_debyltech_server_name }} {
request_body {
max_size {{ caddy_max_request_body_mb }}MB
}
reverse_proxy localhost:8091 {
header_up Host {host}
header_up X-Real-IP {remote}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-X-Powered-By
}
# Nextcloud specific redirects
redir /.well-known/carddav /remote.php/dav 301
redir /.well-known/caldav /remote.php/dav 301
log {
output file /var/log/caddy/cloud-debyltech.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Gitea - {{ gitea_debyl_server_name }} # Gitea - {{ gitea_debyl_server_name }}
{{ gitea_debyl_server_name }} { {{ gitea_debyl_server_name }} {
import common_headers import common_headers
@@ -0,0 +1,228 @@
<?php
/**
* {{ ansible_managed }}
*
* Post-deploy assertion that de Byl Tech mail branding is actually live.
*
* WHY THIS EXISTS: DebyltechEMailTemplate extends OC\Mail\EMailTemplate, which is
* Nextcloud's PRIVATE namespace -- no API stability guarantee. Two things can
* silently switch the branding off:
*
* 1. A Nextcloud major upgrade. appinfo/info.xml pins max-version, so the app
* is auto-disabled as incompatible; Mailer::createEMailTemplate() then
* fails its class_exists() check and falls back to the stock template.
* Mail still sends -- unbranded. That is the right failure mode, but it is
* invisible without this check.
* 2. An upstream change to the private base class breaking an override.
*
* Renders through Message::useTemplate() -- the REAL path -- rather than
* calling renderHtml() directly. That distinction is not academic: renderText()
* runs first and flips the parent's footerAdded flag, and a renderHtml()-only
* test once passed green while live mail shipped with no footer at all.
*
* Exits non-zero with a diagnostic on any failure, so the Ansible task fails
* the play rather than reporting a clean deploy over broken branding.
*/
require_once '/var/www/html/lib/base.php';
$mailer = \OC::$server->get(\OCP\Mail\IMailer::class);
$dispatcher = \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class);
// Mirrors MailService::notifyUnsignedUser() (custom_apps/libresign/lib/Service/MailService.php:85-116).
$template = $mailer->createEMailTemplate('settings.TestEmail');
$template->setSubject('LibreSign: There is a file for you to sign');
$template->addHeader();
$template->addHeading('File to sign', false);
$template->addBodyText('There is a document for you to sign. Access the link below:');
$template->addBodyButton('Sign »verify.pdf«', 'https://{{ cloud_debyltech_server_name }}/verify');
$message = $mailer->createMessage();
$message->setTo(['verify@example.invalid' => 'Verify']);
$message->useTemplate($template);
// What Mailer::send() does at lib/private/Mail/Mailer.php:186. Nothing is sent.
$dispatcher->dispatchTyped(new \OCP\Mail\Events\BeforeMessageSent($message));
$html = $message->getSymfonyEmail()->getHtmlBody() ?? '';
$text = $message->getPlainBody();
$subject = $message->getSubject();
$inlineNames = [];
foreach ($message->getSymfonyEmail()->getAttachments() as $part) {
$inlineNames[] = (string)$part->getFilename();
}
$failures = [];
if (!$template instanceof \OCA\Debyltechmail\Mail\DebyltechEMailTemplate) {
$failures[] = 'template class is ' . get_class($template)
. ' -- expected DebyltechEMailTemplate. Is the debyltechmail app enabled, and does '
. 'appinfo/info.xml still allow this Nextcloud major?';
}
if (!str_starts_with($subject, 'de Byl Technologies LLC')) {
$failures[] = 'subject not rewritten: ' . $subject;
}
if (!str_contains($html, 'official document-signing request')) {
$failures[] = 'HTML footer missing (renderText/renderHtml ordering regression?)';
}
if (!str_contains($text, 'official document-signing request')) {
$failures[] = 'plain-text footer missing';
}
if (!str_contains($html, 'debyltech.com/legal/privacy') || !str_contains($html, 'debyltech.com/legal/tos')) {
$failures[] = 'privacy/terms links missing from footer';
}
if (!str_contains($html, 'content="light only"')) {
$failures[] = 'color-scheme "light only" meta missing -- dark-mode mail clients will repaint '
. 'the ground and bury the black wordmark (did upstream rename </head> in $head?)';
}
if (preg_match('/[»«]/u', $html)) {
$failures[] = 'German guillemets survived into the body';
}
if (!str_contains($html, 'Review document')) {
$failures[] = 'button label not normalised to "Review document"';
}
if (!str_contains($html, 'cid:debyltech-wordmark.png')) {
$failures[] = 'logo is not a cid: reference -- BeforeMessageSent listener did not fire';
}
if (!in_array('debyltech-wordmark.png', $inlineNames, true)) {
$failures[] = 'inline logo MIME part absent (found: ' . (implode(', ', $inlineNames) ?: 'none') . ')';
}
// ---------------------------------------------------------------------------
// LibreSign signing settings. These live in oc_appconfig (the database), not on
// disk, so they survive container recreation -- but they are re-assertable and
// a stray click in the admin UI can change them silently. GRAPHIC in particular
// matters: any other mode makes SignatureTextService::getSignatureWidth()
// return $current / 2 and stamp a name/date block that duplicates -- and
// collides with -- the one our documents already typeset.
$appConfig = \OC::$server->get(\OCP\IAppConfig::class);
// Must be exactly GRAPHIC_ONLY -- SignerElementsService::RENDER_MODE_GRAPHIC_ONLY.
// The valid set is DESCRIPTION_ONLY / SIGNAME_AND_DESCRIPTION /
// GRAPHIC_AND_DESCRIPTION / GRAPHIC_ONLY. Anything outside it (a bare 'GRAPHIC',
// say) is accepted by occ but matches no radio in the admin UI and falls
// through to default behaviour, so this asserts membership, not just non-empty.
$renderMode = $appConfig->getValueString('libresign', 'signature_render_mode', '');
if ($renderMode !== 'GRAPHIC_ONLY') {
$failures[] = 'libresign signature_render_mode is "' . $renderMode
. '" -- expected GRAPHIC_ONLY (signature only). Any other mode halves the '
. 'stamp width and overlays a duplicate name/date block.';
}
// Read with getValueBool, exactly as FooterHandler:158 does -- asserting the
// string form would pass on a value the app itself reads as true.
if ($appConfig->getValueBool('libresign', 'write_qrcode_on_footer', true) !== false) {
$failures[] = 'libresign write_qrcode_on_footer is not false -- the validation '
. 'QR block will be stamped on every page and overlaps the document footer. '
. '(Was it written without --type=boolean?)';
}
// Signer search for account-owned emails. Both keys are asserted because the
// two failure modes are opposite and the second is the more dangerous:
// full_match = yes -> account-owned emails silently unselectable
// full_match_email = no -> email signer search disabled ENTIRELY
// Defaults are 'yes' for both (MailPlugin.php:50-55), so an unset
// full_match_email is correct and only an explicit 'no' is a problem.
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') !== 'no') {
$failures[] = 'core shareapi_restrict_user_enumeration_full_match is not "no" -- '
. 'emails belonging to an existing Nextcloud account cannot be added as '
. 'LibreSign signers (MailPlugin.php:163 aborts the search).';
}
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'no') {
$failures[] = 'core shareapi_restrict_user_enumeration_full_match_email is "no" -- '
. 'this disables email signer search ENTIRELY (MailPlugin.php:67). It must be '
. 'unset or "yes"; it is NOT the knob for the account-owned-email problem.';
}
// Outside signers are invited by address; without an enabled email identify
// method the signer search returns "No signers." for any non-account email.
$methods = json_decode($appConfig->getValueString('libresign', 'identify_methods', '[]'), true) ?: [];
$emailOn = false;
foreach ($methods as $m) {
if (($m['name'] ?? '') === 'email' && !empty($m['enabled'])) {
$emailOn = true;
}
}
if (!$emailOn) {
$failures[] = 'libresign email identify method is not enabled -- outside addresses '
. 'cannot be added as signers ("No signers.")';
}
// Customer isolation (see the policy task in containers/debyltech/cloud.yml).
// A stray click in Settings > Sharing can undo any of these, and each one
// quietly re-exposes customers to one another or lets them share onward.
$isolation = [
'shareapi_exclude_groups' => 'allow',
'shareapi_exclude_groups_list' => json_encode(['{{ cloud_debyltech_staff_group }}']),
'shareapi_allow_share_dialog_user_enumeration' => 'no',
'shareapi_default_permissions' => '1',
];
foreach ($isolation as $key => $want) {
$have = \OC::$server->get(\OCP\IConfig::class)->getAppValue('core', $key, '<unset>');
if ($have !== $want) {
$failures[] = "core $key is \"$have\" -- expected \"$want\" (customer isolation)";
}
}
// Only staff may AUTHOR signature requests (LibreSign cannot be group-
// restricted without breaking its public signing links).
$requesters = json_decode($appConfig->getValueString('libresign', 'groups_request_sign', ''), true);
if ($requesters !== ['{{ cloud_debyltech_staff_group }}']) {
$failures[] = 'libresign groups_request_sign is ' . json_encode($requesters)
. ' -- expected only the staff group, or customers could send signature requests';
}
$identDocs = $appConfig->getValueString('libresign', 'identification_documents', '');
if ($identDocs !== '0') {
$failures[] = 'libresign identification_documents is "' . $identDocs
. '" -- expected 0. A non-zero value gates signing behind an ID upload '
. 'plus admin approval, and signers see no way to sign.';
}
// ---------------------------------------------------------------------------
// Redis: distributed cache + transactional file locking.
//
// These come from the image's config/redis.config.php drop-in, which only
// activates when REDIS_HOST is set on the container. If the env var is lost
// (a container recreated from a stale spec, say), Nextcloud silently reverts
// to DBLockingProvider and every file lock goes back to being a MariaDB write
// -- functional, but the stalls come back with no error anywhere.
$sysConfig = \OC::$server->get(\OCP\IConfig::class);
foreach (['memcache.locking', 'memcache.distributed'] as $key) {
$value = $sysConfig->getSystemValueString($key, '');
if ($value !== '\OC\Memcache\Redis') {
$failures[] = $key . ' is "' . $value . '" -- expected \\OC\\Memcache\\Redis. '
. 'Is REDIS_HOST still set on the debyltech-cloud container?';
}
}
// Prove Redis is actually reachable and authenticating, not merely configured.
// A wrong password leaves the config looking perfect while every cache and
// lock operation fails at runtime.
try {
$cacheFactory = \OC::$server->get(\OCP\ICacheFactory::class);
if (!$cacheFactory->isAvailable()) {
$failures[] = 'distributed cache reports unavailable -- redis unreachable or auth failed';
} else {
$probe = $cacheFactory->createDistributed('debyltechmail-verify');
$probe->set('probe', 'ok', 30);
if ($probe->get('probe') !== 'ok') {
$failures[] = 'distributed cache round-trip failed (set/get mismatch)';
}
$probe->remove('probe');
}
} catch (\Throwable $e) {
$failures[] = 'distributed cache threw: ' . $e->getMessage();
}
if ($failures !== []) {
fwrite(STDERR, "debyltechmail branding verification FAILED:\n");
foreach ($failures as $f) {
fwrite(STDERR, " - $f\n");
}
exit(1);
}
echo "debyltechmail branding OK (subject: $subject)\n";
@@ -0,0 +1,40 @@
# {{ ansible_managed }}
#
# Redis for debyltech-cloud: Nextcloud distributed cache + transactional file
# locking. Reachable only by container name on the `shared` podman network --
# no host port is published.
#
# The password lives HERE rather than on the command line as
# `redis-server --requirepass <pass>`. That is the existing house idiom (see
# the deleted container-nosql.yml in git history), but it leaks the secret into
# `podman inspect`, into the generated systemd unit under
# ~/.config/systemd/user/, and into `ps` for every user on the host. A 0640
# config file mounted read-only keeps it out of all three.
requirepass {{ cloud_debyltech_redis_pass }}
# Bind to all interfaces WITHIN the container's network namespace. The
# container publishes no port, so this is reachable only from the `shared`
# podman network -- not from the host and not from the LAN.
bind 0.0.0.0
port 6379
protected-mode yes
# NO maxmemory / eviction policy, deliberately.
#
# Nextcloud puts BOTH the distributed cache and the transactional file locks in
# this instance. Cache entries are safely evictable; LOCKS ARE NOT. An
# `allkeys-lru` policy under memory pressure can evict a lock that a live
# request still believes it holds, which permits concurrent writers to the same
# file -- silent corruption rather than a visible error. With no maxmemory,
# Redis never evicts. The host has ~14 GiB free of 31 GiB and this instance
# holds a few hundred keys, so a cap buys nothing.
#
# If a cap is ever genuinely needed, use `maxmemory-policy noeviction` so Redis
# returns an error instead of silently discarding a lock.
# No persistence. Locks are ephemeral and TTL-bounded, and the cache is
# rebuildable -- there is nothing here worth surviving a restart. Persisting
# would be actively worse: a restored RDB could reinstate locks whose owning
# request died, blocking files until the TTL expired.
save ""
appendonly no
@@ -85,6 +85,15 @@ for u in {{ podman_prune_ci_users | join(' ') }}; do
# are rebuilt or re-pulled from the registry only when missing, so pruning # are rebuilt or re-pulled from the registry only when missing, so pruning
# them just forces a multi-GB re-download on the next job. # them just forces a multi-GB re-download on the next job.
prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}" prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}"
# ...but the label is inherited by every build of those images, including the
# one a rebuild supersedes. That copy loses its tag and becomes dangling, and
# the label filter above would keep it forever -- a multi-GB leak per weekly
# rebuild, ESP-IDF alone being several GB. Without -a, `image prune` removes
# only dangling images, so it can ignore the label without touching the live
# tagged ones.
dangling=$(run "$u" image prune -f --filter "until={{ podman_prune_ci_until }}" 2>&1 | tail -1)
log "user=$u dangling_prune=${dangling:-none}"
done done
log "status=ok" log "status=ok"
Binary file not shown.