liab-deployments/liab_deployments/deploy/forgejo.py
2026-06-22 11:44:51 +02:00

344 lines
10 KiB
Python

#
# Deploy Forgejo(-aneksajo) (https://codeberg.org/forgejo-aneksajo/forgejo-aneksajo)
#
# Does NOT support multiple deployments under the SAME user account.
#
# The deployment has two principle modes of operation:
# 1) Providing `config_file_asset` allows for deploying or (re)configuring
# a fully set up forgejo instance; 2) Not providing `config_file_asset` leaves
# the instance unconfigured, and forgejo will present the initialization wizard
# that allows for a convenient, basic setup, incl. the creation of an admin account.
# With (1), no forgejo accounts will be set up, also no admin account. This needs
# to be achieved with a different method (e.g., using the CLI, or a dedicated
# user account deployment.
#
# Example inventory
#
# group = [
# (
# '<host-FQDN>',
# {
# 'forgejo': {
# # defaults can provide setting for multiple site deployments
# 'defaults': {
# 'container_tag': 'hub.datalad.org/forgejo/forgejo-aneksajo:14-rootless',
# # repo with customizations to fetch
# # (should be from other host for robust behavior)
# # can also be a (url, branch) tuple
# 'customizations_fetch_url':
# 'https://<otherhost>/some.git',
# # credential to fetch the customization, password should be privy-encrypted
# 'customizations_fetch_auth': ('username', 'password'),
# # if path to annex-get are given, repo is annex-init'ed too
# 'customizations_annex_get': '.',
# 'caddyfile_block_tmpl':
# """\
# {serve_address} {{
# reverse_proxy localhost:{host_port}
# import cors *
# }}
# """,
# # additional directories to create in the deployment
# # HOME (as relative dirs). This can be necessary, when
# # a particular forgejo configuration puts information
# # into a non-standard location
# 'ensure_dirs': ['git/repositories', 'gitea/log'],
# },
# 'sites': [
# {
# 'serve_address': 'hub.example.org',
# 'user': ('hub', '2000'),
# 'host_port': 30001,
# 'config_file_asset': 'assets/hub-example-org-app.ini',
# },
# ],
# }
# },
# ),
# ]
from getpass import getpass
import os
import privy
from pyinfra.api import deploy as _deploy
from pyinfra import (
host,
)
from pyinfra.operations import (
files,
git,
server,
)
from urllib.parse import urlparse
from liab_deployments.operations import (
caddy,
user,
user_systemd,
)
service_unit_tmpl = """\
[Unit]
Description=Podman container-{name}.service
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=always
TimeoutStopSec=300
ExecStartPre=/bin/rm \\
-f %t/%n.ctr-id
ExecStart=/usr/bin/podman container run \\
--cidfile=%t/%n.ctr-id \\
--cgroups=no-conmon \\
--rm \\
--sdnotify=conmon \\
-d \\
--replace \\
--pull always \\
--name {name} \\
-p {host_port}:3000 \\
-v {user_home}/gitea:/var/lib/gitea:Z \\
-v {user_home}/custom:/var/lib/gitea/custom:Z \\
-v {user_home}/conf:/var/lib/gitea/custom/conf:Z \\
-v {user_home}/git:/var/lib/gitea/git:Z \\
--userns keep-id:uid=1000,gid=1000 \\
{container_tag}
ExecStop=/usr/bin/podman stop \\
--ignore -t 10 \\
--cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm \\
-f \\
--ignore -t 10 \\
--cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.target
"""
caddyfile_block_tmpl = """\
{serve_address} {{
reverse_proxy localhost:{host_port}
}}
"""
# used for the systemd service (lower), and the caddy block marker (upper),
# and in documentation (verbatim capitalization)
name = 'Forgejo'
@_deploy(f"Deploy {name}")
def deploy():
if not hasattr(host.data, name.lower()):
return
defaults = host.data.forgejo.get('defaults', {})
for spec in host.data.forgejo.get('sites', []):
_deploy_forgejo(
spec['serve_address'],
spec['container_tag']
if 'container_tag' in spec
else defaults['container_tag'],
spec['user'],
spec['host_port'],
caddyfile_block_tmpl=spec.get(
'caddyfile_block_tmpl',
defaults.get(
'caddyfile_block_tmpl',
caddyfile_block_tmpl)),
service_unit_tmpl=spec.get(
'service_unit_tmpl',
defaults.get(
'service_unit_tmpl',
service_unit_tmpl)),
config_file_asset=spec['config_file_asset']
if 'config_file_asset' in spec else None,
customizations_fetch_url=spec.get(
'customizations_fetch_url',
defaults.get('customizations_fetch_url')),
customizations_fetch_auth=spec.get(
'customizations_fetch_auth',
defaults.get('customizations_fetch_auth')),
customizations_annex_get=spec.get(
'customizations_annex_get',
defaults.get('customizations_annex_get')),
ensure_dirs=spec.get(
'ensure_dirs',
defaults.get('ensure_dirs')),
)
def _deploy_forgejo(
serve_address: str,
container_tag: str,
user_spec: tuple[str, int],
host_port: int,
caddyfile_block_tmpl: str,
service_unit_tmpl: str,
*,
config_file_asset: str | None = None,
customizations_fetch_url: str | tuple[str] | None = None,
customizations_fetch_auth: tuple[str, str] | None = None,
customizations_annex_get: str | None = None,
ensure_dirs: list[str] | None = None,
):
user_name, uid = user_spec
user_home = f'/home/{user_name}'
user.systemd_service(
user_name,
uid,
user_home,
)
if ensure_dirs is None:
# this is the set of directories that a "default" config
# expects, but that is NOT auto-created by forgejo when
# the installer is NOT running (when a full config is
# deployed)
ensure_dirs = ['git/repositories', 'gitea/log']
else:
# even if dirs are given, the container setup requires these
ensure_dirs += ['git', 'gitea']
for i in ensure_dirs:
files.directory(
path=f'{user_home}/{i}',
present=True,
_sudo_user=user_name,
)
user_systemd.service_unit(
user_name,
user_home,
name.lower(),
service_unit_tmpl.format(
name=name.lower(),
user_home=user_home,
container_tag=container_tag,
host_port=host_port,
),
)
if customizations_fetch_url:
_do_customization(
user_name,
f'{user_home}/custom',
customizations_fetch_url,
customizations_fetch_auth,
customizations_annex_get,
)
# needs to be done after a clone of the customization repo
if config_file_asset:
files.directory(
path=f'{user_home}/conf',
present=True,
_sudo_user=user_name,
)
files.put(
name=f'{name} config',
src=config_file_asset,
dest=f'{user_home}/conf/app.ini',
_sudo_user=user_name,
)
user_systemd.run_service(
user_name,
uid,
name.lower(),
)
server.wait(
name=f"Wait for {name} {serve_address!r} to start",
port=host_port,
)
caddy.caddyfile_block(
marker=f'{name.upper()} {serve_address}',
content=caddyfile_block_tmpl.format(
serve_address=serve_address,
host_port=host_port,
),
)
def _do_customization(
user_name, dest_dir, fetch_url, fetch_auth, annex_get,
):
if isinstance(fetch_url, tuple):
furl, fbranch = fetch_url
else:
furl = fetch_url
fbranch = None
url_p = urlparse(furl)
if fetch_auth:
_do_git_credential(url_p, user_name, furl, fetch_auth)
# we need a git-identity for git-annex-init to function
git.config(
name="Ensure Git user name is set",
key="user.name",
value=user_name,
_sudo_user=user_name,
)
git.config(
name="Ensure Git user email is set",
key="user.email",
value=f"{user_name}@localhost",
_sudo_user=user_name,
)
git.repo(
name="Clone customizations",
src=furl,
branch=fbranch,
dest=dest_dir,
pull=True,
rebase=True,
_sudo_user=user_name,
)
if annex_get:
server.shell(
name='Fetch customizations annex',
commands=[
f'cd "{dest_dir}" '
'&& git -c annex.private=true annex init '
f'&& git annex get {annex_get}',
# TODO unused and dropunused
],
_sudo_user=user_name,
)
def _do_git_credential(url_p, user_name, fetch_url, fetch_auth):
privy_password = os.environ.get('PRIVY_PASSWORD')
if not privy_password:
privy_password = getpass('Privy password to decode credential: ')
if not privy_password:
raise ValueError
gf_user, gf_password = fetch_auth
gf_password = privy.peek(gf_password, privy_password).decode()
git.config(
name='Enable Git credential caching',
key='credential.helper',
value='cache --timeout=3600',
system=False,
_sudo_user=user_name,
)
server.shell(
name='Prime Git credential cache',
commands=[
f'echo "protocol={url_p.scheme}\nhost={url_p.netloc}\nusername={gf_user}\npassword={gf_password}" | git credential approve',
],
_sudo_user=user_name,
)