From 53a84cd2f0314ec8ec3e42f8f5b9c6ce8d30a373 Mon Sep 17 00:00:00 2001 From: hcornet Date: Fri, 18 Sep 2026 15:14:01 +0200 Subject: [PATCH] new config --- .env.example | 34 ++ .gitea/scripts/compose-ci-rewrite.py | 447 +++++++++++++++++++++++++ .gitea/scripts/compose-ci.sh | 478 +++++++++++++++++++++++++++ .gitea/workflows/ci-develop.yml | 70 ---- .gitea/workflows/ci-main.yml | 51 --- .gitea/workflows/compose-ci.yml | 63 ++++ .gitignore | 4 + config.yaml | 20 +- docker-compose.yml | 78 ++++- 9 files changed, 1098 insertions(+), 147 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/scripts/compose-ci-rewrite.py create mode 100644 .gitea/scripts/compose-ci.sh delete mode 100644 .gitea/workflows/ci-develop.yml delete mode 100644 .gitea/workflows/ci-main.yml create mode 100644 .gitea/workflows/compose-ci.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9d0a9b --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ + +# Postgre Variables +POSTGRES_IMAGE_TAG=postgres:15.6-alpine +POSTGRE_DB_NAME=gitea_db +POSTGRE_DB_USER=gitea_user +POSTGRE_DB_PASSWORD=P@ssword!Here!123456 +POSTGRE_DB_DATA=/var/lib/postgresql/data/gitea + +# Gitea Variables +GITEA_IMAGE_TAG=gitea/gitea:latest +GITEA_ADMIN_USERNAME=giteaadmin +GITEA_ADMIN_PASSWORD=P@ssword!Here!123456 +GITEA_ADMIN_EMAIL=admin@tips-of-mine.fr +GITEA_NOREPLY_EMAIL=noreply@tips-of-mine.fr +GITEA_URL=https://gitea.tips-of-mine.com/ +GITEA_HOSTNAME=gitea.tips-of-mine.com +GITEA_SSH_PORT=22 +GITEA_HTTP_PORT=3000 +GITEA_SSH_LISTEN_PORT=22 + + +# Adminer Variables +ADMINER_IMAGE_TAG=adminer:latest + +# Backup Variables +BACKUP_INIT_SLEEP=30m +BACKUP_INTERVAL=24h +POSTGRES_BACKUP_PRUNE_DAYS=7 +DATA_BACKUP_PRUNE_DAYS=7 +POSTGRES_BACKUPS_PATH=/srv/gitea-postgres/backups +DATA_BACKUPS_PATH=/srv/gitea-application-data/backups +DATA_PATH=/bitnami/gitea +POSTGRES_BACKUP_NAME=gitea-postgres-backup +DATA_BACKUP_NAME=gitea-application-data-backup \ No newline at end of file diff --git a/.gitea/scripts/compose-ci-rewrite.py b/.gitea/scripts/compose-ci-rewrite.py new file mode 100644 index 0000000..0d08672 --- /dev/null +++ b/.gitea/scripts/compose-ci-rewrite.py @@ -0,0 +1,447 @@ +"""Réécrit une configuration Compose (sortie de `docker compose config --format json`) +pour qu'elle puisse tourner en CI sur le même démon Docker que la production, +sans jamais la toucher. + +Exécuté dans un conteneur utilitaire lancé par compose-ci.sh : + - stdin : configuration Compose résolue (JSON, contient des secrets : jamais affichée) + - stdout : ligne 1 = nombre de services retenus + ligne 2 = configuration réécrite (JSON sur une ligne) + lignes suivantes = valeurs sensibles à masquer dans les journaux (base64) + - stderr : rapport lisible (aucune valeur sensible) + - /ci : copie du dépôt sur l'hôte (répertoire CI_HOST_DIR vu depuis ce conteneur) +""" + +import base64 +import json +import os +import posixpath +import re +import sys + +LOCAL_DIR = "/ci" + +PROJECT_RE = re.compile(r"^ci-[a-z0-9]([a-z0-9-]*[a-z0-9])?-[0-9]+-[0-9]+$") + +# Limites appliquées aux services qui n'en déclarent pas (identiques pour tous les dépôts). +MEM_LIMIT = "1g" +CPU_LIMIT = 1.0 +PIDS_LIMIT = 2048 + +# Labels lus par des composants de production (routage, découverte, mises à jour). +DROPPED_LABEL_PREFIXES = ( + "traefik.", + "homepage.", + "com.centurylinklabs.watchtower.", + "sablier.", +) +# Cible du test HTTP, déduite des labels Traefik avant leur suppression. +HTTP_TARGET_LABEL = "tips-of-mine.ci.http" +HTTP_PORT_RE = re.compile(r"^traefik\.http\.services\.([^.]+)\.loadbalancer\.server\.port$") +HTTP_SCHEME_RE = re.compile(r"^traefik\.http\.services\.([^.]+)\.loadbalancer\.server\.scheme$") +CI_LABELS = { + "traefik.enable": "false", + "com.centurylinklabs.watchtower.enable": "false", + "tips-of-mine.ci": "true", +} + +# Capacités non isolées par les namespaces : elles agissent sur l'hôte. +DANGEROUS_CAPS = { + "ALL", "SYS_ADMIN", "SYS_MODULE", "SYS_RAWIO", "SYS_TIME", "SYS_BOOT", + "SYSLOG", "DAC_READ_SEARCH", "MAC_ADMIN", "MAC_OVERRIDE", "BPF", "PERFMON", +} +HOST_NAMESPACE_KEYS = ("pid", "ipc", "uts", "userns_mode", "cgroup") + +# Répertoires hôtes qui contiennent (ou sont parents de) sockets de démons. +SOCKET_PARENTS = {"/", "/run", "/var", "/var/run"} + +SENSITIVE_NAME_RE = re.compile( + r"PASS|PWD|SECRET|TOKEN|KEY|CREDENTIAL|PRIVATE|AUTH|DSN|URI|URL|SALT|COOKIE", + re.IGNORECASE, +) +MASK_MIN_LENGTH = 6 +OOM_SCORE_ADJ = 500 + + +def fail(message): + print(f"ERREUR : {message}", file=sys.stderr) + sys.exit(1) + + +def report(message): + print(message, file=sys.stderr) + + +def read_settings(): + project = os.environ.get("CI_PROJECT", "") + workspace = os.environ.get("CI_WORKSPACE", "") + host_dir = os.environ.get("CI_HOST_DIR", "") + + if not PROJECT_RE.match(project): + fail(f"nom de projet refusé : {project!r}") + if not workspace.startswith("/"): + fail("CI_WORKSPACE doit être un chemin absolu") + if host_dir != f"/var/tmp/gitea-ci/{project}": + fail(f"répertoire hôte refusé : {host_dir!r}") + + return { + "project": project, + "workspace": posixpath.normpath(workspace), + "host_dir": host_dir, + "mem": MEM_LIMIT, + "cpus": CPU_LIMIT, + "pids": PIDS_LIMIT, + } + + +def is_under(path, root): + path = posixpath.normpath(path) + return path == root or path.startswith(root + "/") + + +def relative_to_workspace(path, settings): + return posixpath.relpath(posixpath.normpath(path), settings["workspace"]) + + +def host_path_problem(path, read_only): + """Raison d'exclusion d'un montage hôte situé hors du dépôt, ou None.""" + path = posixpath.normpath(path) + if path.endswith(".sock") or "docker.sock" in path: + return "socket de l'hôte" + if path in SOCKET_PARENTS or path.startswith(("/run/", "/var/run/")): + return "répertoire de sockets de l'hôte" + if path == "/dev" or path.startswith("/dev/"): + return "périphérique de l'hôte" + if not read_only: + return "montage de l'hôte en écriture" + return None + + +def copied_path_problem(path, settings): + """Refuse un chemin du dépôt qui sort de la copie via un lien symbolique.""" + rel = relative_to_workspace(path, settings) + local = posixpath.normpath(posixpath.join(LOCAL_DIR, rel)) + if not is_under(os.path.realpath(local), LOCAL_DIR): + return f"lien symbolique sortant du dépôt ({rel})" + return None + + +def file_resource_problem(resource, settings): + """Contrôle un secret ou une config de premier niveau déclaré par fichier.""" + source = (resource or {}).get("file") + if not source: + return None + if is_under(source, settings["workspace"]): + return copied_path_problem(source, settings) + return host_path_problem(source, read_only=True) + + +def service_problems(svc, settings, unsafe_secrets, unsafe_configs): + reasons = [] + if svc.get("privileged"): + reasons.append("privileged") + for key in HOST_NAMESPACE_KEYS: + if svc.get(key) == "host": + reasons.append(f"{key}: host") + network_mode = svc.get("network_mode") or "" + if network_mode == "host" or network_mode.startswith("container:"): + reasons.append(f"network_mode: {network_mode}") + + reservations = ((svc.get("deploy") or {}).get("resources") or {}).get("reservations") or {} + if svc.get("devices") or svc.get("device_cgroup_rules") or svc.get("gpus") or reservations.get("devices"): + reasons.append("accès à des périphériques") + + caps = {str(cap).upper().removeprefix("CAP_") for cap in svc.get("cap_add") or []} + if caps & DANGEROUS_CAPS: + reasons.append("cap_add " + ",".join(sorted(caps & DANGEROUS_CAPS))) + + for entry in svc.get("volumes_from") or []: + if str(entry).startswith("container:"): + reasons.append("volumes_from vers un conteneur externe") + + for volume in svc.get("volumes") or []: + if volume.get("type") != "bind": + continue + source = volume.get("source") or "" + if is_under(source, settings["workspace"]): + problem = copied_path_problem(source, settings) + else: + problem = host_path_problem(source, bool(volume.get("read_only"))) + if problem: + problem = f"{problem} ({source})" + if problem: + reasons.append(problem) + + for ref in svc.get("secrets") or []: + if ref.get("source") in unsafe_secrets: + reasons.append(f"secret {ref.get('source')} non isolable") + for ref in svc.get("configs") or []: + if ref.get("source") in unsafe_configs: + reasons.append(f"config {ref.get('source')} non isolable") + return reasons + + +def dependency_targets(svc): + """Services dont celui-ci ne peut pas se passer (réseau ou volumes partagés).""" + targets = [] + network_mode = svc.get("network_mode") or "" + if network_mode.startswith("service:"): + targets.append(network_mode.removeprefix("service:")) + for entry in svc.get("volumes_from") or []: + entry = str(entry) + if not entry.startswith("container:"): + targets.append(entry.removeprefix("service:").split(":")[0]) + return targets + + +def select_services(cfg, settings): + services = cfg.get("services") or {} + unsafe_secrets = { + name for name, res in (cfg.get("secrets") or {}).items() + if file_resource_problem(res, settings) + } + unsafe_configs = { + name for name, res in (cfg.get("configs") or {}).items() + if file_resource_problem(res, settings) + } + + excluded = {} + for name, svc in services.items(): + reasons = service_problems(svc, settings, unsafe_secrets, unsafe_configs) + if reasons: + excluded[name] = reasons + + changed = True + while changed: + changed = False + for name, svc in services.items(): + if name in excluded: + continue + missing = sorted({t for t in dependency_targets(svc) if t in excluded}) + if missing: + excluded[name] = [f"dépend du service exclu {', '.join(missing)}"] + changed = True + return excluded + + +def collect_masks(services): + masks = set() + for svc in services.values(): + environment = svc.get("environment") or {} + if isinstance(environment, list): + environment = dict(item.split("=", 1) for item in environment if "=" in item) + for key, value in environment.items(): + if value is None or not SENSITIVE_NAME_RE.search(key): + continue + value = str(value) + # `compose config` échappe les $ en $$ ; le conteneur voit la forme simple. + for variant in {value, value.replace("$$", "$")}: + if len(variant) >= MASK_MIN_LENGTH: + masks.add(variant) + return masks + + +def service_owner(svc): + """uid/gid numériques du `user:` du service, ou None s'ils ne sont pas numériques.""" + user = str(svc.get("user") or "") + uid, _, gid = user.partition(":") + if not uid.isdigit() or (gid and not gid.isdigit()): + return None + return int(uid), int(gid) if gid else int(uid) + + +def chown_tree(local, owner): + """Attribue une copie du dépôt à l'utilisateur du service, sans suivre les liens.""" + uid, gid = owner + os.lchown(local, uid, gid) + if os.path.isdir(local) and not os.path.islink(local): + for root, dirs, files in os.walk(local, followlinks=False): + for entry in dirs + files: + os.lchown(posixpath.join(root, entry), uid, gid) + + +def relocate_source(source, settings, create_missing, owner=None): + rel = relative_to_workspace(source, settings) + local = posixpath.normpath(posixpath.join(LOCAL_DIR, rel)) + if create_missing and not os.path.lexists(local): + os.makedirs(local, exist_ok=True) + os.chmod(local, 0o777) + if owner and rel != ".": + chown_tree(local, owner) + if rel == ".": + return settings["host_dir"] + return posixpath.join(settings["host_dir"], rel) + + +def http_target(labels): + """Cibles HTTP d'un service exposé par Traefik : "http:8200,https:8443", "auto" ou None.""" + if not any(key.startswith("traefik.http.") for key in labels): + return None + if str(labels.get("traefik.enable", "")).strip().lower() == "false": + return None + ports, schemes = {}, {} + for key, value in labels.items(): + match = HTTP_PORT_RE.match(key) + if match: + ports[match.group(1)] = str(value).strip() + match = HTTP_SCHEME_RE.match(key) + if match: + schemes[match.group(1)] = str(value).strip().lower() + targets = set() + for name, port in ports.items(): + if port.isdigit(): + scheme = "https" if schemes.get(name) == "https" else "http" + targets.add(f"{scheme}:{port}") + return ",".join(sorted(targets)) if targets else "auto" + + +def rewrite_labels(labels, project): + if isinstance(labels, list): + labels = dict(item.split("=", 1) if "=" in item else (item, "") for item in labels) + labels = labels or {} + target = http_target(labels) + kept = {k: v for k, v in labels.items() if not k.startswith(DROPPED_LABEL_PREFIXES)} + kept.update(CI_LABELS) + kept["tips-of-mine.ci.project"] = project + if target: + kept[HTTP_TARGET_LABEL] = target + return kept + + +def apply_limits(svc, settings): + deploy = svc.get("deploy") or {} + deploy.pop("restart_policy", None) + limits = (deploy.get("resources") or {}).get("limits") + + if limits is not None: + # Compose refuse deux valeurs distinctes (ex. cpus et deploy...limits.cpus) : + # on complète le bloc déjà déclaré par le service. + if not svc.get("mem_limit") and not limits.get("memory"): + limits["memory"] = settings["mem"] + if not svc.get("cpus") and not limits.get("cpus"): + limits["cpus"] = str(settings["cpus"]) + if not svc.get("pids_limit") and not limits.get("pids"): + limits["pids"] = settings["pids"] + else: + svc.setdefault("mem_limit", settings["mem"]) + svc.setdefault("cpus", settings["cpus"]) + svc.setdefault("pids_limit", settings["pids"]) + + # Pas de swap pour la CI : le swap de l'hôte sert à la production. + memory = svc.get("mem_limit") or (limits or {}).get("memory") + if memory and not svc.get("memswap_limit"): + svc["memswap_limit"] = memory + svc["oom_score_adj"] = max(int(svc.get("oom_score_adj") or 0), OOM_SCORE_ADJ) + + if deploy: + svc["deploy"] = deploy + else: + svc.pop("deploy", None) + + +def rewrite_service(name, svc, excluded, settings): + for key in ("container_name", "ports", "logging", "mac_address", "external_links"): + svc.pop(key, None) + if "build" in svc: + # Image nommée - : aucun tag de production n'est écrasé. + svc.pop("image", None) + elif svc.get("pull_policy") not in (None, "missing", "if_not_present", "never"): + svc["pull_policy"] = "missing" + svc["restart"] = "no" + svc["labels"] = rewrite_labels(svc.get("labels"), settings["project"]) + apply_limits(svc, settings) + + networks = svc.get("networks") + if isinstance(networks, dict): + for attachment in networks.values(): + if isinstance(attachment, dict): + for key in ("ipv4_address", "ipv6_address", "link_local_ips", "mac_address"): + attachment.pop(key, None) + + depends_on = svc.get("depends_on") + if isinstance(depends_on, dict): + for target in [t for t in depends_on if t in excluded]: + report(f" - {name} : dépendance vers {target} retirée (service exclu)") + del depends_on[target] + if not depends_on: + svc.pop("depends_on") + if svc.get("links"): + svc["links"] = [l for l in svc["links"] if str(l).split(":")[0] not in excluded] + if not svc["links"]: + svc.pop("links") + + # Les montages en écriture appartiennent à l'utilisateur du service, comme en + # production : certaines images refusent de démarrer sinon (chown impossible). + owner = service_owner(svc) + relocated = 0 + for volume in svc.get("volumes") or []: + if volume.get("type") == "bind" and is_under(volume.get("source") or "", settings["workspace"]): + writable_owner = None if volume.get("read_only") else owner + volume["source"] = relocate_source( + volume["source"], settings, create_missing=True, owner=writable_owner + ) + relocated += 1 + return relocated + + +def rewrite_top_level(cfg, settings): + project = settings["project"] + for key, network in list((cfg.get("networks") or {}).items()): + network = network or {} + for field in ("external", "driver", "driver_opts", "ipam", "enable_ipv6"): + network.pop(field, None) + network["name"] = f"{project}_{key}" + cfg["networks"][key] = network + + for key, volume in list((cfg.get("volumes") or {}).items()): + volume = volume or {} + for field in ("external", "driver", "driver_opts"): + volume.pop(field, None) + volume["name"] = f"{project}_{key}" + cfg["volumes"][key] = volume + + for section in ("secrets", "configs"): + for resource in (cfg.get(section) or {}).values(): + source = (resource or {}).get("file") + if source and is_under(source, settings["workspace"]): + resource["file"] = relocate_source(source, settings, create_missing=False) + + cfg["name"] = project + + +def main(): + settings = read_settings() + try: + cfg = json.load(sys.stdin) + except json.JSONDecodeError: + fail("configuration Compose illisible") + + services = cfg.get("services") or {} + if not services: + fail("aucun service dans la configuration Compose") + + masks = collect_masks(services) + excluded = select_services(cfg, settings) + for name in excluded: + del services[name] + + report(f"Projet de CI : {settings['project']}") + report(f"Services testés : {', '.join(sorted(services)) or 'aucun'}") + if excluded: + report("Services exclus :") + for name in sorted(excluded): + report(f" - {name} : {'; '.join(excluded[name])}") + + relocated = sum(rewrite_service(n, s, excluded, settings) for n, s in services.items()) + rewrite_top_level(cfg, settings) + cfg["services"] = services + report(f"Montages relocalisés : {relocated}") + + out = sys.stdout + out.write(f"{len(services)}\n") + out.write(json.dumps(cfg, separators=(",", ":")) + "\n") + for value in sorted(masks): + out.write(base64.b64encode(value.encode()).decode() + "\n") + + +if __name__ == "__main__": + main() diff --git a/.gitea/scripts/compose-ci.sh b/.gitea/scripts/compose-ci.sh new file mode 100644 index 0000000..0c4720d --- /dev/null +++ b/.gitea/scripts/compose-ci.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# Vérification d'une stack Docker Compose en CI, sur le démon Docker de l'hôte, +# sans aucune interaction avec les conteneurs, réseaux et volumes de production. +# +# Usage : compose-ci.sh prepare|up|verify|logs|cleanup +# +# Aucune variable ni aucun secret à configurer : les réglages sont fixés ci-dessous +# et identiques pour tous les dépôts. +# Fichier facultatif dans le dépôt : .env.example (utilisé si .env est absent), +# nécessaire seulement si le compose utilise des ${VARIABLE} sans valeur par défaut. +# Un dépôt sans fichier Compose (ex. le dépôt Template) ne teste rien et reste au vert. + +set -Eeuo pipefail + +readonly HOST_ROOT="/var/tmp/gitea-ci" +readonly HELPER_IMAGE="python:3.13-alpine3.22" +readonly CI_MARKER_LABEL="tips-of-mine.ci" +readonly STABILIZATION_SECONDS=15 +readonly POLL_SECONDS=5 +readonly WAIT_TIMEOUT_SECONDS=300 +readonly HTTP_TIMEOUT_SECONDS=300 +readonly HTTP_TARGET_LABEL="tips-of-mine.ci.http" +readonly MIN_AVAILABLE_MB=3072 +readonly LOG_LINES=50 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly WORK_DIR="${RUNNER_TEMP:-/tmp}/compose-ci" +readonly CI_FILE="${WORK_DIR}/compose.ci.json" +readonly COUNT_FILE="${WORK_DIR}/services.count" +readonly MASK_FILE="${WORK_DIR}/mask.b64" +readonly STARTED_FILE="${WORK_DIR}/started.epoch" +readonly SENSITIVE_NAME_RE='PASS|PWD|SECRET|TOKEN|KEY|CREDENTIAL|PRIVATE|AUTH|DSN|URI|URL|SALT|COOKIE' +readonly MASK_MIN_LENGTH=6 + +# Interroge une URL jusqu'à obtenir une réponse HTTP < 500 ou jusqu'à l'échéance. +# Les redirections ne sont pas suivies : une réponse 3xx prouve que l'application répond. +PROBE_PY="$(cat <<'PY' +import os, ssl, sys, time, urllib.error, urllib.request + +url = os.environ["PROBE_URL"] +start = float(os.environ["PROBE_START"]) +deadline = float(os.environ["PROBE_DEADLINE"]) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +opener = urllib.request.build_opener( + NoRedirect, urllib.request.HTTPSHandler(context=ssl._create_unverified_context()) +) +last = "aucune réponse" +while True: + code = None + try: + with opener.open(url, timeout=5) as response: + code = response.status + except urllib.error.HTTPError as error: + code = error.code + except Exception as error: + last = str(getattr(error, "reason", "") or type(error).__name__) + if code is not None: + if code < 500: + print(f"HTTP {code} disponible en {time.time() - start:.0f} s") + sys.exit(0) + last = f"HTTP {code}" + if time.time() >= deadline: + print(f"ÉCHEC ({last}) indisponible après {time.time() - start:.0f} s") + sys.exit(1) + time.sleep(2) +PY +)" +readonly PROBE_PY + +PROJECT="" +SERVICE_COUNT=0 +ENV_FILE="" +ENV_SOURCE="" + +die() { + echo "ERREUR : $*" >&2 + exit 1 +} + +project_name() { + local repo run_id attempt name + repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY absent}" + repo="${repo##*/}" + repo="$(printf '%s' "${repo,,}" | tr -c 'a-z0-9' '-' | tr -s '-')" + repo="${repo#-}" + repo="${repo:0:40}" + repo="${repo%-}" + run_id="${GITHUB_RUN_ID:?GITHUB_RUN_ID absent}" + attempt="${GITHUB_RUN_ATTEMPT:-1}" + name="ci-${repo}-${run_id}-${attempt}" + [[ "$name" =~ ^ci-[a-z0-9]([a-z0-9-]*[a-z0-9])?-[0-9]+-[0-9]+$ ]] \ + || die "nom de projet de CI invalide : ${name}" + printf '%s' "$name" +} + +compose_file() { + local candidate + for candidate in docker-compose.yml docker-compose.yaml compose.yaml compose.yml; do + if [[ -f "${GITHUB_WORKSPACE}/${candidate}" ]]; then + printf '%s' "$candidate" + return + fi + done +} + +resolve_env_file() { + if [[ -f "${GITHUB_WORKSPACE}/.env" ]]; then + ENV_FILE="${GITHUB_WORKSPACE}/.env" + ENV_SOURCE=".env du dépôt" + elif [[ -f "${GITHUB_WORKSPACE}/.env.example" ]]; then + ENV_FILE="${GITHUB_WORKSPACE}/.env.example" + ENV_SOURCE=".env.example du dépôt" + else + ENV_FILE="" + ENV_SOURCE="aucun" + fi +} + +# Ajoute au masquage les valeurs des variables sensibles du fichier d'environnement. +mask_env_file() { + local line name value + [[ -n "$ENV_FILE" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]] || continue + name="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[3]}" + [[ "$name" =~ $SENSITIVE_NAME_RE ]] || continue + value="${value%$'\r'}" + value=${value#[\"\']} + value=${value%[\"\']} + (( ${#value} >= MASK_MIN_LENGTH )) || continue + printf '%s' "$value" | base64 -w0 >> "$MASK_FILE" + printf '\n' >> "$MASK_FILE" + done < "$ENV_FILE" +} + +compose() { + docker compose --ansi never \ + --project-name "$PROJECT" \ + --project-directory "$GITHUB_WORKSPACE" \ + -f "$CI_FILE" "$@" +} + +helper() { + docker run --rm --network none "$@" +} + +helper_on() { + local network="$1" + shift + [[ "$network" == "${PROJECT}_"* ]] || die "réseau hors du projet de CI refusé : ${network}" + docker run --rm --network "$network" "$@" +} + +load_state() { + PROJECT="$(project_name)" + [[ -f "$COUNT_FILE" ]] \ + || die "configuration de CI absente : l'étape de préparation n'a pas abouti" + SERVICE_COUNT="$(<"$COUNT_FILE")" + [[ "$SERVICE_COUNT" =~ ^[0-9]+$ ]] || die "nombre de services illisible" + if (( SERVICE_COUNT > 0 )) && [[ ! -f "$CI_FILE" ]]; then + die "configuration de CI absente : l'étape de préparation n'a pas abouti" + fi +} + +check_memory() { + local min="$MIN_AVAILABLE_MB" available + available="$(awk '/^MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo)" + [[ "$available" =~ ^[0-9]+$ ]] || die "mémoire disponible illisible" + echo "Mémoire disponible sur l'hôte : ${available} Mio (seuil : ${min} Mio)" + (( available >= min )) \ + || die "mémoire insuffisante sur l'hôte : démarrage annulé pour protéger la production" +} + +assert_isolated() { + local key + for key in '"container_name":' '"ports":' '"external":'; do + if grep -qF "$key" "$CI_FILE"; then + die "la configuration de CI contient encore ${key} : arrêt par sécurité" + fi + done +} + +cmd_prepare() { + local file raw errors unset_vars + local -a env_args=() + PROJECT="$(project_name)" + install -d -m 0700 "$WORK_DIR" + file="$(compose_file)" + if [[ -z "$file" ]]; then + echo "Aucun fichier Compose à la racine du dépôt : rien à tester" + echo 0 > "$COUNT_FILE" + return 0 + fi + echo "Fichier Compose : ${file}" + check_memory + + resolve_env_file + echo "Variables d'environnement : ${ENV_SOURCE}" + if [[ -n "$ENV_FILE" ]]; then + env_args=(--env-file "$ENV_FILE") + fi + + echo "Copie du dépôt vers ${HOST_ROOT}/${PROJECT} sur l'hôte" + tar -C "$GITHUB_WORKSPACE" --exclude=./.git -cf - . \ + | helper -i -v "${HOST_ROOT}/${PROJECT}:/dst" "$HELPER_IMAGE" tar -C /dst -xf - + + echo "Réécriture de la configuration Compose" + raw="${WORK_DIR}/rewrite.out" + errors="${WORK_DIR}/config.err" + if ! docker compose --ansi never \ + --project-name "$PROJECT" \ + --project-directory "$GITHUB_WORKSPACE" \ + "${env_args[@]}" \ + -f "${GITHUB_WORKSPACE}/${file}" \ + config --format json 2> "$errors" \ + | helper -i \ + -e CI_PROJECT="$PROJECT" \ + -e CI_WORKSPACE="$GITHUB_WORKSPACE" \ + -e CI_HOST_DIR="${HOST_ROOT}/${PROJECT}" \ + -v "${HOST_ROOT}/${PROJECT}:/ci" \ + "$HELPER_IMAGE" python3 -c "$(<"${SCRIPT_DIR}/compose-ci-rewrite.py")" \ + > "$raw"; then + cat "$errors" >&2 + die "la configuration Compose n'a pas pu être résolue" + fi + + unset_vars="$(sed -nE 's/.*The \\?"([A-Za-z_][A-Za-z0-9_]*)\\?" variable is not set.*/\1/p' "$errors" | sort -u | paste -sd ' ' -)" + if [[ -n "$unset_vars" ]]; then + die "variables non définies (${ENV_SOURCE}) : ${unset_vars}" + fi + if [[ -s "$errors" ]]; then + cat "$errors" >&2 + fi + + sed -n '1p' "$raw" > "$COUNT_FILE" + sed -n '2p' "$raw" > "$CI_FILE" + sed -n '3,$p' "$raw" > "$MASK_FILE" + rm -f "$raw" + chmod 0600 "$CI_FILE" "$MASK_FILE" + mask_env_file + + load_state + assert_isolated + compose config --quiet + echo "Configuration de CI validée (${SERVICE_COUNT} service(s))" +} + +cmd_up() { + load_state + if (( SERVICE_COUNT == 0 )); then + echo "Aucun service testable dans ce dépôt : rien à démarrer" + return 0 + fi + compose up --detach --build --quiet-pull + date +%s > "$STARTED_FILE" +} + +evaluate_state() { + local ids id name status code health verdict + STATE_PENDING=0 + STATE_FAILED=0 + STATE_TABLE="$(printf '%-50s %-11s %-5s %-10s %s' CONTENEUR ETAT CODE SANTE VERDICT)"$'\n' + + mapfile -t ids < <(compose ps --all --quiet | grep -v '^$' || true) + if (( ${#ids[@]} == 0 )); then + STATE_FAILED=1 + STATE_TABLE+="aucun conteneur créé"$'\n' + return + fi + + for id in "${ids[@]}"; do + IFS='|' read -r name status code health < <( + docker inspect --format \ + '{{.Name}}|{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}-{{end}}' \ + "$id" + ) + case "${status}:${health}" in + running:- | running:healthy) + verdict="OK" ;; + running:starting | running:unhealthy | created:* | restarting:*) + verdict="ATTENTE"; STATE_PENDING=1 ;; + exited:*) + if [[ "$code" == "0" ]]; then + verdict="TERMINÉ" + else + verdict="ÉCHEC"; STATE_FAILED=1 + fi ;; + *) + verdict="ÉCHEC"; STATE_FAILED=1 ;; + esac + STATE_TABLE+="$(printf '%-50s %-11s %-5s %-10s %s' "${name#/}" "$status" "$code" "$health" "$verdict")"$'\n' + done +} + +single_exposed_port() { + local id="$1" entry + local -a ports=() + for entry in $(docker inspect --format '{{range $p, $v := .Config.ExposedPorts}}{{$p}} {{end}}' "$id"); do + [[ "$entry" == */tcp ]] && ports+=("${entry%/tcp}") + done + (( ${#ports[@]} == 1 )) && printf '%s' "${ports[0]}" +} + +probe() { + local network="$1" url="$2" started="$3" result status=0 + result="$(helper_on "$network" \ + -e PROBE_URL="$url" \ + -e PROBE_START="$started" \ + -e PROBE_DEADLINE="$(( started + HTTP_TIMEOUT_SECONDS ))" \ + "$HELPER_IMAGE" python3 -c "$PROBE_PY" 2>&1)" || status=$? + printf '%-70s %s\n' "$url" "$result" + return "$status" +} + +http_checks() { + local started ids id name spec networks network port target scheme tested=0 status=0 + local -a targets=() + [[ -f "$STARTED_FILE" ]] || die "heure de démarrage inconnue : l'étape de démarrage n'a pas abouti" + started="$(<"$STARTED_FILE")" + + echo + echo "Test HTTP (délai maximal : ${HTTP_TIMEOUT_SECONDS} s après le démarrage)" + mapfile -t ids < <(compose ps --quiet | grep -v '^$' || true) + for id in "${ids[@]}"; do + IFS='|' read -r name spec networks < <( + docker inspect --format \ + "{{.Name}}|{{index .Config.Labels \"${HTTP_TARGET_LABEL}\"}}|{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}} {{end}}" \ + "$id" + ) + [[ -n "$spec" ]] || continue + name="${name#/}" + network="${networks%% *}" + if [[ -z "$network" ]]; then + echo "${name} : aucun réseau propre, test HTTP ignoré" + continue + fi + if [[ "$spec" == "auto" ]]; then + port="$(single_exposed_port "$id" || true)" + if [[ -z "$port" ]]; then + echo "${name} : port HTTP indéterminable (aucun label de port Traefik, ports exposés multiples ou absents), test HTTP ignoré" + continue + fi + spec="http:${port}" + fi + IFS=',' read -ra targets <<<"$spec" + for target in "${targets[@]}"; do + scheme="${target%%:*}" + port="${target##*:}" + tested=1 + probe "$network" "${scheme}://${name}:${port}/" "$started" || status=1 + done + done + + if (( ! tested )); then + echo "Aucun service exposé par Traefik : test HTTP non applicable" + fi + return "$status" +} + +cmd_verify() { + local timeout deadline stable=0 + load_state + if (( SERVICE_COUNT == 0 )); then + echo "Rien à vérifier" + return 0 + fi + + timeout="$WAIT_TIMEOUT_SECONDS" + deadline=$(( SECONDS + timeout )) + + while :; do + evaluate_state + if (( STATE_FAILED )); then + printf '%s' "$STATE_TABLE" + die "au moins un conteneur est en échec" + fi + if (( ! STATE_PENDING )); then + if (( stable )); then + printf '%s' "$STATE_TABLE" + echo "Tous les conteneurs sont démarrés et stables" + http_checks || die "au moins un service web ne répond pas dans le délai" + return 0 + fi + stable=1 + sleep "$STABILIZATION_SECONDS" + continue + fi + stable=0 + if (( SECONDS >= deadline )); then + printf '%s' "$STATE_TABLE" + die "délai de ${timeout} s dépassé avant que tous les conteneurs soient prêts" + fi + sleep "$POLL_SECONDS" + done +} + +cmd_logs() { + local logs encoded secret + if [[ ! -f "$CI_FILE" ]]; then + echo "Pas de configuration de CI : aucun journal à afficher" + return 0 + fi + load_state + + compose ps --all --format 'table {{.Service}}\t{{.State}}\t{{.Status}}' || true + logs="$(compose logs --no-color --tail "$LOG_LINES" 2>&1 || true)" + + if [[ -s "$MASK_FILE" ]]; then + while IFS= read -r encoded; do + [[ -n "$encoded" ]] || continue + secret="$(printf '%s' "$encoded" | base64 -d)" + [[ -n "$secret" ]] && logs="${logs//"$secret"/***}" + done < "$MASK_FILE" + fi + printf '%s\n' "$logs" +} + +remove_by_label() { + local kind="$1" filter="label=com.docker.compose.project=${PROJECT}" + case "$kind" in + container) docker ps -aq --filter "$filter" | xargs -r docker rm -f >/dev/null ;; + network) docker network ls -q --filter "$filter" | xargs -r docker network rm >/dev/null ;; + volume) docker volume ls -q --filter "$filter" | xargs -r docker volume rm >/dev/null ;; + esac +} + +cmd_cleanup() { + local markers foreign status=0 + PROJECT="$(project_name)" + + markers="$(docker ps -a \ + --filter "label=com.docker.compose.project=${PROJECT}" \ + --format "[{{.Label \"${CI_MARKER_LABEL}\"}}]")" \ + || die "impossible de lister les conteneurs du projet ${PROJECT}" + foreign="$(grep -cvxF '[true]' <<<"$markers" || true)" + [[ -n "$markers" ]] || foreign=0 + if (( foreign > 0 )); then + die "${foreign} conteneur(s) du projet ${PROJECT} sans marqueur de CI : nettoyage annulé" + fi + + if [[ -f "$CI_FILE" ]]; then + compose down --volumes --remove-orphans --rmi local --timeout 30 || status=1 + fi + remove_by_label container || status=1 + remove_by_label network || status=1 + remove_by_label volume || status=1 + + # shellcheck disable=SC2016 # $1 est développé par le sh du conteneur + helper -v "${HOST_ROOT}:/ci-root" "$HELPER_IMAGE" \ + sh -c 'rm -rf -- "/ci-root/$1"' _ "$PROJECT" || status=1 + + if (( status )); then + die "nettoyage incomplet du projet ${PROJECT}" + fi + echo "Projet ${PROJECT} nettoyé" +} + +main() { + [[ -n "${GITHUB_WORKSPACE:-}" ]] || die "GITHUB_WORKSPACE absent" + case "${1:-}" in + prepare) cmd_prepare ;; + up) cmd_up ;; + verify) cmd_verify ;; + logs) cmd_logs ;; + cleanup) cmd_cleanup ;; + *) die "usage : $0 prepare|up|verify|logs|cleanup" ;; + esac +} + +main "$@" diff --git a/.gitea/workflows/ci-develop.yml b/.gitea/workflows/ci-develop.yml deleted file mode 100644 index 65fc63b..0000000 --- a/.gitea/workflows/ci-develop.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Template -name: Deployment Verification - -on: - push: - branches: - - develop - tags: - - "v[0-9]+.[0-9]+.[0-9]" - pull_request: - branches: - - main - workflow_dispatch: - -jobs: - deploy-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - # https://github.com/docker/setup-qemu-action#usage - - name: Set up QEMU - uses: docker/setup-qemu-action@v3.2.0 - - # https://github.com/marketplace/actions/docker-setup-buildx - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3.6.1 - - # https://github.com/docker/build-push-action - - name: Build and push - uses: docker/build-push-action@v6 - - # - - name: Create necessary Docker networks - run: | - docker network create back_network_gitea || true - docker network create traefik_front_network || true - - - name: Start up services using Docker Compose - run: docker compose -f docker-compose.yml up -d - -# - name: Modify /etc/hosts for internal routing -# run: | -# echo "127.0.0.1 gitea.tips-of-mine.com" | sudo tee -a /etc/hosts - - - name: Print Docker Compose services status - run: docker ps - - - name: Wait for the application to be ready via Traefik - run: | - echo "Checking the routing and availability of application via Traefik..." - timeout 5m bash -c 'while ! curl -fsSLk "https://gitea.tips-of-mine.com"; do echo "Waiting for the application to be ready..."; sleep 10; done' - - - name: Inspect Network Configuration - run: | - docker network inspect back_network_gitea - docker network inspect traefik_front_network - - - name: Shutdown Docker Compose services - if: always() - run: docker compose -f docker-compose.yml down - - - name: Cleanup - if: always() - run: | - docker compose --profile setup down - rm -rf /workspace/tips-of-mine/gitea/* \ No newline at end of file diff --git a/.gitea/workflows/ci-main.yml b/.gitea/workflows/ci-main.yml deleted file mode 100644 index bf77843..0000000 --- a/.gitea/workflows/ci-main.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Template -name: Deployment Verification - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - deploy-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Create necessary Docker networks - run: | - docker network create back_network_{ vars.APPLICATION_NAME } || true - docker network create traefik_front_network || true - - - name: Start up services using Docker Compose - run: docker compose -f docker-compose.yml up -d - -# - name: Modify /etc/hosts for internal routing -# run: | -# echo "127.0.0.1 { vars.APPLICATION_URL }" | sudo tee -a /etc/hosts -# echo "127.0.0.1 dashboard.tips-of-mine.com" | sudo tee -a /etc/hosts - - - name: Print Docker Compose services status - run: docker ps - - - name: Wait for the application to be ready via Traefik - run: | - echo "Checking the routing and availability of application via Traefik..." - timeout 5m bash -c 'while ! curl -fsSLk "https://{ vars.APPLICATION_URL }"; do echo "Waiting for the application to be ready..."; sleep 10; done' - - - name: Inspect Network Configuration - run: | - docker network inspect back_network_{ vars.APPLICATION_NAME } - docker network inspect traefik_front_network - - - name: Shutdown Docker Compose services - if: always() - run: docker compose -f docker-compose.yml down \ No newline at end of file diff --git a/.gitea/workflows/compose-ci.yml b/.gitea/workflows/compose-ci.yml new file mode 100644 index 0000000..be794d1 --- /dev/null +++ b/.gitea/workflows/compose-ci.yml @@ -0,0 +1,63 @@ +# Template — vérification isolée d'une stack Docker Compose. +# À copier tel quel dans chaque dépôt, avec .gitea/scripts/ ; aucune variable à configurer. +# Remplace ci-develop.yml et ci-main.yml : les supprimer du dépôt. +name: Compose CI + +on: + push: + branches: + - develop + - main + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + pull_request: + branches: + - main + workflow_dispatch: + +env: + CI_SCRIPT: .gitea/scripts/compose-ci.sh + HELPER_IMAGE: python:3.13-alpine3.22 + SHELLCHECK_IMAGE: koalaman/shellcheck:v0.10.0 + +jobs: + lint-ci-scripts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: ShellCheck du script de CI + run: docker run --rm -i --network none "$SHELLCHECK_IMAGE" - < "$CI_SCRIPT" + + - name: Syntaxe du script de réécriture + run: >- + docker run --rm -i --network none "$HELPER_IMAGE" + python3 -c "import ast, sys; ast.parse(sys.stdin.read())" + < .gitea/scripts/compose-ci-rewrite.py + + compose-verify: + needs: lint-ci-scripts + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Préparer une configuration isolée + run: bash "$CI_SCRIPT" prepare + + - name: Démarrer la stack de CI + run: bash "$CI_SCRIPT" up + + - name: Vérifier l'état des conteneurs + run: bash "$CI_SCRIPT" verify + + - name: Journaux (en cas d'échec) + if: failure() + run: bash "$CI_SCRIPT" logs + + - name: Nettoyer la stack de CI + if: always() + run: bash "$CI_SCRIPT" cleanup diff --git a/.gitignore b/.gitignore index 8c2b884..e6feb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ # Built Visual Studio Code Extensions *.vsix +# Variables d'environnement et secrets +.env +.env.* +!.env.example \ No newline at end of file diff --git a/config.yaml b/config.yaml index 7d098d2..bf00c5d 100644 --- a/config.yaml +++ b/config.yaml @@ -5,13 +5,13 @@ log: # The level of logging, can be trace, debug, info, warn, error, fatal - level: debug + level: info runner: # Where to store the registration result. file: .runner # Execute how many tasks concurrently at the same time. - capacity: 1 + capacity: 2 # Extra environment variables to run jobs. envs: A_TEST_ENV_NAME_1: a_test_env_value_1 @@ -36,9 +36,9 @@ runner: # If it's empty when registering, it will ask for inputting labels. # If it's empty when execute `daemon`, will use labels in `.runner` file. labels: - #- "ubuntu-latest:docker://gitea/runner-images:ubuntu-latest" - #- "ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04" - #- "ubuntu-20.04:docker://gitea/runner-images:ubuntu-20.04" + - "ubuntu-latest:docker://gitea/runner-images:ubuntu-latest" + - "ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04" + - "ubuntu-20.04:docker://gitea/runner-images:ubuntu-20.04" #- "ubuntu-20.04:docker://gnu96/default-image:ubuntu-20-04-rc-01" - "ubuntu-20.04:docker://registry.traefik.me/gitea/default-image:ubuntu-20.04" @@ -47,14 +47,14 @@ cache: enabled: true # The directory to store the cache data. # If it's empty, the cache data will be stored in $HOME/.cache/actcache. - dir: "" + dir: "/root/.cache/act" # The host of the cache server. # It's not for the address to listen, but the address to connect from job containers. # So 0.0.0.0 is a bad choice, leave it empty to detect automatically. - host: "" + host: "10.0.x.x"" # The port of the cache server. # 0 means to use a random available port. - port: 0 + port: 38905 # The external cache server URL. Valid only when enable is true. # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself. # The URL should generally end with "/". @@ -64,7 +64,7 @@ container: # Specifies the network to which the container will connect. # Could be host, bridge or the name of a custom network. # If it's empty, act_runner will create a network automatically. - network: "" + network: "bridge" # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker). privileged: false # And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway). @@ -98,4 +98,4 @@ container: host: # The parent directory of a job's working directory. # If it's empty, $HOME/.cache/act/ will be used. - workdir_parent: + workdir_parent: /data/actions diff --git a/docker-compose.yml b/docker-compose.yml index 0e353da..38394fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ networks: back_network_gitea: driver: bridge attachable: true + monitoring_network: + external: true + name: monitoring_network #### SERVICES services: @@ -47,8 +50,8 @@ services: - GITEA__actions__ENABLED=true - GITEA__metrics__ENABLED=true - GITEA__metrics__ENABLED_ISSUE_BY_REPOSITORY=true - - GITEA__metrics__ENABLED_ISSUE_BY_LABEL=true - - GITEA__service__DISABLE_REGISTRATION=false + - GITEA__metrics__ENABLED_ISSUE_BY_LABEL=true + - GITEA__service__DISABLE_REGISTRATION=true - GITEA__service__REQUIRE_SIGNIN_VIEW=false - GITEA__service__REGISTER_EMAIL_CONFIRM=true - GITEA__service__ENABLE_NOTIFY_MAIL=true @@ -58,31 +61,33 @@ services: - GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION=false - GITEA__service__DEFAULT_ENABLE_TIMETRACKING=true - GITEA__service__NO_REPLY_ADDRESS=${GITEA_NOREPLY_EMAIL} + - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION_API=true - GITEA__RUN_MODE=prod - GITEA__APP_NAME=Gitea restart: always networks: - back_network_gitea - traefik_front_network + - monitoring_network volumes: - ./gitea:/data:rw - ./custom:/app/gitea/custom:rw - ./log:/app/gitea/log:rw - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro -# ports: -# - "3080:3000" -# - "222:222" depends_on: postgres: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://gitea.tips-of-mine.com/"] + test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"] interval: 10s timeout: 5s retries: 3 start_period: 90s + security_opt: + - no-new-privileges labels: + - "com.centurylinklabs.watchtower.enable=true" - "traefik.enable=true" - "traefik.docker.network=traefik_front_network" # HTTP @@ -94,12 +99,25 @@ services: - "traefik.http.routers.gitea-https.entrypoints=https" - "traefik.http.routers.gitea-https.tls=true" - "traefik.http.routers.gitea-https.priority=50" - - "traefik.http.routers.gitea.service=gitea-https-service" + - "traefik.http.routers.gitea-https.service=gitea-https-service" + - "traefik.http.routers.gitea-https.middlewares=gitea-middlewares" + - "traefik.http.routers.gitea-https.observability.accesslogs=true" + - "traefik.http.routers.gitea-https.observability.metrics=true" + - "traefik.http.routers.gitea-https.observability.tracing=true" # SSH - - "traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)" - - "traefik.tcp.routers.gitea-ssh.entrypoints=ssh" - - "traefik.tcp.routers.gitea-ssh.service=gitea-ssh-service" +# - "traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)" +# - "traefik.tcp.routers.gitea-ssh.entrypoints=ssh" +# - "traefik.tcp.routers.gitea-ssh.service=gitea-ssh-service" # Middleware +# - "traefik.http.middlewares.gitea-middlewares.headers.SSLRedirect=true" + - "traefik.http.middlewares.gitea-middlewares.headers.STSSeconds=15552000" +# - "traefik.http.middlewares.gitea-middlewares.headers.customResponseHeaders" +# - "traefik.http.middlewares.gitea-middlewares.headers.browserXSSFilter=true" +# - "traefik.http.middlewares.gitea-middlewares.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.gitea-middlewares.headers.forceSTSHeader=true" +# - "traefik.http.middlewares.gitea-middlewares.headers.SSLHost=tips-of-mine.com" + - "traefik.http.middlewares.gitea-middlewares.headers.STSIncludeSubdomains=true" + - "traefik.http.middlewares.gitea-middlewares.headers.STSPreload=true" # Service - "traefik.http.services.gitea-https-service.loadbalancer.server.port=3000" # - "traefik.http.services.gitea-https-service.loadbalancer.server.scheme=https" @@ -107,8 +125,14 @@ services: # - "traefik.http.services.gitea-https-service.loadbalancer.healthcheck.method=foobar" # - "traefik.http.services.gitea-https-service.loadbalancer.healthcheck.timeout=10" # - "traefik.http.services.gitea-https-service.loadbalancer.healthcheck.interval=30" - - "traefik.tcp.services.gitea-ssh-service.loadbalancer.server.port=22" +# - "traefik.tcp.services.gitea-ssh-service.loadbalancer.server.port=22" # - "traefik.tcp.services.gitea-ssh-service.loadbalancer.server.tls=true" +# Homepage + - "homepage.group=Dev" + - "homepage.name=Gitea" + - "homepage.icon=gitea.png" + - "homepage.href=https://gitea.tips-of-mine.com" + - "homepage.description=git" ### postgres postgres: @@ -132,6 +156,10 @@ services: timeout: 5s retries: 3 start_period: 60s + security_opt: + - no-new-privileges + labels: + - "com.centurylinklabs.watchtower.enable=true" ### adminer # adminer: @@ -197,6 +225,10 @@ services: depends_on: postgres: condition: service_healthy + security_opt: + - no-new-privileges + labels: + - "com.centurylinklabs.watchtower.enable=true" ### runner runner: @@ -204,22 +236,32 @@ services: hostname: gitea-runner image: gitea/act_runner:latest environment: + - TZ="Europe/Paris" + - GITEA_RUNNER_LOG_LEVEL="trace" - GITEA_INSTANCE_URL=${GITEA_URL} - - GITEA_RUNNER_REGISTRATION_TOKEN=v1bOsBNZeoJ0P3fGHsyEoqxcpBoLc8xTBByhnB5I - GITEA_RUNNER_NAME=gitea-runner +# - GITEA_RUNNER_CAPACITY=3 + - DOCKER_TLS_VERIFY=0 restart: unless-stopped networks: - back_network_gitea + - traefik_front_network volumes: -# - ./data-runner:/data + - ./data/act_runner:/data:rw - /etc/ssl/certs/:/etc/ssl/certs:ro + - ./config.yaml:/config.yaml:ro # - ./data-runner/cache:/root/.cache -# - ./config.yaml:/config.yaml + - ./config.yaml:/config.yaml - /var/run/docker.sock:/var/run/docker.sock - privileged: true + - ./cache/actions:/root/.cache/act:rw + - ./cache/build:/root/.cache:rw depends_on: gitea: condition: service_healthy + security_opt: + - no-new-privileges + labels: + - "com.centurylinklabs.watchtower.enable=true" ### msmtpd msmtpd: @@ -241,7 +283,7 @@ services: - "SMTP_TLS_CHECKCERT=off" - "SMTP_AUTH=on" - "SMTP_USER=hostinfo@tips-of-mine.fr" - - "SMTP_PASSWORD=P@ssword!Here!123456" + - "SMTP_PASSWORD=Whf2VtLEd2QR4er" - "SMTP_DOMAIN=localhost" - "SMTP_FROM=hostinfo@tips-of-mine.fr" restart: always @@ -250,3 +292,7 @@ services: interval: 5s timeout: 5s retries: 5 + security_opt: + - no-new-privileges + labels: + - "com.centurylinklabs.watchtower.enable=true"