Compare commits
38
Commits
f7f4d903a0
...
master
@@ -0,0 +1,273 @@
|
|||||||
|
---
|
||||||
|
# Builds the Gitea Actions job images and publishes them to the Gitea container
|
||||||
|
# registry, so the runner can re-pull one the nightly podman prune removed
|
||||||
|
# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`.
|
||||||
|
#
|
||||||
|
# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the
|
||||||
|
# image contents, defaults/main.yml for the version pins and the registry path.
|
||||||
|
# This workflow reads those vars rather than repeating them. roles/gitea-actions
|
||||||
|
# then only pulls what lands here (gitea_ci_build_local is the escape hatch for
|
||||||
|
# seeding an empty namespace, since the job below runs *in* gitea-ci).
|
||||||
|
#
|
||||||
|
# `docker build` here talks to the gitea-runner user's rootless podman socket,
|
||||||
|
# mounted into every job container by roles/gitea-actions (config.yaml.j2), so
|
||||||
|
# the build happens in the same image store the runner pulls from and the layer
|
||||||
|
# cache survives between runs. That also means a build writes tags the live
|
||||||
|
# runner will use -- which is why pull requests build under a throwaway
|
||||||
|
# :pr-<n> tag and delete it again.
|
||||||
|
name: CI Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- ansible/roles/gitea-actions/files/Containerfile.*
|
||||||
|
- ansible/roles/gitea-actions/defaults/main.yml
|
||||||
|
- .gitea/workflows/ci-images.yml
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- ansible/roles/gitea-actions/files/Containerfile.*
|
||||||
|
- ansible/roles/gitea-actions/defaults/main.yml
|
||||||
|
- .gitea/workflows/ci-images.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image:
|
||||||
|
description: Which image to rebuild
|
||||||
|
type: choice
|
||||||
|
options: [all, ci, espidf, platformio]
|
||||||
|
default: all
|
||||||
|
schedule:
|
||||||
|
# Weekly rebuild so base-image security updates land without a commit.
|
||||||
|
# Sunday 04:00, after the 02:00 podman prune has finished.
|
||||||
|
- cron: "0 4 * * 0"
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml
|
||||||
|
CONTEXT: ansible/roles/gitea-actions/files
|
||||||
|
REGISTRY: git.debyl.io
|
||||||
|
# Not a secret: the same namespace is in defaults/main.yml. It must be the
|
||||||
|
# owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's
|
||||||
|
# user, not by the path, so pushing to gitbot/ means logging in as gitbot.
|
||||||
|
REGISTRY_USER: gitbot
|
||||||
|
KEEP_LABEL: io.debyl.ci-base
|
||||||
|
|
||||||
|
# One publisher at a time. Two runs pushing :latest concurrently would leave the
|
||||||
|
# registry holding whichever finished last, which need not be the newest commit.
|
||||||
|
concurrency:
|
||||||
|
group: ci-images
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
name: Plan
|
||||||
|
runs-on: fedora
|
||||||
|
outputs:
|
||||||
|
images: ${{ steps.plan.outputs.images }}
|
||||||
|
any: ${{ steps.plan.outputs.any }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history so the change detection below can diff against the
|
||||||
|
# pushed-from commit / the PR base.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide which images to build
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
SELECTED: ${{ github.event.inputs.image }}
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
|
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||||
|
import json, os, subprocess, sys, yaml
|
||||||
|
|
||||||
|
defaults = yaml.safe_load(open(os.environ["DEFAULTS"]))
|
||||||
|
ctx = os.environ["CONTEXT"]
|
||||||
|
reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"]
|
||||||
|
|
||||||
|
# Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt
|
||||||
|
# from the same version vars the role interpolates, so a pin bump in
|
||||||
|
# that file moves the image tag here and in ansible together.
|
||||||
|
images = [
|
||||||
|
{
|
||||||
|
"key": "ci",
|
||||||
|
"containerfile": "Containerfile.ci",
|
||||||
|
"tag": f"{reg}/{ns}/gitea-ci:latest",
|
||||||
|
"build_args": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "espidf",
|
||||||
|
"containerfile": "Containerfile.espidf",
|
||||||
|
"tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}",
|
||||||
|
"build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "platformio",
|
||||||
|
"containerfile": "Containerfile.platformio",
|
||||||
|
"tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}",
|
||||||
|
"build_args": (
|
||||||
|
f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} "
|
||||||
|
f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
event = os.environ["EVENT"]
|
||||||
|
|
||||||
|
def changed_files(base):
|
||||||
|
"""Paths touched since `base`, or None if the diff is not usable."""
|
||||||
|
if not base or set(base) == {"0"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "diff", "--name-only", f"{base}...HEAD"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# Force push, shallow clone, first push of a branch: fall back
|
||||||
|
# to building everything rather than silently skipping a real
|
||||||
|
# change.
|
||||||
|
return None
|
||||||
|
return set(out.split())
|
||||||
|
|
||||||
|
if event == "workflow_dispatch":
|
||||||
|
selected = os.environ.get("SELECTED") or "all"
|
||||||
|
picked = images if selected == "all" else [i for i in images if i["key"] == selected]
|
||||||
|
elif event == "schedule":
|
||||||
|
picked = images
|
||||||
|
else:
|
||||||
|
base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"]
|
||||||
|
touched = changed_files(base)
|
||||||
|
if touched is None:
|
||||||
|
picked = images
|
||||||
|
else:
|
||||||
|
# defaults/main.yml holds every pin, so a change there could
|
||||||
|
# retag any image; the workflow file itself changes how all of
|
||||||
|
# them are built. Either one rebuilds the lot.
|
||||||
|
wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"}
|
||||||
|
if touched & wide:
|
||||||
|
picked = images
|
||||||
|
else:
|
||||||
|
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
|
||||||
|
|
||||||
|
# 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("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
|
||||||
|
PY
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.key }}
|
||||||
|
needs: plan
|
||||||
|
if: needs.plan.outputs.any == 'true'
|
||||||
|
runs-on: fedora
|
||||||
|
strategy:
|
||||||
|
# One image failing must not cancel the others: they are independent, and
|
||||||
|
# a half-published set is what this whole workflow exists to avoid.
|
||||||
|
fail-fast: false
|
||||||
|
# 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:
|
||||||
|
- 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
|
||||||
|
if: steps.spec.outputs.build == 'true'
|
||||||
|
|
||||||
|
- name: Log in to the Gitea Container Registry
|
||||||
|
if: steps.spec.outputs.build == 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
# The build lands in the live runner's image store, and act_runner will
|
||||||
|
# not re-pull a tag it already has locally. Tagging a PR build with the
|
||||||
|
# real tag would therefore hand every later job on this host an unmerged
|
||||||
|
# image, so PRs get a throwaway tag that the cleanup step removes.
|
||||||
|
- name: Resolve build tag
|
||||||
|
id: tag
|
||||||
|
if: steps.spec.outputs.build == 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
echo "image=${{ steps.spec.outputs.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "image=${{ steps.spec.outputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build ${{ matrix.key }}
|
||||||
|
if: steps.spec.outputs.build == 'true'
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.tag.outputs.image }}
|
||||||
|
BUILD_ARGS: ${{ steps.spec.outputs.build_args }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
args=()
|
||||||
|
for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done
|
||||||
|
# --pull so a scheduled run actually picks up a refreshed base image;
|
||||||
|
# without it an unchanged FROM line just hits the local layer cache.
|
||||||
|
docker build --pull \
|
||||||
|
"${args[@]}" \
|
||||||
|
-t "$IMAGE" \
|
||||||
|
-f "$CONTEXT/${{ steps.spec.outputs.containerfile }}" \
|
||||||
|
"$CONTEXT"
|
||||||
|
|
||||||
|
- name: Verify the prune-exemption label survived the build
|
||||||
|
if: steps.spec.outputs.build == 'true'
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.tag.outputs.image }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# roles/podman's nightly prune keeps an image only if it carries this
|
||||||
|
# label (podman_prune_ci_keep_label). Publishing one without it would
|
||||||
|
# quietly restore the nightly-deletion behaviour this replaced, and
|
||||||
|
# nothing would notice until CI failed on a Monday morning.
|
||||||
|
got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE")
|
||||||
|
test "$got" = "true" || {
|
||||||
|
echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Push ${{ matrix.key }}
|
||||||
|
if: github.event_name != 'pull_request' && steps.spec.outputs.build == 'true'
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.tag.outputs.image }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker push "$IMAGE"
|
||||||
|
echo "Pushed: $IMAGE"
|
||||||
|
|
||||||
|
# Always, including on failure: the throwaway tag carries the keep label,
|
||||||
|
# so the nightly prune will not reclaim it and a few skipped cleanups add
|
||||||
|
# up to gigabytes in the runner's store.
|
||||||
|
- name: Drop the pull-request image
|
||||||
|
if: always() && github.event_name == 'pull_request' && steps.spec.outputs.build == 'true'
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.tag.outputs.image }}
|
||||||
|
run: docker rmi -f "$IMAGE" || true
|
||||||
@@ -28,6 +28,11 @@ The project uses Python virtualenv for dependency management:
|
|||||||
- Makefile automatically creates `.venv/` and installs dependencies
|
- Makefile automatically creates `.venv/` and installs dependencies
|
||||||
- Vault password is sourced from password manager via `.pass.sh`
|
- Vault password is sourced from password manager via `.pass.sh`
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
- Work directly on `master` - no feature branches and no pull requests in this repo.
|
||||||
|
- Commit to `master` and push to `origin/master` when asked; don't create a branch first.
|
||||||
|
- Pushing to `master` also triggers `.gitea/workflows/ci-images.yml` (see Gitea Actions CI images below), which only rebuilds images whose inputs changed.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
@@ -45,6 +50,7 @@ ansible/
|
|||||||
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
|
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
|
||||||
│ ├── github-actions/# CI/CD runner setup
|
│ ├── github-actions/# CI/CD runner setup
|
||||||
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
|
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
|
||||||
|
│ ├── gitea-actions/ # Gitea Actions runners + CI job images (see its README)
|
||||||
│ └── pihole/ # DNS filtering
|
│ └── pihole/ # DNS filtering
|
||||||
└── vars/
|
└── vars/
|
||||||
└── vault.yml # Encrypted secrets
|
└── vault.yml # Encrypted secrets
|
||||||
@@ -54,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
|
||||||
@@ -90,6 +96,7 @@ Tasks are tagged by service/component for selective deployment:
|
|||||||
- `ddns` - Dynamic DNS tasks
|
- `ddns` - Dynamic DNS tasks
|
||||||
- ~~`drone` - CI/CD tasks (decommissioned)~~
|
- ~~`drone` - CI/CD tasks (decommissioned)~~
|
||||||
- `hass` - Home Assistant tasks
|
- `hass` - Home Assistant tasks
|
||||||
|
- `gitea-actions` - Gitea Actions runners and their CI job images
|
||||||
- Common infrastructure tags like `common`, `ssl`
|
- Common infrastructure tags like `common`, `ssl`
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
@@ -122,6 +129,17 @@ Tasks are tagged by service/component for selective deployment:
|
|||||||
- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is
|
- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is
|
||||||
unreachable
|
unreachable
|
||||||
|
|
||||||
|
### Gitea Actions CI images
|
||||||
|
|
||||||
|
The runner's job images (`gitea-ci`, `gitea-ci-espidf`, `gitea-ci-platformio`)
|
||||||
|
are built by `.gitea/workflows/ci-images.yml` and published to the Gitea
|
||||||
|
container registry under `git.debyl.io/gitbot/`. The `gitea-actions` role only
|
||||||
|
pulls them - do NOT add build steps back to it. To change an image, edit
|
||||||
|
`ansible/roles/gitea-actions/files/Containerfile.*` (or a version pin in that
|
||||||
|
role's `defaults/main.yml`) and push to master; CI rebuilds only what changed.
|
||||||
|
See `ansible/roles/gitea-actions/README.md` for the registry rationale, the
|
||||||
|
prune-exemption label, and the bootstrap path when CI itself cannot build.
|
||||||
|
|
||||||
### Remote SSH Commands for Service Users
|
### Remote SSH Commands for Service Users
|
||||||
|
|
||||||
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
|
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
|
|||||||
touch $@
|
touch $@
|
||||||
|
|
||||||
# Linting
|
# Linting
|
||||||
YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*')
|
# .gitea/workflows is linted too: the CI-image workflow is as much part of the
|
||||||
|
# deployment as the roles it publishes for.
|
||||||
|
YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*')
|
||||||
SKIP_FILE=./.lint-vars.sh
|
SKIP_FILE=./.lint-vars.sh
|
||||||
|
|
||||||
# Targets
|
# Targets
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -4,9 +4,11 @@ git_home: "/srv/{{ git_user }}"
|
|||||||
|
|
||||||
# Gitea configuration
|
# Gitea configuration
|
||||||
gitea_debyl_server_name: git.debyl.io
|
gitea_debyl_server_name: git.debyl.io
|
||||||
gitea_image: docker.gitea.com/gitea:1.26.1
|
# Pinned per instance so one can be upgraded (and verified) before the other.
|
||||||
|
gitea_debyl_image: docker.gitea.com/gitea:1.27.3
|
||||||
gitea_db_image: docker.io/library/postgres:14-alpine
|
gitea_db_image: docker.io/library/postgres:14-alpine
|
||||||
|
|
||||||
# Skudak Gitea configuration
|
# Skudak Gitea configuration
|
||||||
gitea_skudak_server_name: git.skudak.com
|
gitea_skudak_server_name: git.skudak.com
|
||||||
gitea_skudak_ssh_port: 2222
|
gitea_skudak_ssh_port: 2222
|
||||||
|
gitea_skudak_image: docker.gitea.com/gitea:1.27.3
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
become_user: "{{ git_user }}"
|
become_user: "{{ git_user }}"
|
||||||
containers.podman.podman_container:
|
containers.podman.podman_container:
|
||||||
name: gitea-skudak
|
name: gitea-skudak
|
||||||
image: "{{ gitea_image }}"
|
image: "{{ gitea_skudak_image }}"
|
||||||
pod: gitea-skudak-pod
|
pod: gitea-skudak-pod
|
||||||
restart_policy: on-failure:3
|
restart_policy: on-failure:3
|
||||||
log_driver: journald
|
log_driver: journald
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
state: started
|
state: started
|
||||||
ports:
|
ports:
|
||||||
- "3100:3000"
|
- "3100:3000"
|
||||||
tags: gitea
|
tags: gitea, gitea-debyl
|
||||||
|
|
||||||
# PostgreSQL container in pod
|
# PostgreSQL container in pod
|
||||||
- name: create gitea-debyl-postgres container
|
- name: create gitea-debyl-postgres container
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
|
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
|
||||||
volumes:
|
volumes:
|
||||||
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
|
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
|
||||||
tags: gitea
|
tags: gitea, gitea-debyl
|
||||||
|
|
||||||
# Gitea container in pod
|
# Gitea container in pod
|
||||||
- name: create gitea-debyl container
|
- name: create gitea-debyl container
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
become_user: "{{ git_user }}"
|
become_user: "{{ git_user }}"
|
||||||
containers.podman.podman_container:
|
containers.podman.podman_container:
|
||||||
name: gitea-debyl
|
name: gitea-debyl
|
||||||
image: "{{ gitea_image }}"
|
image: "{{ gitea_debyl_image }}"
|
||||||
pod: gitea-debyl-pod
|
pod: gitea-debyl-pod
|
||||||
restart_policy: on-failure:3
|
restart_policy: on-failure:3
|
||||||
log_driver: journald
|
log_driver: journald
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
volumes:
|
volumes:
|
||||||
- "{{ git_home }}/volumes/gitea/data:/data"
|
- "{{ git_home }}/volumes/gitea/data:/data"
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
tags: gitea
|
tags: gitea, gitea-debyl
|
||||||
|
|
||||||
# Generate systemd service for the pod
|
# Generate systemd service for the pod
|
||||||
- name: create systemd job for gitea-debyl-pod
|
- name: create systemd job for gitea-debyl-pod
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
args:
|
args:
|
||||||
chdir: "{{ git_home }}"
|
chdir: "{{ git_home }}"
|
||||||
changed_when: false
|
changed_when: false
|
||||||
tags: gitea
|
tags: gitea, gitea-debyl
|
||||||
|
|
||||||
- name: enable gitea-debyl-pod service
|
- name: enable gitea-debyl-pod service
|
||||||
become: true
|
become: true
|
||||||
@@ -91,4 +91,4 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
scope: user
|
scope: user
|
||||||
tags: gitea
|
tags: gitea, gitea-debyl
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# gitea-actions
|
||||||
|
|
||||||
|
Runs the Gitea Actions runners on `home.debyl.io`. One `act_runner` process per
|
||||||
|
Gitea instance (`git.debyl.io`, `git.skudak.com`), both as the `gitea-runner`
|
||||||
|
user, both backed by the same rootless podman image store.
|
||||||
|
|
||||||
|
## CI job images
|
||||||
|
|
||||||
|
Jobs do not run on the host. Each one gets an ephemeral container from one of
|
||||||
|
three images:
|
||||||
|
|
||||||
|
| `runs-on` / `container:` | Image | Used by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fedora`, `ubuntu-latest`, `ubuntu-22.04` | `git.debyl.io/gitbot/gitea-ci:latest` | Go / node / web jobs, `docker build` |
|
||||||
|
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-espidf:<esp_idf_version>` | esp-mg-tpms, skudak/esp32-stm32-vcu |
|
||||||
|
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-platformio:<pio_espressif32_version>` | skudak/esp32-web-interface |
|
||||||
|
|
||||||
|
**This role does not build them.** `.gitea/workflows/ci-images.yml` builds
|
||||||
|
`files/Containerfile.*` and pushes to the Gitea registry; the role logs
|
||||||
|
`gitea-runner` in and pulls. Version pins live in `defaults/main.yml` and are
|
||||||
|
read by both the role and the workflow, so a bump moves the image tag in one
|
||||||
|
place.
|
||||||
|
|
||||||
|
### Why the registry
|
||||||
|
|
||||||
|
The images used to exist only as `localhost/gitea-ci*` in the runner's store.
|
||||||
|
The nightly prune (`roles/podman`, `podman_prune_ci_until: 48h`) deletes any
|
||||||
|
CI-user image older than that which no container holds, so after an idle
|
||||||
|
weekend every job failed in under a second on `docker pull
|
||||||
|
localhost/gitea-ci:latest`, and the only fix was re-running this role and
|
||||||
|
waiting out a full rebuild.
|
||||||
|
|
||||||
|
Two things now keep that from happening:
|
||||||
|
|
||||||
|
- **A registry copy.** `force_pull` stays `false`, which in act_runner means
|
||||||
|
*pull only when missing* — so a present image is never re-fetched, and a
|
||||||
|
pruned one is restored by the next job without anyone noticing.
|
||||||
|
- **A prune exemption.** Each Containerfile declares
|
||||||
|
`LABEL io.debyl.ci-base="true"`, and the prune skips that label
|
||||||
|
(`podman_prune_ci_keep_label`). Its `until` counts from build time, not pull
|
||||||
|
time, so without this a re-pulled image would be deleted again the same night
|
||||||
|
— a 7.8 GB ESP-IDF download every single day.
|
||||||
|
|
||||||
|
The label is declared in the Containerfile rather than passed as `--label` so
|
||||||
|
neither builder can omit it; the workflow re-checks it with `docker inspect`
|
||||||
|
before pushing.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Both the role and act_runner read `/home/gitea-runner/.docker/config.json`.
|
||||||
|
act_runner uses it for the job-image pull it performs when a label's image is
|
||||||
|
missing; podman falls back to the same file. The role writes it from
|
||||||
|
`gitea_registry_username` / `gitea_registry_token` (vault), so one login covers
|
||||||
|
both. The `skudak` runner pulls from `git.debyl.io` too — same host, same user,
|
||||||
|
same file.
|
||||||
|
|
||||||
|
The workflow pushes with a `REGISTRY_TOKEN` secret on `bastian/deploy_home`,
|
||||||
|
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
|
||||||
|
`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
|
||||||
|
|
||||||
|
Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push
|
||||||
|
to `master`, and the workflow rebuilds only the affected images. It also
|
||||||
|
rebuilds everything weekly so base-image updates land without a commit, and
|
||||||
|
takes a `workflow_dispatch` with an image selector.
|
||||||
|
|
||||||
|
Pull requests build but do not push, under a throwaway `:pr-<n>` tag that is
|
||||||
|
deleted afterwards. The build runs in the live runner's image store, so a PR
|
||||||
|
tagged with the real name would hand every later job on this host an unmerged
|
||||||
|
image.
|
||||||
|
|
||||||
|
### Bootstrap / CI is down
|
||||||
|
|
||||||
|
The workflow that builds `gitea-ci` runs *in* `gitea-ci`, so a registry that has
|
||||||
|
never held it cannot bootstrap itself. Build on the host instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
That builds all three from the same Containerfiles and pushes them. One run is
|
||||||
|
enough even on a cold registry: `tasks/main.yml` imports `images.yml` before
|
||||||
|
`runner.yml`, so the images are published before the runner labels are flipped
|
||||||
|
to point at them.
|
||||||
|
|
||||||
|
The alternative first-time path is to merge the workflow and dispatch it while
|
||||||
|
the deployed labels still say `localhost/` — the job then builds inside the old
|
||||||
|
local image and seeds the registry — then run a plain
|
||||||
|
`make deploy TAGS=gitea-actions` to switch the labels over.
|
||||||
@@ -22,17 +22,18 @@ act_runner_bin: /usr/local/bin/act_runner
|
|||||||
act_runner_config_dir: /etc/act_runner
|
act_runner_config_dir: /etc/act_runner
|
||||||
act_runner_work_dir: /var/lib/act_runner
|
act_runner_work_dir: /var/lib/act_runner
|
||||||
|
|
||||||
# Job container images. tasks/images.yml builds them into the gitea-runner
|
# Job container images, served from the Gitea container registry.
|
||||||
# rootless store and pushes them to the Gitea container registry.
|
|
||||||
#
|
#
|
||||||
# They used to live only under localhost/, and the nightly podman prune
|
# They used to live only under localhost/, built by this role. The nightly
|
||||||
# (roles/podman: podman_prune_ci_until) deletes any CI-user image older than
|
# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image
|
||||||
# 48h that no container is using -- so every idle weekend CI failed in 0-1s on
|
# older than 48h that no container is using, so every idle weekend CI failed in
|
||||||
# `docker pull localhost/gitea-ci:latest` until someone re-ran this role and
|
# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role
|
||||||
# waited out a full rebuild. With a registry copy, a pruned image is simply
|
# and waited out a full rebuild.
|
||||||
# re-pulled by the next job (force_pull stays false, so a present image is
|
#
|
||||||
# never re-pulled), and this role pulls instead of rebuilding when the
|
# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and
|
||||||
# Containerfile has not changed.
|
# pushes them here, and this role only pulls. A pruned image is re-pulled by the
|
||||||
|
# next job on its own (force_pull stays false, which means "pull only when
|
||||||
|
# missing", so a present image is never re-fetched).
|
||||||
#
|
#
|
||||||
# Workflows that pin `container: image:` must use these registry paths too
|
# Workflows that pin `container: image:` must use these registry paths too
|
||||||
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
|
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
|
||||||
@@ -41,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
|
||||||
@@ -55,26 +56,36 @@ gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespa
|
|||||||
# fallback auth file), so one login covers the runner and this role.
|
# fallback auth file), so one login covers the runner and this role.
|
||||||
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
|
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
|
||||||
|
|
||||||
# Label stamped on the CI base images so the nightly prune skips them; must match
|
# The images this role keeps present on the runner. `build_args` is a literal
|
||||||
# podman_prune_ci_keep_label in roles/podman/defaults/main.yml. Without it the
|
# podman-build argument string (podman_image has no structured build-arg
|
||||||
# prune (whose `until` counts from build time, not pull time) would delete a
|
# option) and is only used by the gitea_ci_build_local fallback below -- the
|
||||||
# re-pulled image again the next night -- a 7.8 GB ESP-IDF re-download after
|
# workflow passes the same --build-arg values, read out of the version vars
|
||||||
# every idle day. Superseded tags (e.g. after an esp_idf_version bump) are
|
# above, so there is one source of truth for the pins.
|
||||||
# therefore kept too; remove them by hand.
|
|
||||||
gitea_ci_keep_label: io.debyl.ci-base
|
|
||||||
|
|
||||||
gitea_ci_images:
|
gitea_ci_images:
|
||||||
- image: "{{ gitea_ci_image }}"
|
- image: "{{ gitea_ci_image }}"
|
||||||
containerfile: Containerfile.ci
|
containerfile: Containerfile.ci
|
||||||
template: Containerfile.ci
|
build_args: ""
|
||||||
- image: "{{ gitea_ci_espidf_image }}"
|
- image: "{{ gitea_ci_espidf_image }}"
|
||||||
containerfile: Containerfile.espidf
|
containerfile: Containerfile.espidf
|
||||||
template: Containerfile.espidf.j2
|
build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}"
|
||||||
- image: "{{ gitea_ci_platformio_image }}"
|
- image: "{{ gitea_ci_platformio_image }}"
|
||||||
containerfile: Containerfile.platformio
|
containerfile: Containerfile.platformio
|
||||||
template: Containerfile.platformio.j2
|
build_args: >-
|
||||||
|
--build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }}
|
||||||
|
--build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }}
|
||||||
|
|
||||||
# Default labels for every runner — map runs-on values to the local CI image.
|
# Escape hatch: build the images on the host and push them, instead of pulling
|
||||||
|
# what CI published. Needed to seed a brand-new registry namespace, and when CI
|
||||||
|
# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a
|
||||||
|
# registry that has never held it cannot bootstrap itself.
|
||||||
|
#
|
||||||
|
# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
|
||||||
|
#
|
||||||
|
# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF
|
||||||
|
# rebuild, and two publishers racing on the same tag is worth avoiding.
|
||||||
|
gitea_ci_build_local: false
|
||||||
|
|
||||||
|
# Default labels for every runner — map runs-on values to the registry CI image.
|
||||||
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
|
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
|
||||||
gitea_runner_labels:
|
gitea_runner_labels:
|
||||||
- "fedora:docker://{{ gitea_ci_image }}"
|
- "fedora:docker://{{ gitea_ci_image }}"
|
||||||
|
|||||||
+10
@@ -1,8 +1,18 @@
|
|||||||
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
|
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
|
||||||
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
|
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
|
||||||
# podman socket). Go toolchains are provided per-job by actions/setup-go.
|
# podman socket). Go toolchains are provided per-job by actions/setup-go.
|
||||||
|
#
|
||||||
|
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
|
||||||
|
# only pulls the result (see gitea_ci_build_local for the local-build fallback).
|
||||||
|
# A plain Containerfile, not a template, so CI and ansible build the same bytes.
|
||||||
FROM node:20-bookworm-slim
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
|
||||||
|
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
|
||||||
|
# --label at build time so neither builder can forget it: without the label the
|
||||||
|
# prune deletes the image every night and the next job re-pulls a gigabyte.
|
||||||
|
LABEL io.debyl.ci-base="true"
|
||||||
|
|
||||||
ARG DOCKER_CLI_VERSION=27.3.1
|
ARG DOCKER_CLI_VERSION=27.3.1
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
+13
-1
@@ -14,7 +14,19 @@
|
|||||||
# the release aborts *after* the firmware and version.json are already live —
|
# the release aborts *after* the firmware and version.json are already live —
|
||||||
# clients get the new build while the tag, Gitea release and protocol manifest
|
# clients get the new build while the tag, Gitea release and protocol manifest
|
||||||
# are never written. Keep it installed.
|
# are never written. Keep it installed.
|
||||||
FROM espressif/idf:{{ esp_idf_version }}
|
#
|
||||||
|
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
|
||||||
|
# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible
|
||||||
|
# template var so CI and ansible build the same bytes -- its value is read from
|
||||||
|
# esp_idf_version in roles/gitea-actions/defaults/main.yml by both.
|
||||||
|
ARG ESP_IDF_VERSION
|
||||||
|
FROM espressif/idf:${ESP_IDF_VERSION}
|
||||||
|
|
||||||
|
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
|
||||||
|
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
|
||||||
|
# --label at build time so neither builder can forget it: without the label the
|
||||||
|
# prune deletes the image every night and the next job re-pulls 7.8 GB.
|
||||||
|
LABEL io.debyl.ci-base="true"
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl ca-certificates unzip jq python3-yaml python3-jinja2 \
|
curl ca-certificates unzip jq python3-yaml python3-jinja2 \
|
||||||
+20
-2
@@ -8,8 +8,23 @@
|
|||||||
# was validated on hardware — bump pio_espressif32_version /
|
# was validated on hardware — bump pio_espressif32_version /
|
||||||
# platformio_core_version in defaults/main.yml to upgrade (the image tag
|
# platformio_core_version in defaults/main.yml to upgrade (the image tag
|
||||||
# tracks the platform version).
|
# tracks the platform version).
|
||||||
|
#
|
||||||
|
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
|
||||||
|
# only pulls the result. The pins are build args rather than ansible template
|
||||||
|
# vars so CI and ansible build the same bytes -- their values are read from
|
||||||
|
# platformio_core_version / pio_espressif32_version in
|
||||||
|
# roles/gitea-actions/defaults/main.yml by both.
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ARG PLATFORMIO_CORE_VERSION
|
||||||
|
ARG PIO_ESPRESSIF32_VERSION
|
||||||
|
|
||||||
|
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
|
||||||
|
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
|
||||||
|
# --label at build time so neither builder can forget it: without the label the
|
||||||
|
# prune deletes the image every night and the next job re-pulls a gigabyte.
|
||||||
|
LABEL io.debyl.ci-base="true"
|
||||||
|
|
||||||
ENV PLATFORMIO_CORE_DIR=/opt/platformio
|
ENV PLATFORMIO_CORE_DIR=/opt/platformio
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -18,12 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install --no-cache-dir platformio=={{ platformio_core_version }}
|
RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
|
||||||
|
|
||||||
# Seed project mirroring the real projects' platformio.ini so `pio pkg install`
|
# Seed project mirroring the real projects' platformio.ini so `pio pkg install`
|
||||||
# pulls the platform + toolchain + framework packages into the core dir.
|
# pulls the platform + toolchain + framework packages into the core dir.
|
||||||
|
# %s + a quoted argument, not ${...} inside the single-quoted format string:
|
||||||
|
# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined
|
||||||
|
# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally.
|
||||||
RUN mkdir -p /tmp/seed/src \
|
RUN mkdir -p /tmp/seed/src \
|
||||||
&& printf '[env:seed]\nplatform = espressif32@{{ pio_espressif32_version }}\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' > /tmp/seed/platformio.ini \
|
&& printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \
|
||||||
&& pio pkg install -d /tmp/seed \
|
&& pio pkg install -d /tmp/seed \
|
||||||
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
|
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
|
||||||
&& rm -rf /tmp/seed \
|
&& rm -rf /tmp/seed \
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
---
|
---
|
||||||
# CI job images: stage each Containerfile, restore the image from the Gitea
|
# CI job images. .gitea/workflows/ci-images.yml builds files/Containerfile.*
|
||||||
# registry if the nightly prune removed it, rebuild only when the Containerfile
|
# and pushes them to the Gitea registry; this role only logs the runner in and
|
||||||
# changed, then push so the registry always holds what the runner uses.
|
# makes sure the images are present, so a plain deploy never waits on a build.
|
||||||
# See gitea_ci_images in defaults/main.yml.
|
#
|
||||||
- name: create CI image build directory
|
# Set gitea_ci_build_local=true to build and push from here instead -- see the
|
||||||
become: true
|
# comment on that variable in defaults/main.yml.
|
||||||
become_user: "{{ gitea_runner_user }}"
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ gitea_runner_home }}/ci-images"
|
|
||||||
state: directory
|
|
||||||
mode: "0755"
|
|
||||||
tags: gitea-actions
|
|
||||||
|
|
||||||
- name: create gitea-runner registry auth directory
|
- name: create gitea-runner registry auth directory
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_user }}"
|
become_user: "{{ gitea_runner_user }}"
|
||||||
@@ -21,6 +14,9 @@
|
|||||||
mode: "0700"
|
mode: "0700"
|
||||||
tags: gitea-actions
|
tags: gitea-actions
|
||||||
|
|
||||||
|
# Docker-format path on purpose: act_runner reads ~/.docker/config.json to
|
||||||
|
# authenticate the job-image pull it does when a label's image is missing, and
|
||||||
|
# podman falls back to the same file. One login covers both.
|
||||||
- name: log gitea-runner in to the Gitea container registry
|
- name: log gitea-runner in to the Gitea container registry
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_user }}"
|
become_user: "{{ gitea_runner_user }}"
|
||||||
@@ -34,22 +30,7 @@
|
|||||||
no_log: true
|
no_log: true
|
||||||
tags: gitea-actions
|
tags: gitea-actions
|
||||||
|
|
||||||
- name: stage CI Containerfiles
|
- name: pull CI images from the registry
|
||||||
become: true
|
|
||||||
become_user: "{{ gitea_runner_user }}"
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: "{{ item.template }}"
|
|
||||||
dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
|
||||||
mode: "0644"
|
|
||||||
loop: "{{ gitea_ci_images }}"
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item.containerfile }}"
|
|
||||||
register: ci_containerfiles
|
|
||||||
tags: gitea-actions
|
|
||||||
|
|
||||||
# A missing image here is normal (first push, or a new tag): the build below
|
|
||||||
# creates it. Anything already present locally is left untouched.
|
|
||||||
- name: restore CI images from the registry
|
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_user }}"
|
become_user: "{{ gitea_runner_user }}"
|
||||||
containers.podman.podman_image:
|
containers.podman.podman_image:
|
||||||
@@ -60,8 +41,35 @@
|
|||||||
loop: "{{ gitea_ci_images }}"
|
loop: "{{ gitea_ci_images }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.image }}"
|
label: "{{ item.image }}"
|
||||||
when: not (ci_containerfiles.results | selectattr('item.image', 'equalto', item.image) | first).changed
|
when: not (gitea_ci_build_local | bool)
|
||||||
failed_when: false
|
tags: gitea-actions
|
||||||
|
|
||||||
|
# --- local build fallback (gitea_ci_build_local=true) ------------------------
|
||||||
|
# Only reached when seeding a new namespace or when CI cannot build for us.
|
||||||
|
- name: create CI image build directory
|
||||||
|
become: true
|
||||||
|
become_user: "{{ gitea_runner_user }}"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ gitea_runner_home }}/ci-images"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
when: gitea_ci_build_local | bool
|
||||||
|
tags: gitea-actions
|
||||||
|
|
||||||
|
# copy, not template: these are plain Containerfiles that CI builds verbatim.
|
||||||
|
# Versions come in as --build-arg from the same defaults/main.yml the workflow
|
||||||
|
# reads, so neither builder can drift from the other.
|
||||||
|
- name: stage CI Containerfiles
|
||||||
|
become: true
|
||||||
|
become_user: "{{ gitea_runner_user }}"
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ item.containerfile }}"
|
||||||
|
dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ gitea_ci_images }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.containerfile }}"
|
||||||
|
when: gitea_ci_build_local | bool
|
||||||
tags: gitea-actions
|
tags: gitea-actions
|
||||||
|
|
||||||
- name: build and push CI images
|
- name: build and push CI images
|
||||||
@@ -72,9 +80,8 @@
|
|||||||
path: "{{ gitea_runner_home }}/ci-images"
|
path: "{{ gitea_runner_home }}/ci-images"
|
||||||
build:
|
build:
|
||||||
file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
||||||
# Exempts the image from the nightly CI prune (gitea_ci_keep_label).
|
extra_args: "{{ item.build_args }}"
|
||||||
extra_args: "--label {{ gitea_ci_keep_label }}=true"
|
force: true
|
||||||
force: "{{ (ci_containerfiles.results | selectattr('item.image', 'equalto', item.image) | first).changed }}"
|
|
||||||
push: true
|
push: true
|
||||||
auth_file: "{{ gitea_ci_registry_authfile }}"
|
auth_file: "{{ gitea_ci_registry_authfile }}"
|
||||||
environment:
|
environment:
|
||||||
@@ -82,4 +89,5 @@
|
|||||||
loop: "{{ gitea_ci_images }}"
|
loop: "{{ gitea_ci_images }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.image }}"
|
label: "{{ item.image }}"
|
||||||
|
when: gitea_ci_build_local | bool
|
||||||
tags: gitea-actions
|
tags: gitea-actions
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -311,8 +344,13 @@ podman_prune_ci_users:
|
|||||||
- gitea-runner
|
- gitea-runner
|
||||||
- actions-runner
|
- actions-runner
|
||||||
podman_prune_ci_until: 48h
|
podman_prune_ci_until: 48h
|
||||||
# CI base images built by roles/gitea-actions carry this label (gitea_ci_keep_label
|
# The CI base images declare LABEL io.debyl.ci-base="true" in
|
||||||
# there -- keep the two in sync) and are skipped by the CI image prune.
|
# roles/gitea-actions/files/Containerfile.* (and .gitea/workflows/ci-images.yml
|
||||||
|
# verifies it before publishing -- keep all three in sync). Images carrying it
|
||||||
|
# are skipped below: `until` counts from build time and not pull time, so
|
||||||
|
# without the exemption a re-pulled image would be deleted again the next
|
||||||
|
# night, a 7.8 GB ESP-IDF re-download after every idle day. Superseded tags
|
||||||
|
# (e.g. after an esp_idf_version bump) survive too; remove those by hand.
|
||||||
podman_prune_ci_keep_label: io.debyl.ci-base
|
podman_prune_ci_keep_label: io.debyl.ci-base
|
||||||
|
|
||||||
# Daily rather than weekly: CI turns over many images a day, and a week of that
|
# Daily rather than weekly: CI turns over many images a day, and a week of that
|
||||||
|
|||||||
@@ -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"> </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>
|
||||||
|
 · 
