@yunuseon/kayra 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/ansible/.ansible-lint +13 -0
- package/ansible/ansible.cfg +12 -0
- package/ansible/playbooks/adopt.yml +35 -0
- package/ansible/playbooks/app.yml +21 -0
- package/ansible/playbooks/apps-prune.yml +30 -0
- package/ansible/playbooks/edge.yml +41 -0
- package/ansible/playbooks/nas.yml +47 -0
- package/ansible/playbooks/platform.yml +62 -0
- package/ansible/requirements.yml +7 -0
- package/ansible/roles/app/defaults/main.yml +9 -0
- package/ansible/roles/app/tasks/main.yml +107 -0
- package/ansible/roles/app/templates/app.env.j2 +9 -0
- package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
- package/ansible/roles/backup-check/files/backup-check.service +9 -0
- package/ansible/roles/backup-check/files/backup-check.timer +10 -0
- package/ansible/roles/backup-check/tasks/main.yml +59 -0
- package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
- package/ansible/roles/common/handlers/main.yml +16 -0
- package/ansible/roles/common/tasks/main.yml +100 -0
- package/ansible/roles/compose-stacks/tasks/main.yml +102 -0
- package/ansible/roles/docker/defaults/main.yml +3 -0
- package/ansible/roles/docker/files/buildkit-rootless +23 -0
- package/ansible/roles/docker/handlers/main.yml +12 -0
- package/ansible/roles/docker/tasks/main.yml +82 -0
- package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
- package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
- package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
- package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
- package/ansible/roles/forgejo-config/tasks/main.yml +333 -0
- package/ansible/roles/platform/defaults/main.yml +76 -0
- package/ansible/roles/platform/tasks/main.yml +298 -0
- package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
- package/ansible/roles/platform/templates/platform.env.j2 +41 -0
- package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
- package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
- package/ansible/roles/restic-backup/handlers/main.yml +7 -0
- package/ansible/roles/restic-backup/tasks/main.yml +121 -0
- package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
- package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
- package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
- package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
- package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
- package/ansible/roles/wireguard/handlers/main.yml +9 -0
- package/ansible/roles/wireguard/tasks/main.yml +33 -0
- package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
- package/bin/kayra.js +29 -0
- package/cli/dist/brand.js +41 -0
- package/cli/dist/cli.js +21 -0
- package/cli/dist/commands/doctor.js +79 -0
- package/cli/dist/commands/server.js +215 -0
- package/cli/dist/commands/status.js +43 -0
- package/cli/dist/fleet.js +134 -0
- package/cli/dist/interpret.js +98 -0
- package/cli/dist/load.js +49 -0
- package/cli/dist/main.js +94 -0
- package/cli/dist/plan.js +31 -0
- package/cli/dist/result.js +5 -0
- package/cloud-init.yml.tpl +14 -0
- package/compose/authelia/configuration.yml +138 -0
- package/compose/buildkit/buildkitd.toml +14 -0
- package/compose/docker-compose.acme-dns.yml +12 -0
- package/compose/docker-compose.ci.yml +156 -0
- package/compose/docker-compose.yml +83 -0
- package/compose/jenkins/Dockerfile +40 -0
- package/compose/jenkins/casc/00-platform.yaml +158 -0
- package/compose/jenkins/plugins.txt +97 -0
- package/compose/jenkins-agent/Dockerfile +24 -0
- package/compose/traefik/dynamic/authelia.yml +18 -0
- package/compose/traefik/dynamic/middlewares.yml +14 -0
- package/compose/traefik/dynamic/ratelimit.yml +21 -0
- package/compose/traefik/dynamic/tls.yml +9 -0
- package/compose/traefik/traefik.yml +46 -0
- package/hosts.conf +6 -0
- package/package.json +73 -4
- package/scripts/local/app-manifests.sh +26 -0
- package/scripts/local/authelia-dev-init.sh +58 -0
- package/scripts/local/certs.sh +55 -0
- package/scripts/local/home-image.sh +55 -0
- package/scripts/local/home-reach.sh +41 -0
- package/scripts/local/require-main.sh +38 -0
- package/scripts/local/setup-hosts.sh +30 -0
- package/scripts/server/app-purge +26 -0
- package/scripts/server/app-remove +30 -0
- package/scripts/server/jenkins-api +28 -0
- package/scripts/server/jenkins-app-apply +36 -0
- package/scripts/server/jenkins-reload +12 -0
- package/terraform/modules/hcloud-firewall/main.tf +54 -0
- package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
- package/terraform/modules/hcloud-firewall/variables.tf +22 -0
- package/terraform/modules/hcloud-server/main.tf +62 -0
- package/terraform/modules/hcloud-server/outputs.tf +19 -0
- package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/hcloud-server/variables.tf +50 -0
- package/terraform/modules/multipass-instance/main.tf +67 -0
- package/terraform/modules/multipass-instance/outputs.tf +19 -0
- package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/multipass-instance/variables.tf +39 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Generate local Authelia secrets and a dev user database.
|
|
3
|
+
#
|
|
4
|
+
# Mirrors scripts/local/certs.sh: local-only material, generated on demand and
|
|
5
|
+
# gitignored, never committed. Production gets both from Ansible + vault.
|
|
6
|
+
#
|
|
7
|
+
# Usage: bash scripts/local/authelia-dev-init.sh <authelia-dir> <dev-email> <dev-password>
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
usage="Usage: authelia-dev-init.sh <authelia-dir> <dev-email> <dev-password>"
|
|
11
|
+
AUTHELIA_DIR="${1:?$usage}"
|
|
12
|
+
DEV_EMAIL="${2:?$usage}"
|
|
13
|
+
DEV_PASSWORD="${3:?$usage}"
|
|
14
|
+
IMAGE="authelia/authelia:4.39.28"
|
|
15
|
+
|
|
16
|
+
SECRETS_DIR="${AUTHELIA_DIR}/secrets"
|
|
17
|
+
USERS_FILE="${AUTHELIA_DIR}/users_database.yml"
|
|
18
|
+
|
|
19
|
+
mkdir -p "${SECRETS_DIR}"
|
|
20
|
+
|
|
21
|
+
for name in session storage_encryption; do
|
|
22
|
+
target="${SECRETS_DIR}/${name}"
|
|
23
|
+
if [ ! -s "${target}" ]; then
|
|
24
|
+
echo "==> Generating ${name}..."
|
|
25
|
+
openssl rand -hex 32 > "${target}"
|
|
26
|
+
chmod 600 "${target}"
|
|
27
|
+
fi
|
|
28
|
+
done
|
|
29
|
+
|
|
30
|
+
if [ -s "${USERS_FILE}" ]; then
|
|
31
|
+
echo "==> ${USERS_FILE} exists — leaving it alone."
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
echo "==> Hashing the dev password (argon2id)..."
|
|
36
|
+
# Authelia's own hasher, so parameters always match configuration.yml.
|
|
37
|
+
# --memory is KiB here, matching the config's `memory:` key exactly.
|
|
38
|
+
HASH="$(docker run --rm "${IMAGE}" \
|
|
39
|
+
authelia crypto hash generate argon2 \
|
|
40
|
+
--variant argon2id --iterations 3 --memory 32768 --parallelism 2 \
|
|
41
|
+
--password "${DEV_PASSWORD}" \
|
|
42
|
+
| sed 's/^Digest: //')"
|
|
43
|
+
|
|
44
|
+
cat > "${USERS_FILE}" <<YML
|
|
45
|
+
# Local development only — generated by scripts/local/authelia-dev-init.sh.
|
|
46
|
+
# Production renders this from vault; see the platform Ansible role.
|
|
47
|
+
users:
|
|
48
|
+
dev:
|
|
49
|
+
disabled: false
|
|
50
|
+
displayname: 'Dev'
|
|
51
|
+
password: '${HASH}'
|
|
52
|
+
email: '${DEV_EMAIL}'
|
|
53
|
+
groups: ['admin']
|
|
54
|
+
YML
|
|
55
|
+
chmod 600 "${USERS_FILE}"
|
|
56
|
+
|
|
57
|
+
echo ""
|
|
58
|
+
echo "Done — dev login: ${DEV_EMAIL} / ${DEV_PASSWORD}"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Generate local-trusted TLS certs (mkcert) for Traefik.
|
|
3
|
+
# Covers `<domain>`, `*.<domain>`, the platform's subdomains and the given ones.
|
|
4
|
+
#
|
|
5
|
+
# Usage: bash scripts/local/certs.sh <domain> <certs-dir> [subdomain…]
|
|
6
|
+
# Output: <certs-dir>/cert.pem, <certs-dir>/key.pem
|
|
7
|
+
#
|
|
8
|
+
# A no-op when the existing cert already covers <domain>. Local dev and the
|
|
9
|
+
# multipass VM share one certs dir with different domains, so "the file exists"
|
|
10
|
+
# is not enough: shipping the local cert to the VM would break every hostname.
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
14
|
+
DOMAIN="${1:?Usage: certs.sh <domain> <certs-dir> [subdomain…]}"
|
|
15
|
+
CERTS_DIR="${2:?}"
|
|
16
|
+
shift 2
|
|
17
|
+
|
|
18
|
+
if [ -f "${CERTS_DIR}/key.pem" ] && [ -f "${CERTS_DIR}/cert.pem" ] \
|
|
19
|
+
&& openssl x509 -in "${CERTS_DIR}/cert.pem" -noout -ext subjectAltName | grep -qE "DNS:${DOMAIN}(,|$)"; then
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if ! command -v mkcert >/dev/null 2>&1; then
|
|
24
|
+
echo "ERROR: mkcert not installed. Run: brew install mkcert nss && mkcert -install" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# First run on this machine? Install the local CA into the system trust store.
|
|
29
|
+
if [ ! -f "$(mkcert -CAROOT)/rootCA.pem" ]; then
|
|
30
|
+
echo "==> Installing mkcert local CA (one-time, may prompt for sudo)..."
|
|
31
|
+
mkcert -install
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
mkdir -p "${CERTS_DIR}"
|
|
35
|
+
|
|
36
|
+
# Explicit subdomain SANs alongside the wildcard: Apple's SecureTransport (used
|
|
37
|
+
# by macOS-bundled curl) doesn't match `*.domain` wildcards. Chrome is fine, but
|
|
38
|
+
# pipelines and smoke tests that use system curl break without these.
|
|
39
|
+
NAMES=("${DOMAIN}" "*.${DOMAIN}")
|
|
40
|
+
for sub in $(awk '!/^[[:space:]]*(#|$)/ {print $1}' "${KAYRA_ROOT}"/hosts.conf) "$@"; do
|
|
41
|
+
NAMES+=("${sub}.${DOMAIN}")
|
|
42
|
+
done
|
|
43
|
+
# Keep plain localhost working for native (non-compose) dev.
|
|
44
|
+
NAMES+=(localhost "*.localhost" 127.0.0.1 ::1)
|
|
45
|
+
|
|
46
|
+
echo "==> Generating cert for ${DOMAIN} (${#NAMES[@]} names)..."
|
|
47
|
+
mkcert \
|
|
48
|
+
-cert-file "${CERTS_DIR}/cert.pem" \
|
|
49
|
+
-key-file "${CERTS_DIR}/key.pem" \
|
|
50
|
+
"${NAMES[@]}"
|
|
51
|
+
|
|
52
|
+
chmod 644 "${CERTS_DIR}/cert.pem"
|
|
53
|
+
chmod 600 "${CERTS_DIR}/key.pem"
|
|
54
|
+
|
|
55
|
+
echo "Done — ${CERTS_DIR}/cert.pem + key.pem"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Builds what a target: home server boots from, since nothing can create it
|
|
3
|
+
# for us: the Ubuntu cloud image, grown to vm.disk, and a seed ISO with the
|
|
4
|
+
# same cloud-init as every other server plus its fixed address. Both go into
|
|
5
|
+
# the hypervisor by hand, once: the image as the VM's disk, the ISO as a CD.
|
|
6
|
+
#
|
|
7
|
+
# Usage: FLEET_ROOT=<fleet> bash scripts/local/home-image.sh <server-name>
|
|
8
|
+
# Writes servers/<name>/vm/ (gitignored): <name>.qcow2, seed.iso.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
FLEET_ROOT="${FLEET_ROOT:?the directory of the fleet (servers/), set by kayra}"
|
|
12
|
+
KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
13
|
+
name="${1:?Usage: home-image.sh <server-name>}"
|
|
14
|
+
dir="${FLEET_ROOT}/servers/${name}"
|
|
15
|
+
out="${dir}/vm"
|
|
16
|
+
|
|
17
|
+
# Pinned. Only the first boot uses it; Ansible keeps the server current after.
|
|
18
|
+
image_url=https://cloud-images.ubuntu.com/releases/noble/release-20260926/ubuntu-24.04-server-cloudimg-amd64.img
|
|
19
|
+
image_sha256=6a81c37564db9b1ee84e141922625e1d7c5b389b99bb3c572e0243607d5bb4d2
|
|
20
|
+
|
|
21
|
+
command -v qemu-img >/dev/null || { echo "ERROR: qemu-img not installed. Run: brew install qemu" >&2; exit 1; }
|
|
22
|
+
|
|
23
|
+
address="$(yq '.vm.address' "${dir}/vars.yml")"
|
|
24
|
+
gateway="$(yq '.vm.gateway' "${dir}/vars.yml")"
|
|
25
|
+
disk="$(yq '.vm.disk' "${dir}/vars.yml")"
|
|
26
|
+
key="$(sed -n 's/.*ansible_ssh_private_key_file=\([^ ]*\).*/\1/p' "${dir}/inventory.ini" | head -1).pub"
|
|
27
|
+
|
|
28
|
+
mkdir -p "${out}/seed"
|
|
29
|
+
image="${out}/cloudimg-${image_sha256:0:12}.img"
|
|
30
|
+
if [ ! -f "$image" ]; then
|
|
31
|
+
curl -fL --progress-bar -o "${image}.part" "$image_url"
|
|
32
|
+
mv "${image}.part" "$image"
|
|
33
|
+
fi
|
|
34
|
+
echo "${image_sha256} ${image}" | shasum -a 256 -c --quiet
|
|
35
|
+
|
|
36
|
+
rm -f "${out}/${name}.qcow2"
|
|
37
|
+
qemu-img convert -O qcow2 "$image" "${out}/${name}.qcow2"
|
|
38
|
+
qemu-img resize -q "${out}/${name}.qcow2" "$disk"
|
|
39
|
+
|
|
40
|
+
sed -e "s|[$]{hostname}|${name}|" -e "s|[$]{ssh_pubkey}|$(cat "$key")|" \
|
|
41
|
+
"${KAYRA_ROOT}/cloud-init.yml.tpl" > "${out}/seed/user-data"
|
|
42
|
+
printf 'instance-id: %s\nlocal-hostname: %s\n' "$name" "$name" > "${out}/seed/meta-data"
|
|
43
|
+
cat > "${out}/seed/network-config" <<NETWORK
|
|
44
|
+
version: 2
|
|
45
|
+
ethernets:
|
|
46
|
+
lan:
|
|
47
|
+
match: {name: "e*"}
|
|
48
|
+
addresses: [${address}]
|
|
49
|
+
routes: [{to: default, via: ${gateway}}]
|
|
50
|
+
nameservers: {addresses: [${gateway}]}
|
|
51
|
+
NETWORK
|
|
52
|
+
|
|
53
|
+
rm -f "${out}/seed.iso"
|
|
54
|
+
hdiutil makehybrid -quiet -iso -joliet -default-volume-name cidata -o "${out}/seed.iso" "${out}/seed"
|
|
55
|
+
echo "Built ${out}/${name}.qcow2 (${disk}) and ${out}/seed.iso: import the first as the VM's disk, attach the second as a CD."
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Can Ansible get into a home machine (target: home)? If not, say why, in the
|
|
3
|
+
# terms of the failures seen there, before Ansible's UNREACHABLE does not.
|
|
4
|
+
#
|
|
5
|
+
# Usage: FLEET_ROOT=<fleet> bash scripts/local/home-reach.sh <server-name> <kind>
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
FLEET_ROOT="${FLEET_ROOT:?the directory of the fleet (servers/), set by kayra}"
|
|
9
|
+
name="${1:?Usage: home-reach.sh <server-name> <kind>}"
|
|
10
|
+
kind="${2:?Usage: home-reach.sh <server-name> <kind>}"
|
|
11
|
+
inv="${FLEET_ROOT}/servers/${name}/inventory.ini"
|
|
12
|
+
field() { sed -n "s/.*${1}=\([^ ]*\).*/\1/p" "$inv" | head -1; }
|
|
13
|
+
host="$(field ansible_host)"
|
|
14
|
+
user="$(field ansible_user)"
|
|
15
|
+
key="$(field ansible_ssh_private_key_file)"
|
|
16
|
+
port="$(field ansible_port)"
|
|
17
|
+
|
|
18
|
+
if out="$(ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
|
|
19
|
+
-p "${port:-22}" -i "${key/#\~/$HOME}" "${user}@${host}" true 2>&1)"; then
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
echo "ERROR: cannot SSH into ${name} (${user}@${host}):" >&2
|
|
24
|
+
echo "$out" | grep -v '^\*\*' | sed 's/^/ /' >&2
|
|
25
|
+
case "$out" in
|
|
26
|
+
*"Connection refused"*)
|
|
27
|
+
if [ "$kind" = nas ]; then
|
|
28
|
+
echo " SSH is off: it is on only on demand, for a set time. UGOS: Control Panel → Terminal → SSH on." >&2
|
|
29
|
+
else
|
|
30
|
+
echo " Nothing listens on port ${port:-22}: is the machine up and sshd running?" >&2
|
|
31
|
+
fi ;;
|
|
32
|
+
*"Permission denied"*)
|
|
33
|
+
echo " The key is refused. Is it in the agent? ssh-add ${key}" >&2
|
|
34
|
+
if [ "$kind" = nas ]; then
|
|
35
|
+
echo " If it is: UGOS resets permissions, and sshd ignores a writable ~/.ssh. On the NAS:" >&2
|
|
36
|
+
echo " chmod 755 ~ && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys" >&2
|
|
37
|
+
fi ;;
|
|
38
|
+
*"timed out"*|*"No route to host"*|*"not known"*)
|
|
39
|
+
echo " ${host} does not answer: is the machine on, and is this Mac on the home network?" >&2 ;;
|
|
40
|
+
esac
|
|
41
|
+
exit 1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Refuse to change a real server from anything but main as merged.
|
|
3
|
+
#
|
|
4
|
+
# Every kayra command deploys the local checkout, whatever it is. A
|
|
5
|
+
# test server may track a branch (that is how platform changes are tested);
|
|
6
|
+
# every other server is only ever changed from a clean main that matches
|
|
7
|
+
# origin/main, so what runs there is exactly what was reviewed and merged.
|
|
8
|
+
# Uncommitted changes in other servers' folders do not count: a test server's
|
|
9
|
+
# branch pointers must not block a prod deploy.
|
|
10
|
+
#
|
|
11
|
+
# Usage: FLEET_ROOT=<fleet> bash scripts/local/require-main.sh <server-name>
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
FLEET_ROOT="${FLEET_ROOT:?the directory of the fleet (servers/), set by kayra}"
|
|
15
|
+
name="${1:?Usage: require-main.sh <server-name>}"
|
|
16
|
+
target="$(yq '.target' "${FLEET_ROOT}/servers/${name}/server.yml")"
|
|
17
|
+
[ "$target" != "multipass" ] || exit 0
|
|
18
|
+
|
|
19
|
+
fail() {
|
|
20
|
+
echo "ERROR: ${name} is a ${target} server and is only changed from main as merged." >&2
|
|
21
|
+
echo " $*" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cd "$FLEET_ROOT"
|
|
26
|
+
branch="$(git branch --show-current)"
|
|
27
|
+
[ "$branch" = "main" ] || fail "The checkout is on '${branch:-a detached HEAD}'. Merge it, then: git switch main && git pull"
|
|
28
|
+
git fetch -q origin main || fail "Could not fetch origin/main to compare against."
|
|
29
|
+
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] \
|
|
30
|
+
|| fail "Local main differs from origin/main. Pull, and send unpushed commits through a PR."
|
|
31
|
+
|
|
32
|
+
others=()
|
|
33
|
+
for dir in servers/*/; do
|
|
34
|
+
[ "$dir" = "servers/${name}/" ] || others+=(":(exclude)${dir}")
|
|
35
|
+
done
|
|
36
|
+
dirty="$(git status --porcelain --untracked-files=no -- . "${others[@]}")"
|
|
37
|
+
[ -z "$dirty" ] || fail "Uncommitted changes would be deployed; commit them through a PR first:
|
|
38
|
+
${dirty}"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Point <domain> and its subdomains at <ip> in /etc/hosts. Requires sudo.
|
|
3
|
+
# Subdomains: the platform's (hosts.conf) plus the given ones —
|
|
4
|
+
# callers pass their apps' subdomains, as they do to certs.sh.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash scripts/local/setup-hosts.sh <domain> <ip> [subdomain…]
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
10
|
+
DOMAIN="${1:?Usage: setup-hosts.sh <domain> <ip>}"
|
|
11
|
+
IP="${2:?Usage: setup-hosts.sh <domain> <ip> [subdomain…]}"
|
|
12
|
+
shift 2
|
|
13
|
+
|
|
14
|
+
echo "==> Updating /etc/hosts for ${DOMAIN} -> ${IP}..."
|
|
15
|
+
|
|
16
|
+
if grep -q "${DOMAIN}" /etc/hosts 2>/dev/null; then
|
|
17
|
+
echo " Removing old entries..."
|
|
18
|
+
sudo sed -i.bak "/${DOMAIN}/d" /etc/hosts
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
HOSTS=""
|
|
22
|
+
for sub in $(awk '!/^[[:space:]]*(#|$)/ {print $1}' "${KAYRA_ROOT}"/hosts.conf) "$@"; do
|
|
23
|
+
HOSTS="${HOSTS} ${sub}.${DOMAIN}"
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
echo "${IP} ${DOMAIN}${HOSTS}" | sudo tee -a /etc/hosts > /dev/null
|
|
27
|
+
|
|
28
|
+
echo ""
|
|
29
|
+
echo "Done — /etc/hosts updated:"
|
|
30
|
+
grep "${DOMAIN}" /etc/hosts
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# app-purge <app> — delete a removed app's Docker volumes, i.e. its data.
|
|
4
|
+
#
|
|
5
|
+
# Refuses while the app is still registered here: purging is the second,
|
|
6
|
+
# deliberate step after `app-remove`, never a way around it.
|
|
7
|
+
#
|
|
8
|
+
# Installed by the platform role at <platform_dir>/bin. Run as root.
|
|
9
|
+
# =============================================================================
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
app="${1:?Usage: app-purge <app>}"
|
|
13
|
+
platform_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
|
14
|
+
|
|
15
|
+
if [ -e "${platform_dir}/jenkins/casc/10-${app}.yaml" ]; then
|
|
16
|
+
echo "ERROR: ${app} is still registered on this server — remove it from server.yml and run \`kayra apps <server>\` first." >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
volumes=$(docker volume ls -q --filter "label=com.docker.compose.project=${app}")
|
|
20
|
+
if [ -z "$volumes" ]; then
|
|
21
|
+
echo "No volumes left for ${app}."
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
# Word-splitting on purpose: one volume name per line, none contain spaces.
|
|
25
|
+
# shellcheck disable=SC2086
|
|
26
|
+
docker volume rm $volumes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# app-remove <app> — take an app off this server, keeping its data.
|
|
4
|
+
#
|
|
5
|
+
# Stops its containers and removes everything the platform set up for it:
|
|
6
|
+
# JCasC fragment, secret files, .env, the Jenkins folder with its jobs and
|
|
7
|
+
# history, the release records (so Rollback no longer offers it). Its images
|
|
8
|
+
# are collected by the next registry GC, since the app is no longer registered.
|
|
9
|
+
#
|
|
10
|
+
# Volumes stay: dropping a line from server.yml must never silently destroy a
|
|
11
|
+
# database. `app-purge <app>` deletes them deliberately.
|
|
12
|
+
#
|
|
13
|
+
# Run by `kayra apps` for every registered app missing from server.yml.
|
|
14
|
+
# Installed by the platform role at <platform_dir>/bin. Run as root.
|
|
15
|
+
# =============================================================================
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
app="${1:?Usage: app-remove <app>}"
|
|
19
|
+
platform_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
|
20
|
+
apps_dir="$(sed -n 's/^APPS_DIR=//p' "${platform_dir}/.env")"
|
|
21
|
+
|
|
22
|
+
docker compose -p "$app" down --remove-orphans
|
|
23
|
+
rm -rf "${platform_dir}/jenkins/casc/10-${app}.yaml" \
|
|
24
|
+
"${platform_dir}/jenkins-secrets/${app}" \
|
|
25
|
+
"${apps_dir:?}/${app}"
|
|
26
|
+
# Without its fragment the reload drops the app's credentials.
|
|
27
|
+
"${platform_dir}/bin/jenkins-reload"
|
|
28
|
+
"${platform_dir}/bin/jenkins-api" POST "/job/${app}/doDelete" -o /dev/null || true
|
|
29
|
+
(cd "$platform_dir" && docker compose exec -T jenkins-controller rm -rf "/var/jenkins_home/releases/${app}")
|
|
30
|
+
echo "Removed ${app}. Its volumes are kept — app-purge ${app} deletes them."
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# jenkins-api <METHOD> <path> [curl args…] — one authenticated call to the
|
|
4
|
+
# Jenkins API, from inside the controller (admin credentials from its env,
|
|
5
|
+
# CSRF crumb fetched in the same session). Prints the response body.
|
|
6
|
+
#
|
|
7
|
+
# jenkins-api GET '/api/json?tree=jobs[name]'
|
|
8
|
+
# jenkins-api POST /configuration-as-code/reload
|
|
9
|
+
#
|
|
10
|
+
# Installed by the platform role at <platform_dir>/bin.
|
|
11
|
+
# =============================================================================
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
method="${1:?Usage: jenkins-api <METHOD> <path> [curl args…]}"
|
|
15
|
+
path="${2:?}"
|
|
16
|
+
shift 2
|
|
17
|
+
|
|
18
|
+
cd "$(dirname "$0")/.."
|
|
19
|
+
docker compose exec -T jenkins-controller sh -ec '
|
|
20
|
+
jar=$(mktemp)
|
|
21
|
+
trap "rm -f $jar" EXIT
|
|
22
|
+
auth="${JENKINS_ADMIN_USER}:${JENKINS_ADMIN_PASSWORD}"
|
|
23
|
+
base=http://127.0.0.1:8080
|
|
24
|
+
crumb=$(curl -sf -c "$jar" -u "$auth" \
|
|
25
|
+
"$base/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,\":\",//crumb)")
|
|
26
|
+
method="$1"; path="$2"; shift 2
|
|
27
|
+
curl -sf -b "$jar" -u "$auth" -H "$crumb" -X "$method" "$base$path" "$@"
|
|
28
|
+
' sh "$method" "$path" "$@"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# jenkins-app-apply <app> — the one way an app registers with Jenkins.
|
|
4
|
+
#
|
|
5
|
+
# Reads the app's secrets as KEY=VALUE lines on stdin, writes each to
|
|
6
|
+
# jenkins-secrets/<app>/<KEY> (where its 10-<app>.yaml reads them via
|
|
7
|
+
# ${readFile:/run/app-secrets/<app>/<KEY>}), then runs jenkins-reload.
|
|
8
|
+
#
|
|
9
|
+
# Installed by the platform role at <platform_dir>/bin. Run as root.
|
|
10
|
+
# =============================================================================
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
app="${1:?Usage: jenkins-app-apply <app> < secrets.env}"
|
|
14
|
+
platform_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
|
15
|
+
secrets_dir="${platform_dir}/jenkins-secrets/${app}"
|
|
16
|
+
|
|
17
|
+
# Staged next to the target and swapped in whole, so keys the app no longer
|
|
18
|
+
# declares disappear instead of lingering.
|
|
19
|
+
staging="${secrets_dir}.new"
|
|
20
|
+
rm -rf "$staging"
|
|
21
|
+
mkdir -p "$staging"
|
|
22
|
+
trap 'rm -rf "$staging"' EXIT
|
|
23
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
24
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
25
|
+
printf '%s' "${line#*=}" > "${staging}/${line%%=*}"
|
|
26
|
+
done
|
|
27
|
+
rm -rf "$secrets_dir"
|
|
28
|
+
mv "$staging" "$secrets_dir"
|
|
29
|
+
trap - EXIT
|
|
30
|
+
# The controller runs as uid 1000 and reads these through a read-only mount.
|
|
31
|
+
chown -R 1000:1000 "$secrets_dir"
|
|
32
|
+
find "$secrets_dir" -type f -exec chmod 0400 {} +
|
|
33
|
+
chmod 0500 "$secrets_dir"
|
|
34
|
+
|
|
35
|
+
"${platform_dir}/bin/jenkins-reload"
|
|
36
|
+
echo "Applied Jenkins config for ${app}."
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# jenkins-reload — re-apply every JCasC fragment in jenkins/casc/ in place.
|
|
4
|
+
#
|
|
5
|
+
# Reload, not restart: Jenkins is shared, and recreating the controller for one
|
|
6
|
+
# config change would kill every app's running builds.
|
|
7
|
+
#
|
|
8
|
+
# Installed by the platform role at <platform_dir>/bin.
|
|
9
|
+
# =============================================================================
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
"$(dirname "$0")/jenkins-api" POST /configuration-as-code/reload -o /dev/null
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Hetzner Cloud Firewall Module
|
|
3
|
+
# Creates a firewall with standard web server rules.
|
|
4
|
+
# =============================================================================
|
|
5
|
+
|
|
6
|
+
terraform {
|
|
7
|
+
required_providers {
|
|
8
|
+
hcloud = {
|
|
9
|
+
source = "hetznercloud/hcloud"
|
|
10
|
+
version = "~> 1.45"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
resource "hcloud_firewall" "this" {
|
|
16
|
+
name = var.name
|
|
17
|
+
|
|
18
|
+
# SSH
|
|
19
|
+
rule {
|
|
20
|
+
direction = "in"
|
|
21
|
+
protocol = "tcp"
|
|
22
|
+
port = "22"
|
|
23
|
+
source_ips = var.ssh_allowed_ips
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# HTTP
|
|
27
|
+
rule {
|
|
28
|
+
direction = "in"
|
|
29
|
+
protocol = "tcp"
|
|
30
|
+
port = "80"
|
|
31
|
+
source_ips = ["0.0.0.0/0", "::/0"]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# HTTPS
|
|
35
|
+
rule {
|
|
36
|
+
direction = "in"
|
|
37
|
+
protocol = "tcp"
|
|
38
|
+
port = "443"
|
|
39
|
+
source_ips = ["0.0.0.0/0", "::/0"]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Anything else the server's kind needs open to the world (e.g. WireGuard).
|
|
43
|
+
dynamic "rule" {
|
|
44
|
+
for_each = var.extra_rules
|
|
45
|
+
content {
|
|
46
|
+
direction = "in"
|
|
47
|
+
protocol = rule.value.protocol
|
|
48
|
+
port = rule.value.port
|
|
49
|
+
source_ips = ["0.0.0.0/0", "::/0"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
labels = var.labels
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
variable "name" {
|
|
2
|
+
description = "Firewall name"
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "ssh_allowed_ips" {
|
|
7
|
+
description = "CIDR ranges allowed to SSH (default: anywhere)"
|
|
8
|
+
type = list(string)
|
|
9
|
+
default = ["0.0.0.0/0", "::/0"]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
variable "extra_rules" {
|
|
13
|
+
description = "Additional inbound rules open to everyone, e.g. [{protocol = \"udp\", port = \"51820\"}]"
|
|
14
|
+
type = list(object({ protocol = string, port = string }))
|
|
15
|
+
default = []
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
variable "labels" {
|
|
19
|
+
description = "Labels to attach to the firewall"
|
|
20
|
+
type = map(string)
|
|
21
|
+
default = {}
|
|
22
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Hetzner Cloud Server Module
|
|
3
|
+
# Provisions a VPS with SSH key and cloud-init.
|
|
4
|
+
# =============================================================================
|
|
5
|
+
|
|
6
|
+
terraform {
|
|
7
|
+
required_providers {
|
|
8
|
+
hcloud = {
|
|
9
|
+
source = "hetznercloud/hcloud"
|
|
10
|
+
version = "~> 1.45"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# SSH key
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
resource "hcloud_ssh_key" "deploy" {
|
|
19
|
+
name = "${var.name}-deploy"
|
|
20
|
+
public_key = var.ssh_public_key
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Server
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
resource "hcloud_server" "this" {
|
|
27
|
+
name = var.name
|
|
28
|
+
server_type = var.server_type
|
|
29
|
+
image = var.image
|
|
30
|
+
location = var.location
|
|
31
|
+
ssh_keys = [hcloud_ssh_key.deploy.id]
|
|
32
|
+
|
|
33
|
+
firewall_ids = var.firewall_ids
|
|
34
|
+
|
|
35
|
+
user_data = templatefile("${path.module}/../../../cloud-init.yml.tpl", {
|
|
36
|
+
hostname = var.name
|
|
37
|
+
ssh_pubkey = var.ssh_public_key
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
labels = var.labels
|
|
41
|
+
|
|
42
|
+
# cloud-init runs once, at creation, and Hetzner replaces the server for any
|
|
43
|
+
# change to these. Editing the template (or adopting a server that was never
|
|
44
|
+
# created by us) must not destroy it; Ansible converges existing servers.
|
|
45
|
+
lifecycle {
|
|
46
|
+
ignore_changes = [user_data, ssh_keys, image]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Ansible inventory file
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
resource "local_file" "ansible_inventory" {
|
|
54
|
+
content = templatefile("${path.module}/templates/inventory.ini.tpl", {
|
|
55
|
+
hostname = var.name
|
|
56
|
+
ssh_host = hcloud_server.this.ipv4_address
|
|
57
|
+
ssh_port = 22
|
|
58
|
+
ssh_user = "deploy"
|
|
59
|
+
ssh_key_path = var.ssh_private_key_path
|
|
60
|
+
})
|
|
61
|
+
filename = var.inventory_path
|
|
62
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
output "ipv4_address" {
|
|
2
|
+
description = "Public IPv4 address of the server"
|
|
3
|
+
value = hcloud_server.this.ipv4_address
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "server_id" {
|
|
7
|
+
description = "Hetzner server ID"
|
|
8
|
+
value = hcloud_server.this.id
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
output "server_name" {
|
|
12
|
+
description = "Server name"
|
|
13
|
+
value = hcloud_server.this.name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
output "inventory_path" {
|
|
17
|
+
description = "Path to the generated Ansible inventory"
|
|
18
|
+
value = local_file.ansible_inventory.filename
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
variable "name" {
|
|
2
|
+
description = "Server name"
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "server_type" {
|
|
7
|
+
description = "Hetzner server type (e.g., cx22, cx32)"
|
|
8
|
+
type = string
|
|
9
|
+
default = "cx22"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
variable "image" {
|
|
13
|
+
description = "OS image"
|
|
14
|
+
type = string
|
|
15
|
+
default = "ubuntu-24.04"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
variable "location" {
|
|
19
|
+
description = "Hetzner datacenter location"
|
|
20
|
+
type = string
|
|
21
|
+
default = "nbg1"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
variable "ssh_public_key" {
|
|
25
|
+
description = "SSH public key for the deploy user"
|
|
26
|
+
type = string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
variable "ssh_private_key_path" {
|
|
30
|
+
description = "Path to the SSH private key (for inventory generation)"
|
|
31
|
+
type = string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
variable "firewall_ids" {
|
|
35
|
+
description = "List of firewall IDs to attach"
|
|
36
|
+
type = list(number)
|
|
37
|
+
default = []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
variable "labels" {
|
|
41
|
+
description = "Labels to attach to the server"
|
|
42
|
+
type = map(string)
|
|
43
|
+
default = {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
variable "inventory_path" {
|
|
47
|
+
description = "Path to write the Ansible inventory file"
|
|
48
|
+
type = string
|
|
49
|
+
default = "./inventory.ini"
|
|
50
|
+
}
|