new config
Compose CI / lint-ci-scripts (push) Successful in 6s
Compose CI / compose-verify (push) Successful in 1m6s

This commit is contained in:
2026-09-18 15:14:01 +02:00
parent 626b573b5a
commit 53a84cd2f0
9 changed files with 1098 additions and 147 deletions
+34
View File
@@ -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=[email protected]
GITEA_NOREPLY_EMAIL=[email protected]
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
+447
View File
@@ -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 <projet>-<service> : 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()
+478
View File
@@ -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 "$@"
-70
View File
@@ -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/[email protected]
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/[email protected]
# 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/*
-51
View File
@@ -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
+63
View File
@@ -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
+4
View File
@@ -12,3 +12,7 @@
# Built Visual Studio Code Extensions
*.vsix
# Variables d'environnement et secrets
.env
.env.*
!.env.example
+10 -10
View File
@@ -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
+61 -15
View File
@@ -5,6 +5,9 @@ networks:
back_network_gitea:
driver: bridge
attachable: true
monitoring_network:
external: true
name: monitoring_network
#### SERVICES
services:
@@ -48,7 +51,7 @@ services:
- GITEA__metrics__ENABLED=true
- GITEA__metrics__ENABLED_ISSUE_BY_REPOSITORY=true
- GITEA__metrics__ENABLED_ISSUE_BY_LABEL=true
- GITEA__service__DISABLE_REGISTRATION=false
- 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"
- "[email protected]"
- "SMTP_PASSWORD=P@ssword!Here!123456"
- "SMTP_PASSWORD=Whf2VtLEd2QR4er"
- "SMTP_DOMAIN=localhost"
- "[email protected]"
restart: always
@@ -250,3 +292,7 @@ services:
interval: 5s
timeout: 5s
retries: 5
security_opt:
- no-new-privileges
labels:
- "com.centurylinklabs.watchtower.enable=true"