|
||||||
|
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
|
||||||
|
 · 
|
||||||
|
<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">
|
||||||
|
© {$year} {$entity}. All rights reserved.<br/>
|
||||||
|
Automated message — 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,237 +1,78 @@
|
|||||||
- 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
|
||||||
description: ''
|
description: TV mode on; once it's dark, dim the living room and turn off the
|
||||||
|
lights that glare on the TV
|
||||||
triggers:
|
triggers:
|
||||||
- type: turned_on
|
- trigger: state
|
||||||
device_id: 18a9bb7a2a32be4371da447767ef50a9
|
entity_id: remote.samsung_tv
|
||||||
entity_id: c05688f2610e27e2d86380e2945ceae5
|
to: 'on'
|
||||||
domain: remote
|
not_from:
|
||||||
trigger: device
|
- unavailable
|
||||||
|
- unknown
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- action: input_boolean.turn_on
|
- action: input_boolean.turn_on
|
||||||
target:
|
target:
|
||||||
entity_id: input_boolean.tv_mode
|
entity_id: input_boolean.tv_mode
|
||||||
|
- if:
|
||||||
|
- condition: or
|
||||||
|
conditions:
|
||||||
|
- condition: sun
|
||||||
|
after: sunset
|
||||||
|
after_offset: "-01:00:00"
|
||||||
|
- condition: sun
|
||||||
|
before: sunrise
|
||||||
|
then:
|
||||||
- action: light.turn_on
|
- action: light.turn_on
|
||||||
metadata: {}
|
|
||||||
data:
|
data:
|
||||||
brightness_pct: 5
|
brightness_pct: 5
|
||||||
target:
|
target:
|
||||||
area_id: living_room
|
area_id: living_room
|
||||||
- type: turn_off
|
- action: light.turn_off
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
target:
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
entity_id:
|
||||||
domain: light
|
- light.kitchen_wall_light
|
||||||
- type: turn_off
|
- light.dining_hall
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
- light.bathroom_hallway
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
|
||||||
domain: light
|
|
||||||
- type: turn_off
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
domain: light
|
|
||||||
mode: single
|
mode: single
|
||||||
- id: new_tv_off
|
- id: new_tv_off
|
||||||
alias: Light - TV Off - Restore
|
alias: Light - TV Off - Restore
|
||||||
description: Restores appropriate lighting level when TV turns off based on time
|
description: Evening (sunset -1h to 23:30) brings the lights back to the
|
||||||
|
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
|
||||||
|
daytime leaves the lights alone
|
||||||
triggers:
|
triggers:
|
||||||
- type: turned_off
|
- trigger: state
|
||||||
device_id: 18a9bb7a2a32be4371da447767ef50a9
|
entity_id: remote.samsung_tv
|
||||||
entity_id: c05688f2610e27e2d86380e2945ceae5
|
to: 'off'
|
||||||
domain: remote
|
not_from:
|
||||||
trigger: device
|
- unavailable
|
||||||
|
- unknown
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- action: input_boolean.turn_off
|
- action: input_boolean.turn_off
|
||||||
@@ -239,409 +80,179 @@
|
|||||||
entity_id: input_boolean.tv_mode
|
entity_id: input_boolean.tv_mode
|
||||||
- choose:
|
- choose:
|
||||||
- conditions:
|
- conditions:
|
||||||
|
- condition: sun
|
||||||
|
after: sunset
|
||||||
|
after_offset: "-01:00:00"
|
||||||
- condition: time
|
- condition: time
|
||||||
after: '22:30:00'
|
before: '23:30:00'
|
||||||
before: '23:45:00'
|
|
||||||
sequence:
|
sequence:
|
||||||
# Late dim levels
|
- action: script.evening_lights_apply
|
||||||
- type: turn_on
|
data:
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
apply: force
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
- action: switch.turn_on
|
||||||
domain: light
|
target:
|
||||||
brightness_pct: 1
|
entity_id: switch.desk_lamp
|
||||||
- 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
|
|
||||||
- conditions:
|
- conditions:
|
||||||
|
- condition: or
|
||||||
|
conditions:
|
||||||
- condition: time
|
- condition: time
|
||||||
after: '21:30:00'
|
after: '23:30:00'
|
||||||
before: '22:30:00'
|
- condition: sun
|
||||||
|
before: sunrise
|
||||||
sequence:
|
sequence:
|
||||||
# Mid dim levels
|
- action: light.turn_off
|
||||||
- type: turn_on
|
target:
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
area_id: living_room
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
- action: switch.turn_off
|
||||||
domain: light
|
target:
|
||||||
brightness_pct: 25
|
entity_id: switch.desk_lamp
|
||||||
- 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
|
|
||||||
- conditions:
|
|
||||||
- condition: time
|
|
||||||
after: '21:00:00'
|
|
||||||
before: '21:30:00'
|
|
||||||
sequence:
|
|
||||||
# Early dim levels
|
|
||||||
- type: turn_on
|
|
||||||
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
|
|
||||||
default:
|
|
||||||
# Full brightness (before 21:00 after sunset)
|
|
||||||
- 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
|
mode: single
|
||||||
- id: 'sunset_lights_on'
|
- id: driveway_lights_on
|
||||||
alias: Lights - Sunset On
|
alias: Driveway String Lights On
|
||||||
description: Turn on lights 1 hour before sunset
|
description: On 1 hour before sunset whether or not the TV is on. Also catches
|
||||||
|
up if Home Assistant restarts before the 23:00 off
|
||||||
triggers:
|
triggers:
|
||||||
- trigger: sun
|
- trigger: sun
|
||||||
event: sunset
|
event: sunset
|
||||||
offset: "-01:00:00"
|
offset: "-01:00:00"
|
||||||
|
id: sunset
|
||||||
|
- trigger: homeassistant
|
||||||
|
event: start
|
||||||
conditions:
|
conditions:
|
||||||
- condition: state
|
- condition: state
|
||||||
entity_id: input_boolean.tv_mode
|
entity_id: switch.driveway_string_lights
|
||||||
state: 'off'
|
state: 'off'
|
||||||
actions:
|
- condition: or
|
||||||
- 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: 'evening_dim_2100'
|
|
||||||
alias: Lights - Evening Dim (21:00)
|
|
||||||
description: Dim lights at 21:00 - only affects lights that are ON
|
|
||||||
triggers:
|
|
||||||
- trigger: time
|
|
||||||
at: "21:00:00"
|
|
||||||
conditions:
|
conditions:
|
||||||
- condition: state
|
- condition: trigger
|
||||||
entity_id: input_boolean.tv_mode
|
id: sunset
|
||||||
state: 'off'
|
- condition: and
|
||||||
actions:
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
domain: light
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 50
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
|
||||||
domain: light
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 50
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
domain: light
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 50
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
domain: light
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 50
|
|
||||||
mode: single
|
|
||||||
- id: 'mid_dim_2130'
|
|
||||||
alias: Lights - Mid Dim (21:30)
|
|
||||||
description: Dim lights at 21:30 - only affects lights that are ON
|
|
||||||
triggers:
|
|
||||||
- trigger: time
|
|
||||||
at: "21:30:00"
|
|
||||||
conditions:
|
conditions:
|
||||||
- condition: state
|
- condition: sun
|
||||||
entity_id: input_boolean.tv_mode
|
after: sunset
|
||||||
state: 'off'
|
after_offset: "-01:00:00"
|
||||||
|
- condition: time
|
||||||
|
before: '23:00:00'
|
||||||
actions:
|
actions:
|
||||||
- if:
|
- action: switch.turn_on
|
||||||
- condition: device
|
target:
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
entity_id: switch.driveway_string_lights
|
||||||
domain: light
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 25
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
domain: light
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 25
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
|
||||||
domain: light
|
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 50
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
domain: light
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 25
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
domain: light
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 25
|
|
||||||
mode: single
|
mode: single
|
||||||
- id: 'late_dim_2230'
|
- id: 'sunset_lights_on'
|
||||||
alias: Lights - Late Dim (22:30)
|
alias: Lights - Sunset On
|
||||||
description: Dim lights at 22:30 - only affects lights that are ON
|
description: Turn on lights 1 hour before sunset. If the TV is on, the living
|
||||||
|
room gets the TV glow and the lights that glare on the TV stay off
|
||||||
triggers:
|
triggers:
|
||||||
- trigger: time
|
- trigger: sun
|
||||||
at: "22:30:00"
|
event: sunset
|
||||||
conditions:
|
offset: "-01:00:00"
|
||||||
- condition: state
|
|
||||||
entity_id: input_boolean.tv_mode
|
|
||||||
state: 'off'
|
|
||||||
actions:
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
|
||||||
domain: light
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 1
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
domain: light
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 1
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
|
||||||
domain: light
|
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 25
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
domain: light
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 10
|
|
||||||
- if:
|
|
||||||
- condition: device
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
domain: light
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
type: is_on
|
|
||||||
then:
|
|
||||||
- type: turn_on
|
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
|
||||||
domain: light
|
|
||||||
brightness_pct: 10
|
|
||||||
mode: single
|
|
||||||
- id: 'lights_out_2345'
|
|
||||||
alias: Lights - Out (23:45)
|
|
||||||
description: Turn off all lights at 23:45
|
|
||||||
triggers:
|
|
||||||
- trigger: time
|
|
||||||
at: "23:45:00"
|
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- type: turn_off
|
- action: script.evening_lights_apply
|
||||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
data:
|
||||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
apply: force
|
||||||
domain: light
|
- if:
|
||||||
- type: turn_off
|
- condition: state
|
||||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
entity_id: input_boolean.tv_mode
|
||||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
state: 'on'
|
||||||
domain: light
|
then:
|
||||||
- type: turn_off
|
- action: light.turn_on
|
||||||
device_id: 03a12d2360d9954aed19c2449070725a
|
data:
|
||||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
brightness_pct: 5
|
||||||
domain: light
|
target:
|
||||||
- type: turn_off
|
area_id: living_room
|
||||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
else:
|
||||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
- action: switch.turn_on
|
||||||
domain: light
|
target:
|
||||||
- type: turn_off
|
entity_id: switch.desk_lamp
|
||||||
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
|
mode: single
|
||||||
entity_id: a30b2da3cd80a5b4c927e1608b91eb65
|
- id: evening_dim_ramp
|
||||||
domain: light
|
alias: Lights - Evening Dim Ramp
|
||||||
- type: turn_off
|
description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on
|
||||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
toward the schedule in script.evening_lights_apply
|
||||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
triggers:
|
||||||
domain: light
|
- trigger: time_pattern
|
||||||
|
minutes: /5
|
||||||
|
conditions:
|
||||||
|
- condition: time
|
||||||
|
after: '20:30:00'
|
||||||
|
before: '23:30:00'
|
||||||
|
actions:
|
||||||
|
- action: script.evening_lights_apply
|
||||||
|
data:
|
||||||
|
apply: ramp
|
||||||
|
mode: single
|
||||||
|
- id: 'lights_out_2345'
|
||||||
|
alias: Lights - Out (23:30)
|
||||||
|
description: Turn off all lights at 23:30. While the TV is on the living room
|
||||||
|
is left as it is
|
||||||
|
triggers:
|
||||||
|
- trigger: time
|
||||||
|
at: "23:30:00"
|
||||||
|
conditions: []
|
||||||
|
actions:
|
||||||
|
- action: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id:
|
||||||
|
- light.kitchen_lights
|
||||||
|
- light.kitchen_wall_light
|
||||||
|
- light.dining_hall
|
||||||
|
- light.dining_room
|
||||||
|
- light.bathroom_hallway
|
||||||
|
- if:
|
||||||
|
- condition: state
|
||||||
|
entity_id: input_boolean.tv_mode
|
||||||
|
state: 'off'
|
||||||
|
then:
|
||||||
|
- action: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
- action: switch.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: switch.desk_lamp
|
||||||
|
mode: single
|
||||||
|
- id: lights_sweep_0100
|
||||||
|
alias: Lights - Sweep (01:00)
|
||||||
|
description: Catch anything turned back on after lights-out. While the TV is
|
||||||
|
on the living room is left as it is
|
||||||
|
triggers:
|
||||||
|
- trigger: time
|
||||||
|
at: "01:00:00"
|
||||||
|
conditions: []
|
||||||
|
actions:
|
||||||
|
- action: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id:
|
||||||
|
- light.kitchen_lights
|
||||||
|
- light.kitchen_wall_light
|
||||||
|
- light.dining_hall
|
||||||
|
- light.dining_room
|
||||||
|
- light.bathroom_hallway
|
||||||
|
- action: switch.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: switch.driveway_string_lights
|
||||||
|
- if:
|
||||||
|
- condition: state
|
||||||
|
entity_id: input_boolean.tv_mode
|
||||||
|
state: 'off'
|
||||||
|
then:
|
||||||
|
- action: light.turn_off
|
||||||
|
target:
|
||||||
|
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
|
||||||
description: ''
|
description: ''
|
||||||
triggers:
|
triggers:
|
||||||
- type: turned_on
|
- trigger: state
|
||||||
device_id: afb9734fe9b187ab6881a64d24e1c2f5
|
entity_id: switch.bedroom_light
|
||||||
entity_id: 27efa149b9ebb388e7c21ba89e671b42
|
to: 'on'
|
||||||
domain: switch
|
not_from:
|
||||||
trigger: device
|
- unavailable
|
||||||
|
- unknown
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- action: light.turn_on
|
- action: light.turn_on
|
||||||
@@ -655,11 +266,12 @@
|
|||||||
alias: Bedroom Off
|
alias: Bedroom Off
|
||||||
description: ''
|
description: ''
|
||||||
triggers:
|
triggers:
|
||||||
- type: turned_off
|
- trigger: state
|
||||||
device_id: afb9734fe9b187ab6881a64d24e1c2f5
|
entity_id: switch.bedroom_light
|
||||||
entity_id: 27efa149b9ebb388e7c21ba89e671b42
|
to: 'off'
|
||||||
domain: switch
|
not_from:
|
||||||
trigger: device
|
- unavailable
|
||||||
|
- unknown
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- action: light.turn_off
|
- action: light.turn_off
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ homeassistant:
|
|||||||
media: /share
|
media: /share
|
||||||
|
|
||||||
automation: !include automations.yaml
|
automation: !include automations.yaml
|
||||||
|
script: !include scripts.yaml
|
||||||
|
|
||||||
input_boolean:
|
input_boolean:
|
||||||
tv_mode:
|
tv_mode:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
evening_lights_apply:
|
||||||
|
alias: Evening Lights - Apply Schedule
|
||||||
|
description: >-
|
||||||
|
Sets each evening light to its scheduled brightness for the current time,
|
||||||
|
blending linearly between the points in `schedule`. apply=force turns the
|
||||||
|
lights on (sunset, TV off); apply=ramp only eases lights that are already
|
||||||
|
on, and leaves alone any light someone has changed by hand. While TV mode
|
||||||
|
is on the living room and the lights that glare on the TV are left alone.
|
||||||
|
mode: queued
|
||||||
|
fields:
|
||||||
|
apply:
|
||||||
|
description: "force: turn lights on at the target. ramp: only adjust lights that are already on."
|
||||||
|
example: ramp
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- force
|
||||||
|
- ramp
|
||||||
|
variables:
|
||||||
|
# [minute of day, brightness %] - 1230 = 20:30, 1260 = 21:00,
|
||||||
|
# 1290 = 21:30, 1350 = 22:30. Before the first point a light sits at the
|
||||||
|
# first value; after the last it holds the last value until lights-out.
|
||||||
|
schedule:
|
||||||
|
light.kitchen_lights: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
|
||||||
|
light.kitchen_wall_light: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
|
||||||
|
light.bathroom_hallway: [[1230, 75], [1260, 50], [1290, 25], [1350, 10]]
|
||||||
|
light.living_room: [[1230, 100], [1260, 50], [1290, 25], [1350, 10]]
|
||||||
|
light.dining_hall: [[1290, 25], [1350, 15]]
|
||||||
|
tv_mode_lights:
|
||||||
|
- light.living_room
|
||||||
|
- light.kitchen_wall_light
|
||||||
|
- light.dining_hall
|
||||||
|
- light.bathroom_hallway
|
||||||
|
apply_mode: "{{ apply | default('ramp') }}"
|
||||||
|
sequence:
|
||||||
|
- repeat:
|
||||||
|
for_each: "{{ schedule.keys() | list }}"
|
||||||
|
sequence:
|
||||||
|
- variables:
|
||||||
|
light: "{{ repeat.item }}"
|
||||||
|
# [target now, target 5 minutes ago - what the last ramp tick set]
|
||||||
|
levels: >-
|
||||||
|
{%- macro at(pts, t) -%}
|
||||||
|
{%- if t <= pts[0][0] -%}{{ pts[0][1] }}
|
||||||
|
{%- elif t >= pts[-1][0] -%}{{ pts[-1][1] }}
|
||||||
|
{%- else -%}
|
||||||
|
{%- for i in range(pts | length - 1) if pts[i][0] <= t < pts[i + 1][0] -%}
|
||||||
|
{{ (pts[i][1] + (pts[i + 1][1] - pts[i][1]) * (t - pts[i][0]) / (pts[i + 1][0] - pts[i][0])) | round(0) | int }}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- set t = now().hour * 60 + now().minute -%}
|
||||||
|
{{ [at(schedule[repeat.item], t) | int, at(schedule[repeat.item], t - 5) | int] }}
|
||||||
|
current: "{{ ((state_attr(repeat.item, 'brightness') or 0) / 2.55) | round(0) | int }}"
|
||||||
|
skip: "{{ is_state('input_boolean.tv_mode', 'on') and repeat.item in tv_mode_lights }}"
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ not skip and apply_mode == 'force' }}"
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: "{{ light }}"
|
||||||
|
data:
|
||||||
|
brightness_pct: "{{ levels[0] }}"
|
||||||
|
transition: 2
|
||||||
|
# A light more than 10 points off the last tick was set by hand; the
|
||||||
|
# biggest scheduled change in 5 minutes is ~8
|
||||||
|
- conditions: >-
|
||||||
|
{{ not skip and apply_mode == 'ramp' and is_state(light, 'on')
|
||||||
|
and (current - levels[1]) | abs <= 10 and current != levels[0] }}
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: "{{ light }}"
|
||||||
|
data:
|
||||||
|
brightness_pct: "{{ levels[0] }}"
|
||||||
|
transition: 60
|
||||||
@@ -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"
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
loop:
|
loop:
|
||||||
- configuration.yaml
|
- configuration.yaml
|
||||||
- automations.yaml
|
- automations.yaml
|
||||||
|
- scripts.yaml
|
||||||
|
|
||||||
- name: flush handlers
|
- name: flush handlers
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
- import_tasks: containers/home/hass.yml
|
- import_tasks: containers/home/hass.yml
|
||||||
vars:
|
vars:
|
||||||
image: ghcr.io/home-assistant/home-assistant:2026.8.3
|
image: ghcr.io/home-assistant/home-assistant:2026.9.3
|
||||||
tags: hass
|
tags: hass
|
||||||
|
|
||||||
- import_tasks: containers/home/partsy.yml
|
- import_tasks: containers/home/partsy.yml
|
||||||
@@ -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:20260914.2149
|
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:20260914.2149
|
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,14 +139,14 @@
|
|||||||
|
|
||||||
- import_tasks: containers/home/gregtime.yml
|
- import_tasks: containers/home/gregtime.yml
|
||||||
vars:
|
vars:
|
||||||
image: localhost/greg-time-bot:3.17.3
|
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
|
||||||
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
|
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
|
||||||
- import_tasks: containers/home/rsvp.yml
|
- import_tasks: containers/home/rsvp.yml
|
||||||
vars:
|
vars:
|
||||||
image: localhost/rsvpd:1.0.4
|
image: localhost/rsvpd:1.0.7
|
||||||
tags: rsvp
|
tags: rsvp
|
||||||
|
|
||||||
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
|
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user