Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Only container runtime sources belong in the build context.
**
!Dockerfile
!LICENSE
!docker/
!docker/*.php
!docker/*.sh
!docker/php-fpm.conf
!docker/nginx.conf.template
!docker/health.txt
!moodle/
!moodle/.config/
!moodle/.config/php/
!moodle/.config/php/mooship.ini
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Container entry points and Linux lifecycle scripts must retain Unix line endings.
*.sh text eol=lf
Dockerfile text eol=lf
19 changes: 18 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ updates:
commit-message:
prefix: ci

- package-ecosystem: pip
directory: /
cooldown:
default-days: 7
schedule:
interval: weekly
day: monday
groups:
package-build-tools:
patterns: ['*']
open-pull-requests-limit: 5
commit-message:
prefix: build

- package-ecosystem: pip
directory: /.github
cooldown:
Expand All @@ -31,6 +45,7 @@ updates:

- package-ecosystem: docker
directories:
- /
- /moodle/.docker/php
- /moodle/.docker/nginx
- /nginxproxy
Expand All @@ -48,7 +63,9 @@ updates:
prefix: build

- package-ecosystem: docker-compose
directory: /moodle
directories:
- /moodle
- /docker
# Database branch changes require coordinated migration and restore checks.
ignore:
- dependency-name: mysql
Expand Down
59 changes: 59 additions & 0 deletions .github/scripts/build_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Build a deterministic native toolkit archive from Git's runtime-file inventory."""

import argparse
import gzip
import hashlib
import io
from pathlib import Path
import re
import subprocess
import tarfile

ROOT = Path(__file__).resolve().parents[2]
FILES = {"VERSION", "LICENSE", "README.md", "deploy-moodle.sh", "activate.sh", "deactivate.sh",
"remove.sh", "list-projects.sh", "backup.sh", "restore.sh", "schedule-backup.sh", "doctor.sh"}
DIRECTORIES = {"lib", "moodle", "nginxproxy"}


def build(root: Path, output: Path) -> Path:
version = (root / "VERSION").read_text(encoding="ascii").strip()
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise ValueError("VERSION must contain an exact X.Y.Z release")
inventory = subprocess.run(["git", "-C", str(root), "ls-files", "-z"],
check=True, capture_output=True, timeout=30).stdout
names = sorted(name for name in inventory.decode("utf-8").split("\0") if name
and (name in FILES or name.split("/", 1)[0] in DIRECTORIES))
if not FILES.issubset(names):
raise ValueError("Required toolkit files are missing from Git's inventory")
output.mkdir(parents=True, exist_ok=True)
archive = output / f"mooship-{version}.tar.gz"
# Exclusive output prevents an accidental local overwrite; retries use a fresh directory.
with archive.open("xb") as destination, gzip.GzipFile(fileobj=destination, mode="wb", mtime=0, filename="") as gz:
with tarfile.open(fileobj=gz, mode="w|") as tar:
for name in names:
path = root / name
if path.is_symlink() or not path.is_file():
raise ValueError(f"Toolkit source must be a regular file: {name}")
content = path.read_bytes()
if path.suffix == ".sh":
content = content.replace(b"\r\n", b"\n")
member = tarfile.TarInfo(f"mooship-{version}/{name}")
member.size = len(content)
member.mode = 0o755 if path.suffix == ".sh" else 0o644
tar.addfile(member, io.BytesIO(content))
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
archive.with_name(archive.name + ".sha256").write_text(
f"{digest} {archive.name}\n", encoding="ascii",
)
return archive


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path)
args = parser.parse_args()
print(build(ROOT, args.output))


if __name__ == "__main__":
main()
29 changes: 29 additions & 0 deletions .github/scripts/check_image_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Refuse to overwrite existing Docker Hub release tags before building/pushing."""

import os
import re
from urllib.error import HTTPError
from urllib.request import urlopen


def require_new_tag(tag: str) -> None:
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+-moodle[0-9.]+-(?:php8\.3|nginx)", tag):
raise ValueError("Invalid Mooship release image tag")
try:
# Fixed HTTPS origin and validated tag prevent caller-supplied URL schemes.
with urlopen(f"https://hub.docker.com/v2/repositories/shellharbor/mooship/tags/{tag}", timeout=30): # nosec B310
pass
except HTTPError as exc:
if exc.code == 404:
return
raise
raise ValueError(f"Docker Hub tag already exists; refusing to overwrite {tag}")


def main() -> None:
for suffix in ("php8.3", "nginx"):
require_new_tag(f"{os.environ['VERSION']}-moodle{os.environ['MOODLE_VERSION']}-{suffix}")


if __name__ == "__main__":
main()
49 changes: 49 additions & 0 deletions .github/scripts/check_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Require matching release metadata and successful checks on this exact commit."""

import json
import os
from pathlib import Path
import re
import subprocess

REQUIRED = {"CI", "Configuration", "Component integration", "Security", "CodeQL", "Documentation",
"Moodle images", "Python package"}


def validate_tag(tag: str, version: str) -> None:
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) or tag != f"v{version}":
raise ValueError("Release tag must be vX.Y.Z and match VERSION")


def check_runs(runs: list, commit: str) -> None:
latest = {}
for run in sorted(runs, key=lambda item: item["id"], reverse=True):
if run["head_sha"] == commit:
latest.setdefault(run["name"], run)
failed = sorted(name for name in REQUIRED if name not in latest
or latest[name]["status"] != "completed" or latest[name]["conclusion"] != "success")
if failed:
raise ValueError("Missing successful exact-commit workflows: " + ", ".join(failed))


def command(*args: str) -> str:
return subprocess.run(list(args), check=True, capture_output=True, text=True, timeout=60).stdout.strip()


def main() -> None:
version = Path("VERSION").read_text(encoding="ascii").strip()
validate_tag(os.environ["RELEASE_TAG"], version)
commit = command("git", "rev-parse", "HEAD")
tagged = command("git", "rev-parse", "--verify", f"refs/tags/{os.environ['RELEASE_TAG']}^{{commit}}")
if tagged != commit:
raise ValueError("Checkout does not match the exact release tag commit")
runs = json.loads(command("gh", "api", f"repos/{os.environ['GITHUB_REPOSITORY']}/actions/runs?head_sha={commit}&per_page=100"))
check_runs(runs["workflow_runs"], commit)
source = Path("Dockerfile").read_text(encoding="utf-8")
moodle = re.search(r"^ARG MOODLE_VERSION=([0-9.]+)$", source, re.MULTILINE).group(1)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"commit={commit}\nversion={version}\nmoodle={moodle}\n")


if __name__ == "__main__":
main()
35 changes: 35 additions & 0 deletions .github/scripts/publish_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Upload missing native release assets and refuse conflicting existing content."""

import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile


def command(*args: str) -> str:
return subprocess.run(list(args), capture_output=True, text=True, check=True, timeout=120).stdout


def main() -> None:
tag = os.environ["RELEASE_TAG"]
assets = json.loads(command("gh", "release", "view", tag, "--json", "assets"))["assets"]
existing = {asset["name"] for asset in assets}
for path in sorted(Path(sys.argv[1]).iterdir()):
if not path.is_file() or not (path.name.endswith(".tar.gz") or path.name.endswith(".sha256")):
continue
if path.name in existing:
with tempfile.TemporaryDirectory() as directory:
command("gh", "release", "download", tag, "--pattern", path.name, "--dir", directory)
actual = hashlib.sha256((Path(directory) / path.name).read_bytes()).digest()
if actual != hashlib.sha256(path.read_bytes()).digest():
raise ValueError(f"Existing release asset differs; refusing to replace {path.name}")
else:
command("gh", "release", "upload", tag, str(path))
print(f"Verified release asset: {path.name}")


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ jobs:
- name: Install pinned CI tools
run: python -m pip install -r .github/requirements.txt
- name: Python lint
run: python -m ruff check lib tests .github/scripts
run: python -m ruff check lib src tests .github/scripts
- name: YAML syntax and duplicate keys
run: python -m yamllint .github
run: python -m yamllint .github docker deploy/k8s
- name: GitHub Actions syntax
run: docker run --rm --read-only --network none -v "$PWD:/repo:ro" -w /repo rhysd/actionlint:1.7.12 -color
- name: GitHub Actions security
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/configuration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check every PHP and nginx Dockerfile
- name: Check every Dockerfile
# Existing package-version/apt style warnings remain visible; errors fail the job.
run: |
while IFS= read -r -d '' file; do
echo "Checking $file"
docker run --rm --read-only --network none -i hadolint/hadolint:v2.15.1 hadolint --failure-threshold error - < "$file"
done < <(find moodle/.docker nginxproxy -name '*.Dockerfile' -print0)
done < <(git ls-files -z '*Dockerfile')
72 changes: 70 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,43 @@ jobs:

- name: Deploy
run: >
gateway=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}') &&
sudo bash deploy-moodle.sh --slug "$SLUG" --domain "$DOMAIN" --no-ssl
--db-type "${{ matrix.db }}" --moodle-version "${{ matrix.moodle }}" --profile small
--smtp-host 172.17.0.1:1025 --smtp-secure none --noreply-email "noreply@${DOMAIN}"
--smtp-host "${gateway}:1025" --smtp-secure none --noreply-email "noreply@${DOMAIN}"

- name: Login page through nginxproxy
run: |
curl --fail --silent --show-error --max-time 60 "http://${DOMAIN}/login/index.php" -o login.html
grep -q 'name="username"' login.html

- name: Resolve the synthetic site domain for Moodle self-checks
# The runner's /etc/hosts is not inherited by containers. Moodle 5.3's
# router check makes HTTP requests from PHP to the public wwwroot.
run: |
docker network create --internal mooship-e2e-self-checks
docker network connect --alias "$DOMAIN" mooship-e2e-self-checks nginxproxy
docker network connect mooship-e2e-self-checks "${SLUG}_php"
docker exec -e MOOSHIP_TEST_DOMAIN="$DOMAIN" "${SLUG}_php" php -r '
$host = getenv("MOOSHIP_TEST_DOMAIN");
exit(gethostbyname($host) === $host ? 1 : 0);
'

- name: Moodle router HTTP responses
if: matrix.moodle == '5.3'
run: |
curl --fail --silent --show-error --max-time 60 "http://${DOMAIN}/core/check/controller/test" -o /dev/null
curl --fail-with-body --silent --show-error --max-time 60 "http://${DOMAIN}/api/rest/v2/openapi.json" -o openapi-response.json || {
cat openapi-response.json
exit 1
}
code=$(curl --silent --show-error --max-time 60 -o /dev/null -w '%{http_code}' "http://${DOMAIN}/not/a/valid/request")
test "$code" = 404
for path in /lib/exampleshimroute.php /lib/exampleshimroute2.php; do
code=$(curl --silent --show-error --max-time 60 -o /dev/null -w '%{http_code}' "http://${DOMAIN}${path}")
test "$code" = 302
done

# The project directory is private to root (mode 700)
- name: Moodle configuration, sessions in Redis, and cron
run: |
Expand All @@ -63,9 +91,21 @@ jobs:
password=$(grep "^REDIS_PASSWORD=" .env | cut -d= -f2-)
docker compose exec -T -e REDISCLI_AUTH="$password" redis \
redis-cli --scan --pattern "mdl_session_*" | grep -q .
docker compose exec -T php php /var/www/html/admin/cli/cron.php > /dev/null
docker compose exec -T php php /var/www/html/admin/cli/cron.php --keep-alive=0 > /dev/null
'

- name: Native DB routing and installation-state checks
# Native host routing uses disposable DB containers, never the runner's own databases.
# The normal deployment above has already exercised the container-DB path.
env:
MOOSHIP_NATIVE_PREFLIGHT_TESTS: '1'
MOOSHIP_PREFLIGHT_DB: ${{ matrix.db }}
PYTHONPATH: tests
run: |
export MOOSHIP_PREFLIGHT_PHP_IMAGE
MOOSHIP_PREFLIGHT_PHP_IMAGE=$(sudo docker inspect --format '{{.Image}}' e2e_php)
python3 -B -m unittest test_native_preflight -v

- name: Mail delivery through SMTP
run: |
sudo docker exec -i e2e_php php <<'PHP'
Expand All @@ -88,6 +128,15 @@ jobs:
test ! -e "/etc/systemd/system/mooship-backup-${SLUG}.timer"

# Warnings are expected on a test runner (no certificate, Moodle notices); failures are not
- name: Wait for services resumed after backups
run: |
sudo bash -euo pipefail -c '
source ./deploy-moodle.sh
SLUG=e2e; PROJECT_DIR=/var/www/e2e
cd "$PROJECT_DIR"
for service in php nginx redis cron certbot_renew; do mooship_wait_service "$service"; done
'

- name: Doctor
run: |
status=0
Expand All @@ -101,3 +150,22 @@ jobs:
sudo bash -c 'cd /var/www/e2e && docker compose logs --tail 200' || true
sudo docker logs --tail 100 nginxproxy || true
docker logs --tail 50 mailpit || true
sudo docker exec e2e_php php /var/www/html/admin/cli/checks.php --type=status -v || true
sudo docker exec e2e_php php -r '
define("CLI_SCRIPT", true);
require("/var/www/html/config.php");
$check = new \core\check\environment\router();
if (method_exists($check, "get_results")) {
foreach ($check->get_results() as $result) {
echo $result->get_summary(), "\n", $result->get_details(), "\n";
}
}
if (class_exists(\core\router\apidocs::class)) {
try {
(new \core\router\apidocs())->openapi_docs(new \GuzzleHttp\Psr7\Response());
} catch (\Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n" . $error->getTraceAsString() . "\n");
exit(1);
}
}
' || true
Loading